mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-26 15:52:21 +01:00
Compare commits
302
Commits
a172522d87
...
ffd0aaf69b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffd0aaf69b | ||
|
|
81e7074d93 | ||
|
|
f66a23d19d | ||
|
|
f602819523 | ||
|
|
85a773ea02 | ||
|
|
fb0a64fe4f | ||
|
|
bcf46dafb9 | ||
|
|
b118c33e37 | ||
|
|
da74cc23e4 | ||
|
|
d792b61722 | ||
|
|
1faadf7e10 | ||
|
|
e87b44126c | ||
|
|
62476ddb55 | ||
|
|
e899817969 | ||
|
|
1cc9a003fd | ||
|
|
f7aa2de410 | ||
|
|
514d345334 | ||
|
|
6d507f8128 | ||
|
|
2cbd55b8bd | ||
|
|
cd02ac7ef6 | ||
|
|
e7abb7559d | ||
|
|
172a8ea7b0 | ||
|
|
2adae2bbba | ||
|
|
f5d3e5098a | ||
|
|
4ee5ed4dce | ||
|
|
f2bfe9b91f | ||
|
|
3f3c05e8c2 | ||
|
|
2e9f641c2c | ||
|
|
627a52ac44 | ||
|
|
397fce6e32 | ||
|
|
10cc2295e5 | ||
|
|
933ec8fec9 | ||
|
|
8fe98cf471 | ||
|
|
55b4a5e6ff | ||
|
|
3c0e9fcb25 | ||
|
|
d5de061656 | ||
|
|
8b157f452c | ||
|
|
daec3604f3 | ||
|
|
e75a52efbb | ||
|
|
f28703adf6 | ||
|
|
d8e7cc7053 | ||
|
|
f7e2d0c0b7 | ||
|
|
b5a7d5ccda | ||
|
|
f7a5047228 | ||
|
|
4ccb7c4890 | ||
|
|
1aa5ffb57c | ||
|
|
0939983ddf | ||
|
|
ffc0f1dccc | ||
|
|
57646300a4 | ||
|
|
f23e2e6ffb | ||
|
|
955455b797 | ||
|
|
674457384a | ||
|
|
2cf8bd14ae | ||
|
|
1fcec32a3c | ||
|
|
768bcb565a | ||
|
|
63b4ad2e9c | ||
|
|
d70eb99a0d | ||
|
|
d44de3af43 | ||
|
|
25dd94234c | ||
|
|
600fa6be8a | ||
|
|
781a3ee829 | ||
|
|
a9de61771a | ||
|
|
9873f9b44f | ||
|
|
09a82852c0 | ||
|
|
4074e77d93 | ||
|
|
d3944be1be | ||
|
|
ce964b9a00 | ||
|
|
1d7d9c5e9c | ||
|
|
adac89c8e2 | ||
|
|
65a2e51af8 | ||
|
|
04a97adbb3 | ||
|
|
8829ae2675 | ||
|
|
09a1718103 | ||
|
|
f03a9e79a7 | ||
|
|
bb66914b1e | ||
|
|
8053d6a50a | ||
|
|
7cbf5a2c00 | ||
|
|
0895c70fc9 | ||
|
|
16c41612ca | ||
|
|
7ef3e353c6 | ||
|
|
10b9e6b81f | ||
|
|
360ce696e0 | ||
|
|
0548d335d4 | ||
|
|
79d55b46a6 | ||
|
|
93c0529e00 | ||
|
|
a29c2b25d0 | ||
|
|
654f9f82c7 | ||
|
|
45b3cd15df | ||
|
|
d006e38a2f | ||
|
|
438db357ff | ||
|
|
3ff4eb5519 | ||
|
|
f34cb42b07 | ||
|
|
ac4de93928 | ||
|
|
6763fe4d44 | ||
|
|
44a60c1261 | ||
|
|
f09f606bec | ||
|
|
e6349c016e | ||
|
|
e630605aef | ||
|
|
74e563dabc | ||
|
|
ae0b29af3d | ||
|
|
d68c75a82c | ||
|
|
a615f7f786 | ||
|
|
0808de0b3b | ||
|
|
aba3a7ae43 | ||
|
|
fa3adca5fc | ||
|
|
f78084c230 | ||
|
|
7004e1de7b | ||
|
|
e2a30c0600 | ||
|
|
eb0abe7c90 | ||
|
|
c822d34ce6 | ||
|
|
0889eb4e01 | ||
|
|
77f00eeab1 | ||
|
|
86daf254cf | ||
|
|
9ea3a250db | ||
|
|
c537d2b95c | ||
|
|
f538da9a8e | ||
|
|
015aeb1fab | ||
|
|
0d27480719 | ||
|
|
81a9a1fed3 | ||
|
|
a01ca5a0a1 | ||
|
|
3239430996 | ||
|
|
65ead1f799 | ||
|
|
6cc45a4f77 | ||
|
|
f6c4c9a67c | ||
|
|
10a25f5959 | ||
|
|
a57327c13f | ||
|
|
37e49246a6 | ||
|
|
0351e5e166 | ||
|
|
98c05dd08d | ||
|
|
4811af7ab2 | ||
|
|
ba17829202 | ||
|
|
8f696064d5 | ||
|
|
3819a23344 | ||
|
|
cedc38fee8 | ||
|
|
198af4709d | ||
|
|
696ff78302 | ||
|
|
f2da86b455 | ||
|
|
5212758698 | ||
|
|
9e73912d24 | ||
|
|
6d328b1ad7 | ||
|
|
27c92caee8 | ||
|
|
85966881d3 | ||
|
|
dc170b1f58 | ||
|
|
37269fd96a | ||
|
|
e832133e47 | ||
|
|
51a41c0c30 | ||
|
|
8b8ec7fb1d | ||
|
|
8f4747b1ff | ||
|
|
be7b3d796c | ||
|
|
760c8ef72c | ||
|
|
3c4fb62d3a | ||
|
|
cc86c3dd04 | ||
|
|
32898a68eb | ||
|
|
55e438d18c | ||
|
|
a653f74cab | ||
|
|
4913a1363b | ||
|
|
6edcc07c1b | ||
|
|
7369c7c642 | ||
|
|
7db4e8df4a | ||
|
|
5d5cfc07d7 | ||
|
|
d592b1e6af | ||
|
|
b5449ea3f9 | ||
|
|
73dbf3cde7 | ||
|
|
debd2cd386 | ||
|
|
d95abaff1b | ||
|
|
13086c3662 | ||
|
|
5719e4db5f | ||
|
|
9ac3f40955 | ||
|
|
3a5c58da75 | ||
|
|
7cf3402ef4 | ||
|
|
6066d0af02 | ||
|
|
7e029db44a | ||
|
|
f569b9394e | ||
|
|
fce9942ae0 | ||
|
|
93ae65f99f | ||
|
|
f8d3890e6a | ||
|
|
85a11ad416 | ||
|
|
2be0c5c892 | ||
|
|
e0af7bd8a0 | ||
|
|
1d1678214a | ||
|
|
06899c669c | ||
|
|
05f05dd372 | ||
|
|
a195f4f194 | ||
|
|
28caa40e68 | ||
|
|
6c1ce446f5 | ||
|
|
729494a59b | ||
|
|
df69bced42 | ||
|
|
12c8f9637f | ||
|
|
7fe8a70032 | ||
|
|
2e8e097683 | ||
|
|
24dfd04964 | ||
|
|
86965950ac | ||
|
|
79e9225c68 | ||
|
|
1a3880347f | ||
|
|
20968d5a87 | ||
|
|
a7200dd39b | ||
|
|
d1f732bae1 | ||
|
|
d849189b8c | ||
|
|
d30b2d11e6 | ||
|
|
156009f9ad | ||
|
|
0aa8d17d6c | ||
|
|
39331fafb5 | ||
|
|
05f87b0f50 | ||
|
|
9f1435f761 | ||
|
|
772ddf4a86 | ||
|
|
432b41cede | ||
|
|
e7466175ef | ||
|
|
5bf7caecc9 | ||
|
|
4bf389ed09 | ||
|
|
90acad0d4b | ||
|
|
6e6b860f04 | ||
|
|
e4c7a3aad9 | ||
|
|
ac4627b69d | ||
|
|
99660e1c6d | ||
|
|
f91f37ef70 | ||
|
|
682ec11003 | ||
|
|
41c0ffbb52 | ||
|
|
be430fc4a4 | ||
|
|
15f2b106ab | ||
|
|
e310336a42 | ||
|
|
e1585aa4aa | ||
|
|
6a392542f3 | ||
|
|
7b3bc598f4 | ||
|
|
239cc02422 | ||
|
|
44f12f266e | ||
|
|
8e8ce8ddd6 | ||
|
|
f2ccf8b21f | ||
|
|
5d9d21f227 | ||
|
|
537f492762 | ||
|
|
6a0a7622fd | ||
|
|
719867a819 | ||
|
|
9dfea188bf | ||
|
|
df908b4c11 | ||
|
|
be126afcf8 | ||
|
|
b2243efd3f | ||
|
|
79c04c71e9 | ||
|
|
ebd2332db4 | ||
|
|
070ec4c711 | ||
|
|
6fc79e90ac | ||
|
|
f5ad59317c | ||
|
|
803df21fc2 | ||
|
|
df47536b8d | ||
|
|
2049eb7713 | ||
|
|
f42cee8512 | ||
|
|
8a00f954a9 | ||
|
|
6d1d626d87 | ||
|
|
8632072ce0 | ||
|
|
c637b5057b | ||
|
|
153b788134 | ||
|
|
bc2d934b94 | ||
|
|
2b1e2e9e20 | ||
|
|
b5b96980e3 | ||
|
|
127745d13b | ||
|
|
5ec1e12a50 | ||
|
|
7c1af0385a | ||
|
|
dde2d25804 | ||
|
|
7f71fbc3ea | ||
|
|
7017127a11 | ||
|
|
00643b5a4b | ||
|
|
e25c279e4b | ||
|
|
df54d8d2bf | ||
|
|
8ae31aeb13 | ||
|
|
cc86760a26 | ||
|
|
2e7cfbe1fa | ||
|
|
9dbe31bfb0 | ||
|
|
2bf372b41c | ||
|
|
a86990fc58 | ||
|
|
f4c1b264c6 | ||
|
|
031a600725 | ||
|
|
b385b25d5f | ||
|
|
49b72bd09c | ||
|
|
0a3333b961 | ||
|
|
1638db9c86 | ||
|
|
cd9ad1a7f2 | ||
|
|
023f1ba575 | ||
|
|
1a4659b7fc | ||
|
|
965b0e143c | ||
|
|
1eca28e588 | ||
|
|
a80421efb6 | ||
|
|
89efd7d44b | ||
|
|
41980df6f1 | ||
|
|
baa4449a03 | ||
|
|
1ee51be420 | ||
|
|
94931ba59f | ||
|
|
49ecd806a2 | ||
|
|
1eaa5c2a81 | ||
|
|
e107c5876e | ||
|
|
4f7061fd61 | ||
|
|
7690860ab1 | ||
|
|
b6366e9da5 | ||
|
|
64122269e9 | ||
|
|
1bdd515941 | ||
|
|
8ac0ae72dc | ||
|
|
b2458f9891 | ||
|
|
2252776a97 | ||
|
|
c9fecd53dc | ||
|
|
75268e7f43 | ||
|
|
8ef9b8b215 | ||
|
|
459b825daa | ||
|
|
3247773447 | ||
|
|
013beab861 | ||
|
|
c5230e85a9 |
@@ -190,3 +190,10 @@ SEARXNG_INSTANCE=http://localhost:8080
|
||||
# These overlays only expose the GPU devices. The slim Odysseus image
|
||||
# still needs CUDA/ROCm userspace via Cookbook -> Dependencies (vLLM,
|
||||
# llama-cpp-python, etc.) before models can actually serve on GPU.
|
||||
|
||||
# ============================================================
|
||||
# Storage Paths (Docker Compose)
|
||||
# ============================================================
|
||||
|
||||
# APP_DATA_DIR=./data
|
||||
# APP_LOGS_DIR=./logs
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
# Pull Request Review Template
|
||||
|
||||
Use this shape as a copyable reference for substantive PR reviews; GitHub does
|
||||
not auto-apply this file to review comments. Omit sections that do not add
|
||||
useful signal. Lead with confirmed findings; keep speculative notes out of the
|
||||
public review unless they are framed as a concrete open question.
|
||||
|
||||
## Small PR Path
|
||||
|
||||
For narrow docs, typo, test-only, or obvious local fixes, a short review is
|
||||
enough:
|
||||
|
||||
```md
|
||||
LGTM after checking:
|
||||
- scope:
|
||||
- validation:
|
||||
- residual risk:
|
||||
```
|
||||
|
||||
Use the fuller structure below for larger, risky, multi-finding, or
|
||||
security-sensitive reviews.
|
||||
|
||||
## Findings
|
||||
|
||||
**<sub><sub></sub></sub> issue (test): Short issue title**
|
||||
|
||||
- **Problem:** Concrete broken flow, contract, input, or risk.
|
||||
|
||||
- **Impact:** Why this matters to users, CI, maintainers, data, security, or scale.
|
||||
|
||||
- **Ask:** Smallest practical correction or decision the author should make.
|
||||
|
||||
- **Location:** `path:line`
|
||||
|
||||
## Open Questions
|
||||
|
||||
- **question (scope, non-blocking): Short author question** Ask the concrete
|
||||
intent, scope, or tradeoff question.
|
||||
|
||||
## Validation
|
||||
|
||||
- Ran:
|
||||
- Not run:
|
||||
- Residual risk:
|
||||
|
||||
## PR Hygiene
|
||||
|
||||
- Target/template/checks:
|
||||
- Related, duplicate, or superseding context:
|
||||
|
||||
## No Findings Variant
|
||||
|
||||
```md
|
||||
## Findings
|
||||
|
||||
none confirmed
|
||||
|
||||
## Validation
|
||||
|
||||
- Ran:
|
||||
- Not run:
|
||||
- Residual risk:
|
||||
```
|
||||
|
||||
## Legend
|
||||
|
||||
- **Findings:** Verified, author-actionable issues that should be fixed or
|
||||
consciously accepted before merge.
|
||||
- **Priority badges:** The shields.io badges below are optional formatting for
|
||||
priority labels. Plain `P0`, `P1`, `P2`, or `P3` text is also acceptable when
|
||||
an external image dependency is undesirable or may not render.
|
||||
- **P0:** `` -
|
||||
release-blocking or actively dangerous.
|
||||
- **P1:** `` -
|
||||
serious bug, security risk, data-loss risk, or broken primary flow.
|
||||
- **P2:** `` -
|
||||
meaningful correctness, test, maintainability, or edge-case issue.
|
||||
- **P3:** `` -
|
||||
minor polish or low-risk cleanup.
|
||||
- **Intent labels:**
|
||||
- **`issue`:** A confirmed defect, regression, broken contract, or concrete
|
||||
risk.
|
||||
- **`suggestion`:** A non-blocking improvement that would make the PR clearer,
|
||||
safer, or easier to maintain.
|
||||
- **`nit`:** A tiny, non-blocking cleanup or style note. Use it only when the
|
||||
author can safely ignore it without changing the review outcome.
|
||||
- **`question`:** A real author-facing clarification about intent, scope, or
|
||||
tradeoffs. Do not use questions to hide an issue that should be stated
|
||||
directly.
|
||||
- **`LGTM`:** "Looks good to me." Use only when the review found no blocking
|
||||
issues, or when any remaining notes are clearly optional.
|
||||
- **Decorations:** Optional labels in parentheses that clarify the finding type,
|
||||
scope, or merge impact.
|
||||
- **`security`:** Auth, authorization, ownership, secrets, SSRF, injection,
|
||||
unsafe external input, or other trust-boundary concerns.
|
||||
- **`test`:** Missing, failing, misleading, brittle, or insufficient tests.
|
||||
- **`scope`:** PR scope, feature boundaries, unrelated churn, or work that
|
||||
should be split into a separate issue or PR.
|
||||
- **`ci`:** CI configuration, workflow failures, flaky checks, or validation
|
||||
signal quality.
|
||||
- **`api`:** Route, request/response, public function, schema, persistence, or
|
||||
integration contract changes.
|
||||
- **`docs`:** User-facing docs, contributor docs, examples, or comments that
|
||||
need to change with the code.
|
||||
- **`non-blocking`:** Useful feedback that should not prevent merge by
|
||||
itself.
|
||||
- **Finding fields:**
|
||||
- **Problem:** What is wrong, what contract is ambiguous, or what risk the PR
|
||||
introduces.
|
||||
- **Impact:** Why the problem matters in practical terms.
|
||||
- **Ask:** The smallest concrete fix, test, or decision requested from the PR
|
||||
author.
|
||||
- **Location:** The most useful repo-relative file and line reference for the
|
||||
finding, using `path:line`.
|
||||
- **Optional sections:**
|
||||
- **Open Questions:** Genuine scope or intent questions; omit when there are
|
||||
no real questions.
|
||||
- **Validation:** What the reviewer ran, what was intentionally not run, and
|
||||
what risk remains after review.
|
||||
- **PR Hygiene:** Target-branch, template, CI/check, duplicate, related-work,
|
||||
or superseding-PR notes.
|
||||
- **`none confirmed`:** Use only when no review-worthy findings were confirmed;
|
||||
still list validation gaps or residual risk when relevant.
|
||||
@@ -19,10 +19,10 @@ jobs:
|
||||
name: Python syntax (compileall)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
# Byte-compile sources — catches syntax errors without installing deps.
|
||||
@@ -32,10 +32,10 @@ jobs:
|
||||
name: JS syntax (node --check)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "20"
|
||||
# Syntax-check our own JS (skip vendored libs in static/lib).
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
# ROADMAP "fresh install smoke tests" item; make this required once green.
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -81,7 +81,7 @@ jobs:
|
||||
echo "docs_only=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
if: steps.docs-check.outputs.docs_only != 'true'
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
# CodeQL code scanning
|
||||
#
|
||||
# Purpose: GitHub's own static analysis engine reads the application source
|
||||
# (Python backend + the JavaScript frontend) and looks for real
|
||||
# vulnerabilities -- SQL/command injection, path traversal, auth mistakes,
|
||||
# unsafe deserialization. Findings appear in the repo's Security tab. This is
|
||||
# the deepest check in the suite and the most valuable for a high-profile
|
||||
# target.
|
||||
#
|
||||
# It runs on every push to main and on a weekly schedule (to catch newly
|
||||
# disclosed query patterns against unchanged code). It deliberately does NOT
|
||||
# run on pull requests: most PRs here come from forks, whose read-only token
|
||||
# cannot publish results, which would produce confusing failures. To scan pull
|
||||
# requests too, a maintainer can instead enable CodeQL "default setup" in
|
||||
# Settings -> Security -> Code scanning (one toggle, no file needed) -- see
|
||||
# docs/security-ci.md.
|
||||
|
||||
name: CodeQL
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
schedule:
|
||||
# Weekly, Monday 06:00 UTC.
|
||||
- cron: '0 6 * * 1'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: codeql-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze (${{ matrix.language }})
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write # publish results to the Security tab
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Both are interpreted, so CodeQL needs no build step (build-mode none).
|
||||
language: [python, javascript-typescript]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@03e4368ac7daa2bd82b3e85262f3bf87ee112f57 # v3.36.0
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: none
|
||||
|
||||
- name: Perform CodeQL analysis
|
||||
uses: github/codeql-action/analyze@03e4368ac7daa2bd82b3e85262f3bf87ee112f57 # v3.36.0
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -93,7 +93,7 @@ jobs:
|
||||
security-events: write # upload SARIF to the Security tab
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -119,7 +119,7 @@ jobs:
|
||||
TRIVY_DB_REPOSITORY: ghcr.io/aquasecurity/trivy-db:2
|
||||
|
||||
- name: Upload Trivy results
|
||||
uses: github/codeql-action/upload-sarif@03e4368ac7daa2bd82b3e85262f3bf87ee112f57 # v3.36.0
|
||||
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
with:
|
||||
sarif_file: trivy-results.sarif
|
||||
category: trivy-image
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
# Full history so a secret committed in an earlier commit (and later
|
||||
# deleted) is still caught -- deletion does not remove it from Git.
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -70,6 +70,9 @@ output.txt.txt
|
||||
*.tiff
|
||||
*.pdf
|
||||
|
||||
# …except shipped static assets
|
||||
!static/icons/*.png
|
||||
|
||||
# …except shipped demo assets in docs/ that the README links to.
|
||||
!docs/*.jpg
|
||||
!docs/*.jpeg
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM python:3.12-slim
|
||||
FROM python:3.14-slim
|
||||
|
||||
# System deps. tmux is required by Cookbook for background downloads/serves.
|
||||
# openssh-client is required for Cookbook remote server tests, setup, probes,
|
||||
|
||||
@@ -1,469 +1,65 @@
|
||||
# Odysseus
|
||||
<p align="center">
|
||||
<img src="docs/odysseus-wordmark.png" alt="Odysseus" width="280">
|
||||
</p>
|
||||
|
||||
> **Branch note:** `dev` is the default branch and contains the latest development changes, but it may be unstable. For the more stable curated branch, use [`main`](https://github.com/pewdiepie-archdaemon/odysseus/tree/main).
|
||||
<p align="center">
|
||||
A self-hosted AI workspace for chat, agents, research, documents, email, notes, calendar, and local model workflows.
|
||||
</p>
|
||||
|
||||
```
|
||||
───────────────────────────────────────────────
|
||||
⊹ ࣪ ˖ ૮( ˶ᵔ ᵕ ᵔ˶ )っ Odysseus vers. 1.0
|
||||
───────────────────────────────────────────────
|
||||
```
|
||||
<p align="center">
|
||||
<a href="#quick-start">Quick Start</a> ·
|
||||
<a href="docs/setup.md">Setup Guide</a> ·
|
||||
<a href="CONTRIBUTING.md">Contributing</a> ·
|
||||
<a href="ROADMAP.md">Roadmap</a>
|
||||
</p>
|
||||
|
||||

|
||||
<p align="center">
|
||||
<a href="https://repology.org/project/odysseus-ai/versions"><img src="https://repology.org/badge/vertical-allrepos/odysseus-ai.svg" alt="Packaging status"></a>
|
||||
</p>
|
||||
|
||||
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.
|
||||
<p align="center">
|
||||
<img src="docs/odysseus.jpg" alt="Odysseus interface">
|
||||
</p>
|
||||
|
||||
## Features
|
||||
- **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>
|
||||
- **Compare** -- a fun tool to compare models side by side. Test completely blind, no bias!<br> <sub>multi-model · blind test · synthesis</sub>
|
||||
- **Documents** -- YOU write the text, AI is there to assist, not the opposite.<br> <sub>multi-tab editor · markdown · HTML · CSV · syntax highlighting · AI edits · suggestions</sub>
|
||||
- **Memory / Skills** -- Persistent memory and skills, your agent evolves over time as it better understands you and your tasks!<br> <sub>ChromaDB · fastembed (ONNX) · vector + keyword retrieval · import/export</sub>
|
||||
- **Email** -- IMAP/SMTP inbox with AI triage built in: urgency reminders, auto-tag, auto-summary, auto-reply drafts, auto-spam.<br> <sub>IMAP · SMTP · per-account routing · CalDAV-aware</sub>
|
||||
- **Notes & Tasks** -- Quick notes with reminders, a todo list, and scheduled tasks the agent can act on.<br> <sub>note pings · checklist · cron-style tasks · ntfy / browser / email channels</sub>
|
||||
- **Calendar** -- Local-first calendar with CalDAV sync to Radicale / Nextcloud / Apple / Fastmail.<br> <sub>CalDAV pull · .ics import/export · per-calendar colors · agent-aware</sub>
|
||||
- **Works on mobile** -- looks and runs great on your phone, not just desktop.<br> <sub>responsive · installable (PWA) · touch gestures</sub>
|
||||
- **Extras** -- more to explore, happy if you give it a go!<br> <sub>image editor · theme editor · file uploads (vision + PDF) · web search · presets · sessions · 2FA</sub>
|
||||
|
||||
## Demo
|
||||
A full, hover-to-play tour lives on the landing page (`docs/index.html`).
|
||||
|
||||
<details>
|
||||
<summary>Screenshots / clips</summary>
|
||||
|
||||
### Chat & Agents
|
||||

|
||||
### Deep Research
|
||||

|
||||
### Compare
|
||||

|
||||
### Documents
|
||||

|
||||
### Notes & Tasks
|
||||

|
||||
|
||||
</details>
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
Defaults work out of the box: clone, run, then configure models/search/email
|
||||
inside **Settings**. Only edit `.env` for deployment-level overrides like
|
||||
`APP_BIND`, `APP_PORT`, `AUTH_ENABLED`, `DATABASE_URL`, or a pre-seeded admin password.
|
||||
> `dev` is the default branch and gets the newest changes first. Use [`main`](https://github.com/pewdiepie-archdaemon/odysseus/tree/main) if you want the more curated branch.
|
||||
|
||||
On first setup, Odysseus creates an admin account (`admin` unless
|
||||
`ODYSSEUS_ADMIN_USER` is set) and prints a temporary password in the terminal.
|
||||
For Docker installs, the same line is in `docker compose logs odysseus`.
|
||||
Use that for the first login, then change it in **Settings**.
|
||||
|
||||
Contributing? See [CONTRIBUTING.md](CONTRIBUTING.md) for setup, testing, and
|
||||
pull request guidelines.
|
||||
|
||||
### Docker (recommended)
|
||||
```bash
|
||||
git clone https://github.com/pewdiepie-archdaemon/odysseus.git
|
||||
cd odysseus
|
||||
cp .env.example .env # optional, but recommended for explicit defaults
|
||||
cp .env.example .env
|
||||
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`
|
||||
only when you intentionally want LAN/reverse-proxy access.
|
||||
Open `http://localhost:7000` when the containers are healthy. The first admin password is printed in `docker compose logs odysseus`.
|
||||
|
||||
> **On Apple Silicon (M-series) Macs:** Docker can't reach the Metal GPU, so
|
||||
> Cookbook serves local models on CPU only. For GPU-accelerated model serving,
|
||||
> run natively instead — see [Apple Silicon](#apple-silicon) below.
|
||||
Native installs, GPU notes, Windows/macOS instructions, HTTPS, and configuration live in the [setup guide](docs/setup.md).
|
||||
|
||||
### Native Linux / macOS
|
||||
```bash
|
||||
git clone https://github.com/pewdiepie-archdaemon/odysseus.git
|
||||
cd odysseus
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
python setup.py
|
||||
python -m uvicorn app:app --host 127.0.0.1 --port 7000
|
||||
```
|
||||
Requirements: Python 3.11+. Cookbook also needs `tmux` for background model
|
||||
downloads and serves. The app itself is lightweight; local model serving is the
|
||||
heavy part and depends on the model, runtime, GPU, and VRAM, so small hosts can
|
||||
connect to API or remote model servers instead. Use `--host 0.0.0.0` only when you intentionally want LAN/reverse-proxy access.
|
||||
## Features
|
||||
|
||||
### Apple Silicon
|
||||
Docker on macOS cannot use the Metal GPU. For GPU-accelerated Cookbook on an
|
||||
M-series Mac, run Odysseus natively:
|
||||
- **Chat + Agents** — local/API models, tools, MCP, files, shell, skills, and memory.
|
||||
- **Cookbook** — hardware-aware model recommendations, downloads, and serving.
|
||||
- **Deep Research** — multi-step web research with source reading and report generation.
|
||||
- **Compare** — blind side-by-side model testing and synthesis.
|
||||
- **Documents** — writing-first editor with AI edits, suggestions, Markdown, HTML, CSV, and syntax highlighting.
|
||||
- **Email** — IMAP/SMTP inbox with triage, tags, summaries, reminders, and reply drafts.
|
||||
- **Notes, Tasks + Calendar** — reminders, todos, scheduled agent tasks, and CalDAV sync.
|
||||
- **Extras** — gallery/image editor, themes, uploads, web search, presets, sessions, and 2FA.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/pewdiepie-archdaemon/odysseus.git
|
||||
cd odysseus
|
||||
./start-macos.sh
|
||||
```
|
||||
## Demo
|
||||
|
||||
It launches at `http://127.0.0.1:7860`. To expose it to your phone over a trusted LAN/VPN such as Tailscale, bind all interfaces:
|
||||
|
||||
```bash
|
||||
ODYSSEUS_HOST=0.0.0.0 ./start-macos.sh
|
||||
# then open http://<tailscale-ip>:7860
|
||||
```
|
||||
|
||||
The script also reads `.env` at startup, so `APP_BIND=0.0.0.0` and `APP_PORT`
|
||||
set there are picked up automatically without a command-line override each run.
|
||||
|
||||
Keep `AUTH_ENABLED=true` (the default) before binding outside loopback. Do not
|
||||
expose this port directly to the public internet. To build a clickable app wrapper:
|
||||
|
||||
```bash
|
||||
./build-macos-app.sh
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Cookbook, GPU, Ollama, and troubleshooting notes</summary>
|
||||
|
||||
**Docker bundled services.** Compose starts Odysseus, ChromaDB, SearXNG, and
|
||||
ntfy. Odysseus and the bundled service ports bind to `127.0.0.1` by default, so
|
||||
they are reachable from the host but not exposed to your LAN/public internet
|
||||
unless you opt in.
|
||||
|
||||
**Cookbook storage in Docker.** Downloads live in `./data/huggingface`
|
||||
(`~/.cache/huggingface` in the container). Cookbook-installed Python CLIs and
|
||||
serve engines live in `./data/local` (`~/.local` in the container), so they
|
||||
survive container recreation.
|
||||
|
||||
**Remote servers.** In **Cookbook -> Settings -> Servers**, generate the
|
||||
Odysseus SSH key and add the public key to the remote server's
|
||||
`~/.ssh/authorized_keys`. From the host you can also run:
|
||||
|
||||
```bash
|
||||
ssh-copy-id -i data/ssh/id_ed25519.pub user@server
|
||||
```
|
||||
|
||||
**Docker GPU overlays.** CPU-only users can skip this section. Cookbook can
|
||||
only detect GPUs that Docker exposes to the container — if the host runtime or
|
||||
device passthrough is not configured, Cookbook sees the iGPU, another card, or
|
||||
CPU instead of your intended GPU.
|
||||
|
||||
For NVIDIA, `scripts/check-docker-gpu.sh` diagnoses GPU passthrough and can
|
||||
optionally install the host runtime or update `.env`.
|
||||
|
||||
```bash
|
||||
# Read-only diagnostic (default — installs nothing, never edits .env):
|
||||
scripts/check-docker-gpu.sh
|
||||
|
||||
# Print OS-specific install commands without running them:
|
||||
scripts/check-docker-gpu.sh --print-install-commands
|
||||
|
||||
# Install NVIDIA Container Toolkit on Ubuntu/Debian (requires sudo):
|
||||
scripts/check-docker-gpu.sh --install-nvidia-toolkit
|
||||
|
||||
# Write COMPOSE_FILE to .env (only when GPU passthrough is confirmed working):
|
||||
scripts/check-docker-gpu.sh --enable-nvidia-overlay
|
||||
|
||||
# Full assisted setup — install toolkit, then enable overlay if passthrough works:
|
||||
scripts/check-docker-gpu.sh --install-nvidia-toolkit --enable-nvidia-overlay
|
||||
```
|
||||
|
||||
Safety notes:
|
||||
- The app never installs host GPU runtime automatically.
|
||||
- The app never edits `.env` automatically.
|
||||
- `.env` is only modified when `--enable-nvidia-overlay` is explicitly passed,
|
||||
and only after GPU passthrough succeeds. `--yes` skips prompts but does not
|
||||
bypass the passthrough gate.
|
||||
- `.env.bak.*` backups created by `--enable-nvidia-overlay` are ignored by
|
||||
Git and the Docker build context.
|
||||
|
||||
To enable manually without the script, add this to `.env`:
|
||||
|
||||
```bash
|
||||
COMPOSE_FILE=docker-compose.yml:docker/gpu.nvidia.yml
|
||||
```
|
||||
|
||||
**AMD / ROCm.** AMD setup is read-only diagnostic plus manual `.env` edit. Run:
|
||||
|
||||
```bash
|
||||
scripts/check-docker-amd-gpu.sh
|
||||
```
|
||||
|
||||
Then add the reported values to `.env`, replacing `RENDER_GID` with your host's
|
||||
numeric render group id:
|
||||
|
||||
```bash
|
||||
COMPOSE_FILE=docker-compose.yml:docker/gpu.amd.yml
|
||||
RENDER_GID=989
|
||||
```
|
||||
|
||||
For NVIDIA/AMD GPU support, also read the comments in the selected overlay file: docker/gpu.nvidia.yml or docker/gpu.amd.yml.
|
||||
|
||||
**Stack-management UIs (Portainer, Coolify, Dockhand, etc.).** These tools
|
||||
often accept only a single Compose file and do not reliably honor `COMPOSE_FILE`
|
||||
or multiple `-f` overlays. CLI users should keep using the `COMPOSE_FILE`
|
||||
overlay workflow above. For stack UIs, point the stack at one of the standalone
|
||||
files instead, which bundle the base stack plus the GPU settings:
|
||||
|
||||
- `docker-compose.gpu-nvidia.yml` — still requires the NVIDIA Container Toolkit
|
||||
on the host.
|
||||
- `docker-compose.gpu-amd.yml` — still requires host ROCm/kfd/DRI setup, the
|
||||
`video`/`render` group membership, and `RENDER_GID` when needed.
|
||||
|
||||
The base `docker-compose.yml` plus the `docker/gpu.*.yml` overlays remain the
|
||||
source of truth; the standalone files mirror them for single-file deployments.
|
||||
|
||||
Verify after enabling either overlay:
|
||||
|
||||
```bash
|
||||
docker compose exec odysseus nvidia-smi -L # NVIDIA
|
||||
docker compose exec odysseus sh -lc 'test -e /dev/kfd && test -d /dev/dri && ls -l /dev/kfd /dev/dri/renderD*' # AMD
|
||||
```
|
||||
|
||||
> **GPU passthrough ≠ llama.cpp CUDA.** `nvidia-smi` passing inside the
|
||||
> container confirms Docker GPU access, but llama.cpp also needs `cudart` and
|
||||
> the CUDA Toolkit at runtime. If Cookbook logs show `Unable to find cudart
|
||||
> library`, `Could NOT find CUDAToolkit`, `CUDA Toolkit not found`, or
|
||||
> tensors/layers assigned to CPU, that is a Cookbook/llama.cpp build issue —
|
||||
> not a Docker passthrough failure. Reinstall the serve engine via
|
||||
> **Cookbook → Dependencies** to get a CUDA-enabled build.
|
||||
>
|
||||
> The same split applies to AMD/ROCm: seeing `/dev/kfd` and `/dev/dri` inside
|
||||
> the container confirms device passthrough, not ROCm userspace or a
|
||||
> ROCm-enabled vLLM/llama.cpp build. `rocm-smi` and `rocminfo` are not expected
|
||||
> inside the slim Odysseus image.
|
||||
|
||||
**Ollama with Docker.** If Ollama runs on the host, add this endpoint in
|
||||
Settings:
|
||||
|
||||
```text
|
||||
http://host.docker.internal:11434/v1
|
||||
```
|
||||
|
||||
Ollama must listen outside its own loopback interface:
|
||||
|
||||
```bash
|
||||
OLLAMA_HOST=0.0.0.0:11434 ollama serve
|
||||
```
|
||||
|
||||
This connects Odysseus in Docker to an Ollama server that is already running on
|
||||
your host machine; it does not start Ollama inside the container.
|
||||
`host.docker.internal` is Docker's hostname for the host machine from inside the
|
||||
container. Cookbook **Serve** is a separate workflow for serving downloaded
|
||||
models through Odysseus/llama.cpp, so Windows users with an existing Ollama
|
||||
install usually only need to add the endpoint in Settings.
|
||||
|
||||
**Useful checks.**
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
docker compose logs --tail=120 odysseus
|
||||
docker compose logs odysseus | grep -E 'ChromaDB|MemoryVectorStore|DEGRADED'
|
||||
```
|
||||
|
||||
**macOS details.** `start-macos.sh` installs Homebrew deps, creates the venv,
|
||||
runs setup, and starts uvicorn on port `7860` because AirPlay often holds
|
||||
`7000`. It uses llama.cpp/Ollama for Metal. vLLM/SGLang are CUDA/ROCm-only and
|
||||
do not run on macOS. MLX-only models are not served by Odysseus.
|
||||
|
||||
</details>
|
||||
|
||||
### Native Windows
|
||||
|
||||
**One-command launcher** (creates the venv, installs deps, runs setup, starts the
|
||||
server; safe to re-run):
|
||||
|
||||
```powershell
|
||||
git clone https://github.com/pewdiepie-archdaemon/odysseus.git
|
||||
cd odysseus
|
||||
powershell -ExecutionPolicy Bypass -File .\launch-windows.ps1
|
||||
```
|
||||
|
||||
Or do it by hand:
|
||||
|
||||
```powershell
|
||||
git clone https://github.com/pewdiepie-archdaemon/odysseus.git
|
||||
cd odysseus
|
||||
py -3.11 -m venv venv
|
||||
venv\Scripts\Activate.ps1
|
||||
pip install -r requirements.txt
|
||||
python setup.py
|
||||
python -m uvicorn app:app --host 127.0.0.1 --port 7000
|
||||
```
|
||||
|
||||
If `python` points at an older interpreter, use `py -3.12` (or another installed
|
||||
3.11+ version) for the venv step.
|
||||
|
||||
**Requirements:** Python 3.11+. The core app (chat, agent, memory, documents,
|
||||
email, calendar, deep research) runs fully native. For full **Cookbook** background
|
||||
model downloads and the agent shell tool, also install
|
||||
[Git for Windows](https://git-scm.com/download/win) (provides `bash.exe`).
|
||||
Local GPU *serving* of vLLM/SGLang needs Linux/WSL2; for a local model on Windows,
|
||||
[Ollama](https://ollama.com/download) is the easiest path — point Odysseus at
|
||||
`http://localhost:11434/v1` in Settings.
|
||||
|
||||
Open `http://localhost:7000`, log in with the generated admin password,
|
||||
and configure everything else inside **Settings**.
|
||||
|
||||
## Troubleshooting & Advanced Setup
|
||||
|
||||
### `chromadb-client` conflicts with embedded ChromaDB
|
||||
If `chromadb-client` (the lightweight HTTP-only package) is installed alongside the full `chromadb` package, Odysseus starts but ChromaDB silently falls back to HTTP-only mode and fails.
|
||||
|
||||
**Fix:** uninstall `chromadb-client` and force-reinstall the full package:
|
||||
```bash
|
||||
./venv/bin/pip uninstall chromadb-client -y
|
||||
./venv/bin/pip install --force-reinstall chromadb
|
||||
```
|
||||
|
||||
### HTTPS + LAN/Tailscale exposure
|
||||
To expose Odysseus on a local network or Tailscale with HTTPS:
|
||||
1. Change the bind address to `0.0.0.0` in `.env` (`APP_BIND=0.0.0.0` or `ODYSSEUS_HOST=0.0.0.0`).
|
||||
2. Generate a locally-trusted cert for your LAN/Tailscale IPs using [mkcert](https://github.com/FiloSottile/mkcert):
|
||||
```bash
|
||||
mkcert -install
|
||||
mkcert -cert-file cert.pem -key-file key.pem 192.168.1.100 tailscale-ip
|
||||
```
|
||||
3. Run `uvicorn` with the generated certs:
|
||||
```bash
|
||||
python -m uvicorn app:app --host 0.0.0.0 --port 7000 --ssl-certfile=cert.pem --ssl-keyfile=key.pem
|
||||
```
|
||||
4. Install the `mkcert` CA on any other device you want to access Odysseus from (e.g., for iOS, email the `rootCA.pem` to yourself, install the profile, and trust it in Certificate Trust Settings).
|
||||
|
||||
### Optional Dependencies
|
||||
`requirements-optional.txt` contains packages that unlock extra features. It is not installed by default.
|
||||
|
||||
| Package | Feature unlocked |
|
||||
|---------|-----------------|
|
||||
| `faster-whisper` | Local speech-to-text (microphone -> text) via the "local" STT provider. |
|
||||
| `ddgs` | DuckDuckGo as a search provider option. |
|
||||
| `PyMuPDF` | PDF page rendering in the side viewer panel and form-filling. (Note: AGPL-3.0) |
|
||||
| `markitdown` | Office/EPUB document text extraction (converts .docx/.xlsx/.pptx/.xls/.epub to Markdown). |
|
||||
|
||||
### Faster, reproducible installs with uv (optional)
|
||||
[uv](https://docs.astral.sh/uv/) works as a drop-in replacement for the
|
||||
venv + pip steps in the native install guides, no project changes are needed but this change results in faster installs along with a lockfile for reproducible environments. After [installing `uv`](https://docs.astral.sh/uv/getting-started/installation/), use:
|
||||
|
||||
```bash
|
||||
uv venv venv --python 3.13
|
||||
uv pip install -r requirements.txt
|
||||
# then continue as usual: python setup.py, uvicorn, ...
|
||||
```
|
||||
|
||||
`requirements.txt` is intentionally unpinned, so two installs at different times can produce different package versions. If you want a reproducible environment (e.g. across your own machines, or to roll back after a bad upgrade), snapshot and restore exact versions with:
|
||||
|
||||
```bash
|
||||
uv pip compile requirements.txt -o requirements.lock # snapshot current resolution
|
||||
uv pip sync requirements.lock # reproduce it exactly later
|
||||
```
|
||||
|
||||
`requirements.lock` is gitignored and platform-specific (compile it on the OS you deploy to). Regenerate it deliberately when you want to take upgrades. The plain `uv pip install -r requirements.txt` keeps following the unpinned requirements like pip does.
|
||||
|
||||
### Outlook / Office 365 email
|
||||
Odysseus email accounts currently use IMAP/SMTP username-password auth. Outlook
|
||||
and Microsoft 365 generally require OAuth instead, so normal Microsoft mailbox
|
||||
passwords will fail. See [docs/email-outlook.md](docs/email-outlook.md) for the
|
||||
current limitation and the planned integration direction.
|
||||
|
||||
## Security Notes
|
||||
Odysseus is a self-hosted workspace with powerful local tools: shell access, file uploads, model downloads, web research, email/calendar integrations, and API tokens. Treat it like an admin console.
|
||||
|
||||
- Keep `AUTH_ENABLED=true` for any network-accessible deployment.
|
||||
- Keep `LOCALHOST_BYPASS=false` outside local development.
|
||||
- Use `SECURE_COOKIES=true` when Odysseus is served through HTTPS by a trusted reverse proxy or private access gateway.
|
||||
- Do not expose it directly to the public internet without HTTPS and a trusted reverse proxy or private access layer.
|
||||
- Keep `.env`, `data/`, `logs/`, databases, uploads, generated media, backups, auth/session files, API keys, and model/provider tokens out of Git and private shares. They are ignored by default.
|
||||
- Review `data/auth.json` after first boot: disable open signup unless you intentionally want it, make only your own account admin, and keep demo/test accounts non-admin.
|
||||
- Non-admin users do not get shell/Python/file read/write by default, and admin-only routes/tools such as MCP management, API tokens, webhooks, model/cookbook serving, backup/vault, and app settings are admin-gated. Other features are controlled by per-user privileges, so review each user's privileges before exposing a deployment.
|
||||
- Rotate any API keys or tokens that were ever pasted into a shared chat, demo, screenshot, or log.
|
||||
- If you enable API tokens or webhooks, create separate tokens per integration and delete unused ones.
|
||||
- Prefer binding manual development runs to `127.0.0.1`; bind to `0.0.0.0` only when you intentionally want LAN/reverse-proxy access.
|
||||
- Keep ChromaDB, SearXNG, ntfy, Ollama, vLLM, llama.cpp, databases, and raw model/provider APIs internal-only. Expose only the authenticated Odysseus web/API entrypoint through your trusted proxy or private access layer.
|
||||
- Before publishing a fork, run `git status --short` and confirm no private files from `.env`, `data/`, `logs/`, uploads, backups, or local databases are staged.
|
||||
|
||||
### Private or proxied deployments
|
||||
Odysseus serves plain HTTP on its app port. Docker Compose binds Odysseus and the bundled services to `127.0.0.1` by default, so a typical production/private setup is:
|
||||
|
||||
1. Keep Odysseus on localhost, for example `127.0.0.1:7000`.
|
||||
2. Terminate HTTPS at a trusted reverse proxy or private access gateway.
|
||||
3. Put the authenticated Odysseus web/API entrypoint behind that layer.
|
||||
4. Keep raw service and model ports internal-only.
|
||||
|
||||
Cloudflare Access, Tailscale, Caddy, nginx, and Traefik can all fit this pattern; none are required by Odysseus. If your access layer reaches Odysseus on the same host, proxy to `http://127.0.0.1:7000` and keep `AUTH_ENABLED=true`, `LOCALHOST_BYPASS=false`, and `SECURE_COOKIES=true`.
|
||||
`ALLOWED_ORIGINS` lists exact permitted origins for cross-origin browser/API clients; ordinary same-origin reverse-proxy access usually does not need a special CORS entry.
|
||||
|
||||
Common internal-only ports from the default docs/compose setup:
|
||||
|
||||
| Port | Service |
|
||||
|---|---|
|
||||
| `7000` | Odysseus raw app port |
|
||||
| `8080` | SearXNG |
|
||||
| `8091` | ntfy |
|
||||
| `8100` | ChromaDB host port for manual/compose access |
|
||||
| `11434` | Ollama |
|
||||
| `8000-8020` | Common local model/provider APIs |
|
||||
A full hover-to-play tour lives on the landing page: [`docs/index.html`](docs/index.html).
|
||||
|
||||
## Contributing
|
||||
Help is welcome. The best entry points are fresh-install testing, provider setup
|
||||
bugs, mobile/editor polish, docs, and small focused refactors. See
|
||||
[ROADMAP.md](ROADMAP.md) for the current help-wanted list.
|
||||
|
||||
## Configuration
|
||||
Most setup is done inside the app with `/setup` or **Settings**. Use `.env`
|
||||
for deployment-level defaults and secrets you want present before first boot.
|
||||
Key settings:
|
||||
Help is welcome. The best entry points are fresh-install testing, provider setup bugs, mobile/editor polish, docs, and small focused refactors. See [CONTRIBUTING.md](CONTRIBUTING.md) and [ROADMAP.md](ROADMAP.md).
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `LLM_HOST` | `localhost` | Your LLM server (e.g. `llm-host.local:8000`) |
|
||||
| `LLM_HOSTS` | -- | Comma-separated list for model discovery |
|
||||
| `OPENAI_API_KEY` | -- | Optional OpenAI key. Prefer adding providers in the app unless pre-seeding. |
|
||||
| `SEARXNG_INSTANCE` | `http://localhost:8080` | SearXNG URL. Docker overrides this to `http://searxng:8080`. |
|
||||
| `SEARXNG_SECRET` | generated on first Docker boot | Optional SearXNG cookie/CSRF secret. Leave blank unless you need to pin it. |
|
||||
| `APP_BIND` | `127.0.0.1` | Docker Compose host bind address for the web UI. Use `0.0.0.0` only for intentional LAN/reverse-proxy access. |
|
||||
| `APP_PORT` | `7000` | Docker Compose host port for the web UI. |
|
||||
| `AUTH_ENABLED` | `true` | Enable/disable login |
|
||||
| `LOCALHOST_BYPASS` | `false` | Development-only auth bypass for loopback requests. Keep false for shared/network deployments. |
|
||||
| `ALLOWED_ORIGINS` | `http://localhost,http://127.0.0.1` | Comma-separated exact permitted origins for cross-origin browser/API clients. |
|
||||
| `SECURE_COOKIES` | `false` | Set true when serving Odysseus through HTTPS at a trusted proxy or private access gateway. |
|
||||
| `DATABASE_URL` | `sqlite:///./data/app.db` | Database connection string |
|
||||
| `CHROMADB_HOST` | `localhost` | ChromaDB host for vector memory. Docker overrides this to `chromadb`. |
|
||||
| `CHROMADB_PORT` | `8100` | ChromaDB port for manual host runs. Docker overrides this to `8000`. |
|
||||
| `EMBEDDING_URL` | -- | OpenAI-compatible embeddings endpoint |
|
||||
| `ODYSSEUS_CHAT_UPLOAD_MAX_BYTES` | `10485760` | Chat/agent attachment cap in bytes. Raise for larger local PDFs or text documents. |
|
||||
| `ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES` | `104857600` | Gallery image upload cap in bytes (100 MB). |
|
||||
| `ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES` | `26214400` | Gallery transform input cap in bytes (25 MB). |
|
||||
| `ODYSSEUS_MEMORY_IMPORT_MAX_BYTES` | `10485760` | Memory import file cap in bytes (10 MB). |
|
||||
| `ODYSSEUS_PERSONAL_UPLOAD_MAX_BYTES` | `26214400` | Personal document upload cap in bytes (25 MB). |
|
||||
| `ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES` | `26214400` | Email compose attachment cap in bytes (25 MB). |
|
||||
| `ODYSSEUS_STT_MAX_AUDIO_BYTES` | `26214400` | Speech-to-text audio cap in bytes (25 MB). |
|
||||
| `ODYSSEUS_ICS_MAX_BYTES` | `10485760` | Calendar `.ics` import cap in bytes (10 MB). |
|
||||
## Security
|
||||
|
||||
All upload-limit vars are validated (must be a positive integer) and optional; an invalid value fails fast at startup.
|
||||
|
||||
### Built-in MCP servers (optional setup)
|
||||
|
||||
Odysseus auto-registers a few built-in MCP servers at startup. The npx-based ones (currently the browser server, `@playwright/mcp`) only start when their npm package is already in the local npx cache. If a package isn't cached, that server is skipped with a startup log message explaining what to do, so a fresh install does not block on a multi-minute npm download or hang if Playwright system deps are missing.
|
||||
|
||||
To enable the browser MCP (page navigation, screenshots, vision), run once:
|
||||
|
||||
```bash
|
||||
npx -y @playwright/mcp@latest --version
|
||||
```
|
||||
|
||||
That installs `@playwright/mcp` plus Playwright (~300MB total). Restart Odysseus and the server will register at startup.
|
||||
|
||||
## Architecture
|
||||
```
|
||||
app.py # FastAPI entry point
|
||||
core/ auth, database, middleware, constants
|
||||
src/ llm_core, agent_loop, agent_tools, chat_processor, search/
|
||||
routes/ chat, session, document, memory, model … endpoints
|
||||
services/ docs, memory, search, hwfit (Cookbook) …
|
||||
static/ index.html + app.js + style.css + js/ (modular front-end)
|
||||
docs/ landing page (index.html) + preview clips
|
||||
```
|
||||
|
||||
## Data
|
||||
All user data lives in `data/` (gitignored): `app.db` (sessions, messages, documents),
|
||||
`memory.json`, `presets.json`, `uploads/`, `personal_docs/`, `chroma/`, `settings.json`.
|
||||
Odysseus is a self-hosted workspace with powerful local tools. Keep auth enabled, keep private data out of Git, and do not expose raw model/service ports publicly. Deployment details are in the [setup guide](docs/setup.md#security-notes).
|
||||
|
||||
## Star History
|
||||
|
||||
@@ -476,19 +72,5 @@ All user data lives in `data/` (gitignored): `app.db` (sessions, messages, docum
|
||||
</a>
|
||||
|
||||
## License
|
||||
AGPL-3.0-or-later -- see [LICENSE](LICENSE) and [ACKNOWLEDGMENTS.md](ACKNOWLEDGMENTS.md).
|
||||
|
||||
```
|
||||
|
|
||||
|||
|
||||
|||||
|
||||
| | | |||||||
|
||||
)_) )_) )_) ~|~
|
||||
)___))___))___)\ |
|
||||
)____)____)_____)\\|
|
||||
_____|____|____|_____\\\__
|
||||
\ /
|
||||
~^~^~~^~^~~^~^~~^~^~~^~^~~^~^~~^~^~~^~^~
|
||||
~^~ all aboard! ~^~
|
||||
~^~^~~^~^~~^~^~~^~^~~^~^~~^~^~~^~^~~^~^~
|
||||
```
|
||||
AGPL-3.0-or-later -- see [LICENSE](LICENSE) and [ACKNOWLEDGMENTS.md](ACKNOWLEDGMENTS.md).
|
||||
|
||||
@@ -69,10 +69,37 @@ from src.generated_images import GENERATED_IMAGE_HEADERS, resolve_generated_imag
|
||||
from starlette.responses import RedirectResponse
|
||||
|
||||
# ========= LOGGING =========
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
)
|
||||
import logging.handlers
|
||||
from core.constants import DATA_DIR
|
||||
|
||||
_root_logger = logging.getLogger()
|
||||
_root_logger.setLevel(logging.INFO)
|
||||
_formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
|
||||
# Clear existing handlers to avoid duplicates
|
||||
for _h in list(_root_logger.handlers):
|
||||
_root_logger.removeHandler(_h)
|
||||
|
||||
_console_h = logging.StreamHandler()
|
||||
_console_h.setFormatter(_formatter)
|
||||
_root_logger.addHandler(_console_h)
|
||||
|
||||
try:
|
||||
_log_dir = os.path.join(DATA_DIR, "logs")
|
||||
os.makedirs(_log_dir, exist_ok=True)
|
||||
_log_file = os.path.join(_log_dir, "app.log")
|
||||
|
||||
# RotatingFileHandler is not multi-process safe (e.g. if uvicorn is run with --workers N).
|
||||
# Odysseus is single-process by convention, so this is acceptable, but be aware that
|
||||
# concurrent log rotation issues can arise if multiple workers are configured.
|
||||
_file_h = logging.handlers.RotatingFileHandler(
|
||||
_log_file, maxBytes=5 * 1024 * 1024, backupCount=3, encoding="utf-8"
|
||||
)
|
||||
_file_h.setFormatter(_formatter)
|
||||
_root_logger.addHandler(_file_h)
|
||||
except Exception as e:
|
||||
_root_logger.warning(f"Failed to initialize file logging handler (falling back to console-only): {e}")
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ========= APP =========
|
||||
@@ -140,6 +167,7 @@ _TIMEOUT_EXEMPT_PREFIXES = (
|
||||
"/api/cookbook/setup", # remote pacman/apt installs
|
||||
"/api/upload", # large files
|
||||
"/api/image", # diffusion proxies (inpaint/harmonize/upscale/etc.) — own 120s httpx timeout
|
||||
"/api/memory/audit", # retains own 120s LLM inactivity timeout
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ Authentication module — multi-user password hashing, session tokens, config pe
|
||||
Config stored in data/auth.json. Uses bcrypt directly.
|
||||
"""
|
||||
|
||||
import enum
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
@@ -83,6 +84,15 @@ def _verify_password(password: str, hashed: str) -> bool:
|
||||
return bcrypt.checkpw(password.encode("utf-8"), hashed.encode("utf-8"))
|
||||
|
||||
|
||||
class SetAdminResult(enum.Enum):
|
||||
"""Outcome of AuthManager.set_admin, so callers can map each case to a
|
||||
precise response instead of guessing from a bare bool."""
|
||||
OK = "ok"
|
||||
USER_NOT_FOUND = "user_not_found"
|
||||
NOT_AUTHORIZED = "not_authorized" # requester is not an admin
|
||||
LAST_ADMIN = "last_admin" # would remove the last remaining admin
|
||||
|
||||
|
||||
class AuthManager:
|
||||
"""Manages multi-user password + session-token auth system."""
|
||||
|
||||
@@ -387,6 +397,69 @@ class AuthManager:
|
||||
logger.info(f"Updated privileges for '{username}': {current}")
|
||||
return True
|
||||
|
||||
def set_admin(self, username: str, is_admin: bool,
|
||||
requesting_user: str) -> SetAdminResult:
|
||||
"""Promote/demote an existing user to/from admin. Admin only.
|
||||
|
||||
Refuses to remove the last remaining admin so the instance can never
|
||||
be locked out of admin access; self-demotion is allowed as long as
|
||||
another admin remains. Admin status is re-checked live on every
|
||||
request, so unlike delete/rename no session or token revocation is
|
||||
needed — a demoted admin simply fails the next is_admin() gate.
|
||||
|
||||
Promotion stashes the user's current privilege map and demotion
|
||||
restores it, so a temporary admin stint can't silently broaden a
|
||||
user's non-admin access; users without a stash (created as admin,
|
||||
or promoted before stashing existed) demote to DEFAULT_PRIVILEGES.
|
||||
|
||||
Counting admins and flipping the flag happen in one critical section
|
||||
so two concurrent demotions can't race the admin count to zero.
|
||||
"""
|
||||
username = (username or "").strip().lower()
|
||||
requesting_user = (requesting_user or "").strip().lower()
|
||||
is_admin = bool(is_admin)
|
||||
with self._config_lock:
|
||||
target = self._config.get("users", {}).get(username)
|
||||
if target is None:
|
||||
return SetAdminResult.USER_NOT_FOUND
|
||||
if not self.users.get(requesting_user, {}).get("is_admin"):
|
||||
return SetAdminResult.NOT_AUTHORIZED
|
||||
currently_admin = bool(target.get("is_admin"))
|
||||
if currently_admin == is_admin:
|
||||
return SetAdminResult.OK # no-op; leave privileges untouched
|
||||
if currently_admin and not is_admin:
|
||||
admin_count = sum(1 for d in self.users.values() if d.get("is_admin"))
|
||||
if admin_count <= 1:
|
||||
return SetAdminResult.LAST_ADMIN
|
||||
# Write order matters for lock-free readers: get_privileges()
|
||||
# reads without _config_lock and trusts is_admin, so the admin
|
||||
# flag must be flipped while the stored map is safe to expose —
|
||||
# before writing admin privileges on promote, after restoring
|
||||
# the pre-admin map on demote.
|
||||
if is_admin:
|
||||
target["is_admin"] = True
|
||||
# Stash the pre-admin map so a later demotion can restore it.
|
||||
# While is_admin is set the stored map is inert: get_privileges
|
||||
# short-circuits to ADMIN_PRIVILEGES and set_privileges refuses
|
||||
# admins, so only set_admin ever touches the stash.
|
||||
target["privileges_before_admin"] = dict(
|
||||
target.get("privileges") or DEFAULT_PRIVILEGES
|
||||
)
|
||||
target["privileges"] = dict(ADMIN_PRIVILEGES)
|
||||
else:
|
||||
# Restore the stashed pre-admin map. Fall back to defaults for
|
||||
# users created as admins (their stored map is ADMIN_PRIVILEGES,
|
||||
# which must not leak past demotion — e.g. can_use_bash) and
|
||||
# for admins promoted before the stash existed.
|
||||
target["privileges"] = dict(
|
||||
target.pop("privileges_before_admin", None)
|
||||
or DEFAULT_PRIVILEGES
|
||||
)
|
||||
target["is_admin"] = False
|
||||
self._save()
|
||||
logger.info("Set is_admin=%s for '%s' (by '%s')", is_admin, username, requesting_user)
|
||||
return SetAdminResult.OK
|
||||
|
||||
def change_password(self, username: str, current_password: str, new_password: str) -> bool:
|
||||
username = username.strip().lower()
|
||||
if username not in self.users:
|
||||
|
||||
@@ -1602,6 +1602,7 @@ class CalendarCal(TimestampMixin, Base):
|
||||
# NULL for local calendars and for CalDAV calendars created before
|
||||
# multi-account support was added (treated as "use any configured account").
|
||||
account_id = Column(String, nullable=True, index=True)
|
||||
caldav_base_url = Column(String, nullable=True)
|
||||
|
||||
events = relationship("CalendarEvent", back_populates="calendar", cascade="all, delete-orphan")
|
||||
|
||||
@@ -1632,10 +1633,27 @@ class CalendarEvent(TimestampMixin, Base):
|
||||
# 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)
|
||||
remote_href = Column(String, nullable=True) # CalDAV object URL for updates/deletes
|
||||
remote_etag = Column(String, nullable=True) # Last seen CalDAV ETag, when available
|
||||
caldav_sync_pending = Column(String, nullable=True) # create | update | delete retry marker
|
||||
|
||||
calendar = relationship("CalendarCal", back_populates="events")
|
||||
|
||||
|
||||
class CalendarDeletedEvent(TimestampMixin, Base):
|
||||
"""Hidden CalDAV delete tombstone retained until remote delete succeeds."""
|
||||
__tablename__ = "caldav_deleted_events"
|
||||
|
||||
uid = Column(String, primary_key=True, index=True)
|
||||
owner = Column(String, nullable=True, index=True)
|
||||
calendar_id = Column(String, nullable=True, index=True)
|
||||
remote_href = Column(String, nullable=True)
|
||||
remote_etag = Column(String, nullable=True)
|
||||
caldav_base_url = Column(String, nullable=True)
|
||||
summary = Column(String, nullable=True)
|
||||
last_error = Column(Text, nullable=True)
|
||||
|
||||
|
||||
class Integration(TimestampMixin, Base):
|
||||
"""An external service connection (email, RSS, webhook, etc.)."""
|
||||
__tablename__ = "integrations"
|
||||
@@ -1767,6 +1785,7 @@ def init_db():
|
||||
_migrate_add_calendar_is_utc()
|
||||
_migrate_add_calendar_origin()
|
||||
_migrate_add_calendar_account_id()
|
||||
_migrate_add_caldav_sync_columns()
|
||||
_migrate_chat_messages_fts()
|
||||
_migrate_encrypt_email_passwords()
|
||||
_migrate_encrypt_signatures()
|
||||
@@ -2067,6 +2086,31 @@ def _migrate_add_calendar_account_id():
|
||||
pass
|
||||
|
||||
|
||||
def _migrate_add_caldav_sync_columns():
|
||||
"""Add remote CalDAV metadata used for bidirectional sync."""
|
||||
import sqlite3
|
||||
db_path = DATABASE_URL.replace("sqlite:///", "")
|
||||
if not os.path.exists(db_path):
|
||||
return
|
||||
try:
|
||||
conn = sqlite3.connect(db_path)
|
||||
ev_columns = [row[1] for row in conn.execute("PRAGMA table_info(calendar_events)").fetchall()]
|
||||
if ev_columns and "remote_href" not in ev_columns:
|
||||
conn.execute("ALTER TABLE calendar_events ADD COLUMN remote_href TEXT")
|
||||
if ev_columns and "remote_etag" not in ev_columns:
|
||||
conn.execute("ALTER TABLE calendar_events ADD COLUMN remote_etag TEXT")
|
||||
if ev_columns and "caldav_sync_pending" not in ev_columns:
|
||||
conn.execute("ALTER TABLE calendar_events ADD COLUMN caldav_sync_pending TEXT")
|
||||
|
||||
cal_columns = [row[1] for row in conn.execute("PRAGMA table_info(calendars)").fetchall()]
|
||||
if cal_columns and "caldav_base_url" not in cal_columns:
|
||||
conn.execute("ALTER TABLE calendars ADD COLUMN caldav_base_url TEXT")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
logging.getLogger(__name__).warning(f"CalDAV sync metadata migration failed: {e}")
|
||||
|
||||
|
||||
def _migrate_add_calendar_metadata():
|
||||
"""Add importance/event_type/last_pinged columns to calendar_events table."""
|
||||
import sqlite3
|
||||
|
||||
@@ -16,18 +16,18 @@ services:
|
||||
ports:
|
||||
- "${APP_BIND:-127.0.0.1}:${APP_PORT:-7000}:7000"
|
||||
volumes:
|
||||
- ./data:/app/data:z
|
||||
- ./logs:/app/logs:z
|
||||
- ${APP_DATA_DIR:-./data}:/app/data:z
|
||||
- ${APP_LOGS_DIR:-./logs}:/app/logs:z
|
||||
# Cookbook remote-server SSH identity. Odysseus can generate a key here;
|
||||
# add the shown public key to each remote server's authorized_keys.
|
||||
- ./data/ssh:/app/.ssh:z
|
||||
- ${APP_DATA_DIR:-./data}/ssh:/app/.ssh:z
|
||||
# Cookbook local model cache. Inside Docker, "Local" means the Odysseus
|
||||
# container, so persist its HuggingFace cache under ./data/huggingface.
|
||||
- ./data/huggingface:/app/.cache/huggingface:z
|
||||
- ${APP_DATA_DIR:-./data}/huggingface:/app/.cache/huggingface:z
|
||||
# Cookbook-installed Python CLIs/packages (vLLM, llama-cpp-python, etc.)
|
||||
# land under /app/.local for the odysseus user. Persist them so a
|
||||
# container recreate does not silently remove installed serve engines.
|
||||
- ./data/local:/app/.local:z
|
||||
- ${APP_DATA_DIR:-./data}/local:/app/.local:z
|
||||
extra_hosts:
|
||||
# Lets the container reach local services on the Docker host, including
|
||||
# Ollama at http://host.docker.internal:11434.
|
||||
|
||||
@@ -15,18 +15,18 @@ services:
|
||||
ports:
|
||||
- "${APP_BIND:-127.0.0.1}:${APP_PORT:-7000}:7000"
|
||||
volumes:
|
||||
- ./data:/app/data:z
|
||||
- ./logs:/app/logs:z
|
||||
- ${APP_DATA_DIR:-./data}:/app/data:z
|
||||
- ${APP_LOGS_DIR:-./logs}:/app/logs:z
|
||||
# Cookbook remote-server SSH identity. Odysseus can generate a key here;
|
||||
# add the shown public key to each remote server's authorized_keys.
|
||||
- ./data/ssh:/app/.ssh:z
|
||||
- ${APP_DATA_DIR:-./data}/ssh:/app/.ssh:z
|
||||
# Cookbook local model cache. Inside Docker, "Local" means the Odysseus
|
||||
# container, so persist its HuggingFace cache under ./data/huggingface.
|
||||
- ./data/huggingface:/app/.cache/huggingface:z
|
||||
- ${APP_DATA_DIR:-./data}/huggingface:/app/.cache/huggingface:z
|
||||
# Cookbook-installed Python CLIs/packages (vLLM, llama-cpp-python, etc.)
|
||||
# land under /app/.local for the odysseus user. Persist them so a
|
||||
# container recreate does not silently remove installed serve engines.
|
||||
- ./data/local:/app/.local:z
|
||||
- ${APP_DATA_DIR:-./data}/local:/app/.local:z
|
||||
extra_hosts:
|
||||
# Lets the container reach local services on the Docker host, including
|
||||
# Ollama at http://host.docker.internal:11434.
|
||||
|
||||
+5
-5
@@ -4,18 +4,18 @@ services:
|
||||
ports:
|
||||
- "${APP_BIND:-127.0.0.1}:${APP_PORT:-7000}:7000"
|
||||
volumes:
|
||||
- ./data:/app/data:z
|
||||
- ./logs:/app/logs:z
|
||||
- ${APP_DATA_DIR:-./data}:/app/data:z
|
||||
- ${APP_LOGS_DIR:-./logs}:/app/logs:z
|
||||
# Cookbook remote-server SSH identity. Odysseus can generate a key here;
|
||||
# add the shown public key to each remote server's authorized_keys.
|
||||
- ./data/ssh:/app/.ssh:z
|
||||
- ${APP_DATA_DIR:-./data}/ssh:/app/.ssh:z
|
||||
# Cookbook local model cache. Inside Docker, "Local" means the Odysseus
|
||||
# container, so persist its HuggingFace cache under ./data/huggingface.
|
||||
- ./data/huggingface:/app/.cache/huggingface:z
|
||||
- ${APP_DATA_DIR:-./data}/huggingface:/app/.cache/huggingface:z
|
||||
# Cookbook-installed Python CLIs/packages (vLLM, llama-cpp-python, etc.)
|
||||
# land under /app/.local for the odysseus user. Persist them so a
|
||||
# container recreate does not silently remove installed serve engines.
|
||||
- ./data/local:/app/.local:z
|
||||
- ${APP_DATA_DIR:-./data}/local:/app/.local:z
|
||||
extra_hosts:
|
||||
# Lets the container reach local services on the Docker host, including
|
||||
# Ollama at http://host.docker.internal:11434.
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
# Agent migration manifests
|
||||
|
||||
Odysseus should be able to learn from another agent without blindly trusting
|
||||
that agent's whole state. The safe migration path is:
|
||||
|
||||
```text
|
||||
source agent export -> source adapter -> agent-migration.v1 manifest -> preview -> apply
|
||||
```
|
||||
|
||||
The manifest is intentionally source-neutral. OpenClaw, Hermes, a folder of
|
||||
Markdown notes, or any other agent can have its own adapter, but Odysseus only
|
||||
needs to understand the normalized manifest.
|
||||
|
||||
## Why not import everything as memory?
|
||||
|
||||
Durable memory should stay compact and useful. Long notes, logs, session
|
||||
transcripts, and project archives are useful context, but they are not all
|
||||
memories. A good migration keeps two layers separate:
|
||||
|
||||
- **Archive documents** preserve source material for search, reading, and later
|
||||
extraction.
|
||||
- **Memory candidates** are short facts or preferences that can be reviewed
|
||||
before being saved into Odysseus memory.
|
||||
|
||||
This keeps Odysseus' existing memory-review flow intact while giving it better
|
||||
source material to review.
|
||||
|
||||
## Manifest shape
|
||||
|
||||
`agent-migration.v1` is a JSON object:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": "agent-migration.v1",
|
||||
"generated_at": "2026-06-06T00:00:00Z",
|
||||
"source": {
|
||||
"name": "example-agent",
|
||||
"kind": "generic"
|
||||
},
|
||||
"summary": {
|
||||
"item_count": 3,
|
||||
"counts_by_kind": {
|
||||
"memory": 1,
|
||||
"skill": 1,
|
||||
"conversation_thread": 1,
|
||||
"archive_document": 1
|
||||
},
|
||||
"warning_count": 0
|
||||
},
|
||||
"items": [],
|
||||
"warnings": []
|
||||
}
|
||||
```
|
||||
|
||||
Each item has a stable `id`, a `kind`, source metadata, and enough content for a
|
||||
future importer to preview it before applying.
|
||||
|
||||
Supported item kinds in the first pass:
|
||||
|
||||
- `memory` — a candidate memory with `text`, `category`, `source`, and
|
||||
provenance metadata.
|
||||
- `skill` — a `SKILL.md` file with content and parsed frontmatter metadata.
|
||||
- `conversation_thread` — a normalized transcript thread from an exported chat
|
||||
history. Message content is optional; adapters can preserve only thread
|
||||
metadata, message counts, timestamps, and hashes when a manifest should stay
|
||||
small or avoid embedding private transcript text.
|
||||
- `archive_document` — long-form source material. Content is optional; adapters
|
||||
can preserve only path/hash/size metadata when a manifest should stay small.
|
||||
|
||||
## Build a manifest
|
||||
|
||||
Use the read-only helper:
|
||||
|
||||
```bash
|
||||
python3 scripts/agent_migration_manifest.py \
|
||||
--source-name old-agent \
|
||||
--source-kind generic \
|
||||
--memory-json /path/to/memories.json \
|
||||
--skills-dir /path/to/skills \
|
||||
--conversation-json /path/to/conversations.json \
|
||||
--archive /path/to/notes \
|
||||
--output /tmp/agent-migration.json
|
||||
```
|
||||
|
||||
The helper does not write to `data/`, call an LLM, import Odysseus modules, or
|
||||
modify the source. It only writes JSON.
|
||||
|
||||
Memory JSON may be:
|
||||
|
||||
```json
|
||||
[
|
||||
"A plain memory string",
|
||||
{
|
||||
"text": "A categorized memory",
|
||||
"category": "preference",
|
||||
"source": "old-agent"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
or an object containing a list under `memories`, `memory`, `items`, or `data`.
|
||||
|
||||
Skills are scanned recursively for `SKILL.md`:
|
||||
|
||||
```bash
|
||||
python3 scripts/agent_migration_manifest.py \
|
||||
--source-name hermes \
|
||||
--source-kind hermes \
|
||||
--skills-dir ~/.hermes/skills \
|
||||
--output /tmp/hermes-skills-manifest.json
|
||||
```
|
||||
|
||||
Archive documents are metadata-only by default. To embed text content:
|
||||
|
||||
```bash
|
||||
python3 scripts/agent_migration_manifest.py \
|
||||
--source-name notes-export \
|
||||
--archive /path/to/markdown-notes \
|
||||
--include-archive-content \
|
||||
--output /tmp/notes-manifest.json
|
||||
```
|
||||
|
||||
Conversation exports are also metadata-only by default:
|
||||
|
||||
```bash
|
||||
python3 scripts/agent_migration_manifest.py \
|
||||
--source-name chatgpt-export \
|
||||
--source-kind chatgpt \
|
||||
--conversation-json /path/to/conversations.json \
|
||||
--output /tmp/chatgpt-conversations-manifest.json
|
||||
```
|
||||
|
||||
The first pass supports generic conversation JSON such as:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "thread-1",
|
||||
"title": "Project plan",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Can we design this?"},
|
||||
{"role": "assistant", "content": "Yes, start with a narrow slice."}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
It also recognizes ChatGPT-style `mapping` exports from `conversations.json`.
|
||||
To embed normalized messages:
|
||||
|
||||
```bash
|
||||
python3 scripts/agent_migration_manifest.py \
|
||||
--source-name chatgpt-export \
|
||||
--source-kind chatgpt \
|
||||
--conversation-json /path/to/conversations.json \
|
||||
--include-conversation-content \
|
||||
--max-conversation-messages 2000 \
|
||||
--output /tmp/chatgpt-conversations-with-content.json
|
||||
```
|
||||
|
||||
Content embedding is explicit because exported chat histories can be huge and
|
||||
private. A future source-specific adapter can add ZIP traversal, attachment
|
||||
metadata, and provider-specific project/workspace fields while still emitting
|
||||
the same `conversation_thread` manifest item.
|
||||
|
||||
## Recommended apply behavior
|
||||
|
||||
A future Odysseus importer should treat the manifest as untrusted user-provided
|
||||
data and apply it in stages:
|
||||
|
||||
1. Show a dry-run summary with counts, warnings, duplicates, and sample items.
|
||||
2. Back up current `data/` state before writing anything.
|
||||
3. Import archive documents as documents or another searchable source, not as
|
||||
memory.
|
||||
4. Import conversation threads as searchable archived context first, with
|
||||
citations back to the source thread. Do not turn whole transcripts into
|
||||
memory.
|
||||
5. Show memory candidates for review before saving through the normal memory
|
||||
path.
|
||||
6. Import skills only after name/category conflict checks.
|
||||
7. Skip secrets by default. Credentials need explicit, provider-specific flows.
|
||||
|
||||
## What belongs in source adapters?
|
||||
|
||||
Adapters can be source-specific. The core manifest should not be.
|
||||
|
||||
For example, an OpenClaw adapter may know about OpenClaw's workspace files. A
|
||||
Hermes adapter may know about `~/.hermes/config.yaml` and `~/.hermes/skills`.
|
||||
A ChatGPT adapter may know about `conversations.json`, uploaded-file metadata,
|
||||
and image attachment directories. A Claude adapter may know about Claude's
|
||||
export shape and project boundaries. A generic adapter may only know about
|
||||
memory JSON, conversation JSON, `SKILL.md`, and Markdown folders.
|
||||
|
||||
Nonstandard folders should be adapter details, not required Odysseus concepts.
|
||||
@@ -0,0 +1,129 @@
|
||||
# Backup & Restore
|
||||
|
||||
Odysseus keeps all of your state in the `data/` directory — the SQLite database
|
||||
(`app.db`), the Fernet encryption key (`data/.app_key`), the vault, memory, RAG
|
||||
indexes, personal documents, and uploads. The `scripts/odysseus-backup` tool
|
||||
snapshots that directory into a single gzip tarball and restores it later.
|
||||
|
||||
Snapshots are safe to take while the app is running: SQLite databases are copied
|
||||
through SQLite's own `.backup` API rather than a raw file copy, so an in-flight
|
||||
write can't corrupt the snapshot.
|
||||
|
||||
> **A snapshot contains your secrets.** The tarball includes the Fernet
|
||||
> encryption key (`data/.app_key`), the vault, sessions, and any stored
|
||||
> provider/API tokens — so treat it like a password. Store backups somewhere
|
||||
> private, never commit them to Git, and prefer an encrypted destination when
|
||||
> copying them offsite.
|
||||
|
||||
## Quick start
|
||||
|
||||
Run the tool from the repository root:
|
||||
|
||||
```bash
|
||||
# Create a snapshot → backups/odysseus-backup-<YYYYMMDD-HHMMSS>.tar.gz
|
||||
./scripts/odysseus-backup snapshot
|
||||
|
||||
# List existing snapshots (most recent first)
|
||||
./scripts/odysseus-backup list
|
||||
|
||||
# Check a tarball's integrity without extracting it
|
||||
./scripts/odysseus-backup verify backups/odysseus-backup-20260101-120000.tar.gz
|
||||
|
||||
# Restore (destructive — see the warning below)
|
||||
./scripts/odysseus-backup restore backups/odysseus-backup-20260101-120000.tar.gz --yes
|
||||
```
|
||||
|
||||
The script depends only on the Python standard library, so any `python3` on your
|
||||
`PATH` will run it — you don't need the app's virtualenv active.
|
||||
|
||||
Every command prints a JSON result. Add `--pretty` for indented output.
|
||||
|
||||
## Commands
|
||||
|
||||
### `snapshot`
|
||||
|
||||
Writes a `tar.gz` of `data/` to `backups/<timestamp>.tar.gz`.
|
||||
|
||||
| Flag | Effect |
|
||||
| --- | --- |
|
||||
| `--out PATH` | Write to a specific path instead of the default `backups/` location. Must be **outside** `data/`. |
|
||||
| `--include-research` | Include `data/deep_research/` (skipped by default — research runs are large). |
|
||||
| `--include-attachments` | Include `data/mail-attachments/` (skipped by default — cached IMAP extractions, re-derivable). |
|
||||
|
||||
By default the snapshot includes everything under `data/` **except**
|
||||
`deep_research/` and `mail-attachments/`. Personal uploads and documents are
|
||||
included.
|
||||
|
||||
```bash
|
||||
# Snapshot straight to a mounted NAS path
|
||||
./scripts/odysseus-backup snapshot --out /mnt/nas/odysseus-$(date +%F).tar.gz
|
||||
|
||||
# Full snapshot including research runs and mail attachments
|
||||
./scripts/odysseus-backup snapshot --include-research --include-attachments
|
||||
```
|
||||
|
||||
### `list`
|
||||
|
||||
Lists the tarballs in `backups/`, most recent first, with size and modification
|
||||
time.
|
||||
|
||||
### `verify PATH`
|
||||
|
||||
Opens the tarball read-only and walks every member to confirm it is intact and
|
||||
safe to restore. Nothing is extracted. Use this before relying on an old backup
|
||||
or after copying one across machines.
|
||||
|
||||
### `restore PATH --yes`
|
||||
|
||||
Overwrites `data/` from a tarball.
|
||||
|
||||
> **Restore is destructive.** It replaces the current `data/` directory. `--yes`
|
||||
> is required so a mistyped command can't wipe your live state.
|
||||
|
||||
Restore is not a blind delete: before extracting, the tool **renames your current
|
||||
`data/` to `data.before-restore-<timestamp>`** in the repository root. If a
|
||||
restore turns out to be wrong, your previous state is still there — delete the
|
||||
restored `data/` and rename the stashed directory back. The restore path is also
|
||||
validated entry-by-entry: archives containing absolute paths, `..` segments,
|
||||
symlinks, or anything outside `data/` are rejected.
|
||||
|
||||
## Scheduling offsite backups
|
||||
|
||||
The tarball output composes cleanly with cron and any copy tool. For example, a
|
||||
nightly snapshot copied offsite:
|
||||
|
||||
```cron
|
||||
0 3 * * * cd /path/to/odysseus && ./scripts/odysseus-backup snapshot --out "/mnt/nas/odysseus-$(date +\%F).tar.gz"
|
||||
```
|
||||
|
||||
Swap the `--out` target for `scp`, `rclone`, `s3cmd`, or similar to push the
|
||||
snapshot to remote storage.
|
||||
|
||||
## Docker vs native installs
|
||||
|
||||
The tool reads `data/` and writes `backups/` relative to the repository root, so
|
||||
where you run it matters:
|
||||
|
||||
- **Native installs** — run it from the repo root as shown above. `data/` and
|
||||
`backups/` are both in the repo directory.
|
||||
- **Docker** — `docker-compose.yml` bind-mounts the host's `./data` to
|
||||
`/app/data`, so the live data is also present on the host. **Run the tool on
|
||||
the host** from the repo root; the snapshot reads the bind-mounted `./data` and
|
||||
writes to `./backups` on the host. Running it *inside* the container is not
|
||||
recommended, because `backups/` is not a mounted volume and the tarball would
|
||||
be lost when the container is recreated.
|
||||
|
||||
> **ChromaDB caveat (Docker only).** In the Docker setup, ChromaDB stores its
|
||||
> vectors in a separate Compose-managed volume (declared as `chromadb-data`),
|
||||
> **not** under `./data`. `odysseus-backup` therefore does not capture the Docker
|
||||
> ChromaDB store. Back it up separately if you need it. Compose prefixes the
|
||||
> volume with the project name, so find the real name first
|
||||
> (`docker volume ls | grep chromadb`), then archive it — for example:
|
||||
>
|
||||
> ```bash
|
||||
> docker run --rm -v <project>_chromadb-data:/data -v "$PWD":/backup \
|
||||
> alpine tar czf /backup/chromadb.tar.gz -C /data .
|
||||
> ```
|
||||
>
|
||||
> On native installs ChromaDB lives at `data/chroma/` and is included in the
|
||||
> snapshot normally.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 52 KiB |
+425
@@ -0,0 +1,425 @@
|
||||
# Odysseus Setup Guide
|
||||
|
||||
This page keeps the detailed install, deployment, troubleshooting, and configuration notes out of the front README.
|
||||
|
||||
## Quick Start
|
||||
|
||||
> **Branch note:** `dev` is the default branch and contains the latest development changes, but it may be unstable. For the more stable curated branch, use [`main`](https://github.com/pewdiepie-archdaemon/odysseus/tree/main).
|
||||
|
||||
Defaults work out of the box: clone, run, then configure models/search/email
|
||||
inside **Settings**. Only edit `.env` for deployment-level overrides like
|
||||
`APP_BIND`, `APP_PORT`, `AUTH_ENABLED`, `DATABASE_URL`, or a pre-seeded admin password.
|
||||
|
||||
On first setup, Odysseus creates an admin account (`admin` unless
|
||||
`ODYSSEUS_ADMIN_USER` is set) and prints a temporary password in the terminal.
|
||||
For Docker installs, the same line is in `docker compose logs odysseus`.
|
||||
Use that for the first login, then change it in **Settings**.
|
||||
|
||||
Contributing? See [CONTRIBUTING.md](CONTRIBUTING.md) for setup, testing, and
|
||||
pull request guidelines.
|
||||
|
||||
### Docker (recommended)
|
||||
```bash
|
||||
git clone https://github.com/pewdiepie-archdaemon/odysseus.git
|
||||
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`
|
||||
only when you intentionally want LAN/reverse-proxy access.
|
||||
|
||||
> **On Apple Silicon (M-series) Macs:** Docker can't reach the Metal GPU, so
|
||||
> Cookbook serves local models on CPU only. For GPU-accelerated model serving,
|
||||
> run natively instead — see [Apple Silicon](#apple-silicon) below.
|
||||
|
||||
### Native Linux / macOS
|
||||
```bash
|
||||
git clone https://github.com/pewdiepie-archdaemon/odysseus.git
|
||||
cd odysseus
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
python setup.py
|
||||
python -m uvicorn app:app --host 127.0.0.1 --port 7000
|
||||
```
|
||||
Requirements: Python 3.11+. Cookbook also needs `tmux` for background model
|
||||
downloads and serves. The app itself is lightweight; local model serving is the
|
||||
heavy part and depends on the model, runtime, GPU, and VRAM, so small hosts can
|
||||
connect to API or remote model servers instead. Use `--host 0.0.0.0` only when you intentionally want LAN/reverse-proxy access.
|
||||
|
||||
### Apple Silicon
|
||||
Docker on macOS cannot use the Metal GPU. For GPU-accelerated Cookbook on an
|
||||
M-series Mac, run Odysseus natively:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/pewdiepie-archdaemon/odysseus.git
|
||||
cd odysseus
|
||||
./start-macos.sh
|
||||
```
|
||||
|
||||
It launches at `http://127.0.0.1:7860`. To expose it to your phone over a trusted LAN/VPN such as Tailscale, bind all interfaces:
|
||||
|
||||
```bash
|
||||
ODYSSEUS_HOST=0.0.0.0 ./start-macos.sh
|
||||
# then open http://<tailscale-ip>:7860
|
||||
```
|
||||
|
||||
The script also reads `.env` at startup, so `APP_BIND=0.0.0.0` and `APP_PORT`
|
||||
set there are picked up automatically without a command-line override each run.
|
||||
|
||||
Keep `AUTH_ENABLED=true` (the default) before binding outside loopback. Do not
|
||||
expose this port directly to the public internet. To build a clickable app wrapper:
|
||||
|
||||
```bash
|
||||
./build-macos-app.sh
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Cookbook, GPU, Ollama, and troubleshooting notes</summary>
|
||||
|
||||
**Docker bundled services.** Compose starts Odysseus, ChromaDB, SearXNG, and
|
||||
ntfy. Odysseus and the bundled service ports bind to `127.0.0.1` by default, so
|
||||
they are reachable from the host but not exposed to your LAN/public internet
|
||||
unless you opt in.
|
||||
|
||||
**Cookbook storage in Docker.** Downloads live in `./data/huggingface`
|
||||
(`~/.cache/huggingface` in the container). Cookbook-installed Python CLIs and
|
||||
serve engines live in `./data/local` (`~/.local` in the container), so they
|
||||
survive container recreation.
|
||||
|
||||
**Remote servers.** In **Cookbook -> Settings -> Servers**, generate the
|
||||
Odysseus SSH key and add the public key to the remote server's
|
||||
`~/.ssh/authorized_keys`. From the host you can also run:
|
||||
|
||||
```bash
|
||||
ssh-copy-id -i data/ssh/id_ed25519.pub user@server
|
||||
```
|
||||
|
||||
**Docker GPU overlays.** CPU-only users can skip this section. Cookbook can
|
||||
only detect GPUs that Docker exposes to the container — if the host runtime or
|
||||
device passthrough is not configured, Cookbook sees the iGPU, another card, or
|
||||
CPU instead of your intended GPU.
|
||||
|
||||
For NVIDIA, `scripts/check-docker-gpu.sh` diagnoses GPU passthrough and can
|
||||
optionally install the host runtime or update `.env`.
|
||||
|
||||
```bash
|
||||
# Read-only diagnostic (default — installs nothing, never edits .env):
|
||||
scripts/check-docker-gpu.sh
|
||||
|
||||
# Print OS-specific install commands without running them:
|
||||
scripts/check-docker-gpu.sh --print-install-commands
|
||||
|
||||
# Install NVIDIA Container Toolkit on Ubuntu/Debian (requires sudo):
|
||||
scripts/check-docker-gpu.sh --install-nvidia-toolkit
|
||||
|
||||
# Write COMPOSE_FILE to .env (only when GPU passthrough is confirmed working):
|
||||
scripts/check-docker-gpu.sh --enable-nvidia-overlay
|
||||
|
||||
# Full assisted setup — install toolkit, then enable overlay if passthrough works:
|
||||
scripts/check-docker-gpu.sh --install-nvidia-toolkit --enable-nvidia-overlay
|
||||
```
|
||||
|
||||
Safety notes:
|
||||
- The app never installs host GPU runtime automatically.
|
||||
- The app never edits `.env` automatically.
|
||||
- `.env` is only modified when `--enable-nvidia-overlay` is explicitly passed,
|
||||
and only after GPU passthrough succeeds. `--yes` skips prompts but does not
|
||||
bypass the passthrough gate.
|
||||
- `.env.bak.*` backups created by `--enable-nvidia-overlay` are ignored by
|
||||
Git and the Docker build context.
|
||||
|
||||
To enable manually without the script, add this to `.env`:
|
||||
|
||||
```bash
|
||||
COMPOSE_FILE=docker-compose.yml:docker/gpu.nvidia.yml
|
||||
```
|
||||
|
||||
**AMD / ROCm.** AMD setup is read-only diagnostic plus manual `.env` edit. Run:
|
||||
|
||||
```bash
|
||||
scripts/check-docker-amd-gpu.sh
|
||||
```
|
||||
|
||||
Then add the reported values to `.env`, replacing `RENDER_GID` with your host's
|
||||
numeric render group id:
|
||||
|
||||
```bash
|
||||
COMPOSE_FILE=docker-compose.yml:docker/gpu.amd.yml
|
||||
RENDER_GID=989
|
||||
```
|
||||
|
||||
For NVIDIA/AMD GPU support, also read the comments in the selected overlay file: docker/gpu.nvidia.yml or docker/gpu.amd.yml.
|
||||
|
||||
**Stack-management UIs (Portainer, Coolify, Dockhand, etc.).** These tools
|
||||
often accept only a single Compose file and do not reliably honor `COMPOSE_FILE`
|
||||
or multiple `-f` overlays. CLI users should keep using the `COMPOSE_FILE`
|
||||
overlay workflow above. For stack UIs, point the stack at one of the standalone
|
||||
files instead, which bundle the base stack plus the GPU settings:
|
||||
|
||||
- `docker-compose.gpu-nvidia.yml` — still requires the NVIDIA Container Toolkit
|
||||
on the host.
|
||||
- `docker-compose.gpu-amd.yml` — still requires host ROCm/kfd/DRI setup, the
|
||||
`video`/`render` group membership, and `RENDER_GID` when needed.
|
||||
|
||||
The base `docker-compose.yml` plus the `docker/gpu.*.yml` overlays remain the
|
||||
source of truth; the standalone files mirror them for single-file deployments.
|
||||
|
||||
Verify after enabling either overlay:
|
||||
|
||||
```bash
|
||||
docker compose exec odysseus nvidia-smi -L # NVIDIA
|
||||
docker compose exec odysseus sh -lc 'test -e /dev/kfd && test -d /dev/dri && ls -l /dev/kfd /dev/dri/renderD*' # AMD
|
||||
```
|
||||
|
||||
> **GPU passthrough ≠ llama.cpp CUDA.** `nvidia-smi` passing inside the
|
||||
> container confirms Docker GPU access, but llama.cpp also needs `cudart` and
|
||||
> the CUDA Toolkit at runtime. If Cookbook logs show `Unable to find cudart
|
||||
> library`, `Could NOT find CUDAToolkit`, `CUDA Toolkit not found`, or
|
||||
> tensors/layers assigned to CPU, that is a Cookbook/llama.cpp build issue —
|
||||
> not a Docker passthrough failure. Reinstall the serve engine via
|
||||
> **Cookbook → Dependencies** to get a CUDA-enabled build.
|
||||
>
|
||||
> The same split applies to AMD/ROCm: seeing `/dev/kfd` and `/dev/dri` inside
|
||||
> the container confirms device passthrough, not ROCm userspace or a
|
||||
> ROCm-enabled vLLM/llama.cpp build. `rocm-smi` and `rocminfo` are not expected
|
||||
> inside the slim Odysseus image.
|
||||
|
||||
**Ollama with Docker.** If Ollama runs on the host, add this endpoint in
|
||||
Settings:
|
||||
|
||||
```text
|
||||
http://host.docker.internal:11434/v1
|
||||
```
|
||||
|
||||
Ollama must listen outside its own loopback interface:
|
||||
|
||||
```bash
|
||||
OLLAMA_HOST=0.0.0.0:11434 ollama serve
|
||||
```
|
||||
|
||||
This connects Odysseus in Docker to an Ollama server that is already running on
|
||||
your host machine; it does not start Ollama inside the container.
|
||||
`host.docker.internal` is Docker's hostname for the host machine from inside the
|
||||
container. Cookbook **Serve** is a separate workflow for serving downloaded
|
||||
models through Odysseus/llama.cpp, so Windows users with an existing Ollama
|
||||
install usually only need to add the endpoint in Settings.
|
||||
|
||||
**Useful checks.**
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
docker compose logs --tail=120 odysseus
|
||||
docker compose logs odysseus | grep -E 'ChromaDB|MemoryVectorStore|DEGRADED'
|
||||
```
|
||||
|
||||
**macOS details.** `start-macos.sh` installs Homebrew deps, creates the venv,
|
||||
runs setup, and starts uvicorn on port `7860` because AirPlay often holds
|
||||
`7000`. It uses llama.cpp/Ollama for Metal. vLLM/SGLang are CUDA/ROCm-only and
|
||||
do not run on macOS. MLX-only models are not served by Odysseus.
|
||||
|
||||
</details>
|
||||
|
||||
### Native Windows
|
||||
|
||||
**One-command launcher** (creates the venv, installs deps, runs setup, starts the
|
||||
server; safe to re-run):
|
||||
|
||||
```powershell
|
||||
git clone https://github.com/pewdiepie-archdaemon/odysseus.git
|
||||
cd odysseus
|
||||
powershell -ExecutionPolicy Bypass -File .\launch-windows.ps1
|
||||
```
|
||||
|
||||
Or do it by hand:
|
||||
|
||||
```powershell
|
||||
git clone https://github.com/pewdiepie-archdaemon/odysseus.git
|
||||
cd odysseus
|
||||
py -3.11 -m venv venv
|
||||
venv\Scripts\Activate.ps1
|
||||
pip install -r requirements.txt
|
||||
python setup.py
|
||||
python -m uvicorn app:app --host 127.0.0.1 --port 7000
|
||||
```
|
||||
|
||||
If `python` points at an older interpreter, use `py -3.12` (or another installed
|
||||
3.11+ version) for the venv step.
|
||||
|
||||
**Requirements:** Python 3.11+. The core app (chat, agent, memory, documents,
|
||||
email, calendar, deep research) runs fully native. For full **Cookbook** background
|
||||
model downloads and the agent shell tool, also install
|
||||
[Git for Windows](https://git-scm.com/download/win) (provides `bash.exe`).
|
||||
Local GPU *serving* of vLLM/SGLang needs Linux/WSL2; for a local model on Windows,
|
||||
[Ollama](https://ollama.com/download) is the easiest path — point Odysseus at
|
||||
`http://localhost:11434/v1` in Settings.
|
||||
|
||||
Open `http://localhost:7000`, log in with the generated admin password,
|
||||
and configure everything else inside **Settings**.
|
||||
|
||||
## Troubleshooting & Advanced Setup
|
||||
|
||||
### `chromadb-client` conflicts with embedded ChromaDB
|
||||
If `chromadb-client` (the lightweight HTTP-only package) is installed alongside the full `chromadb` package, Odysseus starts but ChromaDB silently falls back to HTTP-only mode and fails.
|
||||
|
||||
**Fix:** uninstall `chromadb-client` and force-reinstall the full package:
|
||||
```bash
|
||||
./venv/bin/pip uninstall chromadb-client -y
|
||||
./venv/bin/pip install --force-reinstall chromadb
|
||||
```
|
||||
|
||||
### HTTPS + LAN/Tailscale exposure
|
||||
To expose Odysseus on a local network or Tailscale with HTTPS:
|
||||
1. Change the bind address to `0.0.0.0` in `.env` (`APP_BIND=0.0.0.0` or `ODYSSEUS_HOST=0.0.0.0`).
|
||||
2. Generate a locally-trusted cert for your LAN/Tailscale IPs using [mkcert](https://github.com/FiloSottile/mkcert):
|
||||
```bash
|
||||
mkcert -install
|
||||
mkcert -cert-file cert.pem -key-file key.pem 192.168.1.100 tailscale-ip
|
||||
```
|
||||
3. Run `uvicorn` with the generated certs:
|
||||
```bash
|
||||
python -m uvicorn app:app --host 0.0.0.0 --port 7000 --ssl-certfile=cert.pem --ssl-keyfile=key.pem
|
||||
```
|
||||
4. Install the `mkcert` CA on any other device you want to access Odysseus from (e.g., for iOS, email the `rootCA.pem` to yourself, install the profile, and trust it in Certificate Trust Settings).
|
||||
|
||||
### Optional Dependencies
|
||||
`requirements-optional.txt` contains packages that unlock extra features. It is not installed by default.
|
||||
|
||||
| Package | Feature unlocked |
|
||||
|---------|-----------------|
|
||||
| `faster-whisper` | Local speech-to-text (microphone -> text) via the "local" STT provider. |
|
||||
| `ddgs` | DuckDuckGo as a search provider option. |
|
||||
| `PyMuPDF` | PDF page rendering in the side viewer panel and form-filling. (Note: AGPL-3.0) |
|
||||
| `markitdown` | Office/EPUB document text extraction (converts .docx/.xlsx/.pptx/.xls/.epub to Markdown). |
|
||||
|
||||
### Faster, reproducible installs with uv (optional)
|
||||
[uv](https://docs.astral.sh/uv/) works as a drop-in replacement for the
|
||||
venv + pip steps in the native install guides, no project changes are needed but this change results in faster installs along with a lockfile for reproducible environments. After [installing `uv`](https://docs.astral.sh/uv/getting-started/installation/), use:
|
||||
|
||||
```bash
|
||||
uv venv venv --python 3.13
|
||||
uv pip install -r requirements.txt
|
||||
# then continue as usual: python setup.py, uvicorn, ...
|
||||
```
|
||||
|
||||
`requirements.txt` is intentionally unpinned, so two installs at different times can produce different package versions. If you want a reproducible environment (e.g. across your own machines, or to roll back after a bad upgrade), snapshot and restore exact versions with:
|
||||
|
||||
```bash
|
||||
uv pip compile requirements.txt -o requirements.lock # snapshot current resolution
|
||||
uv pip sync requirements.lock # reproduce it exactly later
|
||||
```
|
||||
|
||||
`requirements.lock` is gitignored and platform-specific (compile it on the OS you deploy to). Regenerate it deliberately when you want to take upgrades. The plain `uv pip install -r requirements.txt` keeps following the unpinned requirements like pip does.
|
||||
|
||||
### Outlook / Office 365 email
|
||||
Odysseus email accounts currently use IMAP/SMTP username-password auth. Outlook
|
||||
and Microsoft 365 generally require OAuth instead, so normal Microsoft mailbox
|
||||
passwords will fail. See [docs/email-outlook.md](docs/email-outlook.md) for the
|
||||
current limitation and the planned integration direction.
|
||||
|
||||
## Security Notes
|
||||
Odysseus is a self-hosted workspace with powerful local tools: shell access, file uploads, model downloads, web research, email/calendar integrations, and API tokens. Treat it like an admin console.
|
||||
|
||||
- Keep `AUTH_ENABLED=true` for any network-accessible deployment.
|
||||
- Keep `LOCALHOST_BYPASS=false` outside local development.
|
||||
- Use `SECURE_COOKIES=true` when Odysseus is served through HTTPS by a trusted reverse proxy or private access gateway.
|
||||
- Do not expose it directly to the public internet without HTTPS and a trusted reverse proxy or private access layer.
|
||||
- Keep `.env`, `data/`, `logs/`, databases, uploads, generated media, backups, auth/session files, API keys, and model/provider tokens out of Git and private shares. They are ignored by default.
|
||||
- Review `data/auth.json` after first boot: disable open signup unless you intentionally want it, make only your own account admin, and keep demo/test accounts non-admin.
|
||||
- Non-admin users do not get shell/Python/file read/write by default, and admin-only routes/tools such as MCP management, API tokens, webhooks, model/cookbook serving, backup/vault, and app settings are admin-gated. Other features are controlled by per-user privileges, so review each user's privileges before exposing a deployment.
|
||||
- Rotate any API keys or tokens that were ever pasted into a shared chat, demo, screenshot, or log.
|
||||
- If you enable API tokens or webhooks, create separate tokens per integration and delete unused ones.
|
||||
- Prefer binding manual development runs to `127.0.0.1`; bind to `0.0.0.0` only when you intentionally want LAN/reverse-proxy access.
|
||||
- Keep ChromaDB, SearXNG, ntfy, Ollama, vLLM, llama.cpp, databases, and raw model/provider APIs internal-only. Expose only the authenticated Odysseus web/API entrypoint through your trusted proxy or private access layer.
|
||||
- Before publishing a fork, run `git status --short` and confirm no private files from `.env`, `data/`, `logs/`, uploads, backups, or local databases are staged.
|
||||
|
||||
### Private or proxied deployments
|
||||
Odysseus serves plain HTTP on its app port. Docker Compose binds Odysseus and the bundled services to `127.0.0.1` by default, so a typical production/private setup is:
|
||||
|
||||
1. Keep Odysseus on localhost, for example `127.0.0.1:7000`.
|
||||
2. Terminate HTTPS at a trusted reverse proxy or private access gateway.
|
||||
3. Put the authenticated Odysseus web/API entrypoint behind that layer.
|
||||
4. Keep raw service and model ports internal-only.
|
||||
|
||||
Cloudflare Access, Tailscale, Caddy, nginx, and Traefik can all fit this pattern; none are required by Odysseus. If your access layer reaches Odysseus on the same host, proxy to `http://127.0.0.1:7000` and keep `AUTH_ENABLED=true`, `LOCALHOST_BYPASS=false`, and `SECURE_COOKIES=true`.
|
||||
`ALLOWED_ORIGINS` lists exact permitted origins for cross-origin browser/API clients; ordinary same-origin reverse-proxy access usually does not need a special CORS entry.
|
||||
|
||||
Common internal-only ports from the default docs/compose setup:
|
||||
|
||||
| Port | Service |
|
||||
|---|---|
|
||||
| `7000` | Odysseus raw app port |
|
||||
| `8080` | SearXNG |
|
||||
| `8091` | ntfy |
|
||||
| `8100` | ChromaDB host port for manual/compose access |
|
||||
| `11434` | Ollama |
|
||||
| `8000-8020` | Common local model/provider APIs |
|
||||
|
||||
## Configuration
|
||||
Most setup is done inside the app with `/setup` or **Settings**. Use `.env`
|
||||
for deployment-level defaults and secrets you want present before first boot.
|
||||
Key settings:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `LLM_HOST` | `localhost` | Your LLM server (e.g. `llm-host.local:8000`) |
|
||||
| `LLM_HOSTS` | -- | Comma-separated list for model discovery |
|
||||
| `OPENAI_API_KEY` | -- | Optional OpenAI key. Prefer adding providers in the app unless pre-seeding. |
|
||||
| `SEARXNG_INSTANCE` | `http://localhost:8080` | SearXNG URL. Docker overrides this to `http://searxng:8080`. |
|
||||
| `SEARXNG_SECRET` | generated on first Docker boot | Optional SearXNG cookie/CSRF secret. Leave blank unless you need to pin it. |
|
||||
| `APP_BIND` | `127.0.0.1` | Docker Compose host bind address for the web UI. Use `0.0.0.0` only for intentional LAN/reverse-proxy access. |
|
||||
| `APP_PORT` | `7000` | Docker Compose host port for the web UI. |
|
||||
| `APP_DATA_DIR` | `./data` | Docker Compose host directory for application data volumes. |
|
||||
| `APP_LOGS_DIR` | `./logs` | Docker Compose host directory for application logs. |
|
||||
| `AUTH_ENABLED` | `true` | Enable/disable login |
|
||||
| `LOCALHOST_BYPASS` | `false` | Development-only auth bypass for loopback requests. Keep false for shared/network deployments. |
|
||||
| `ALLOWED_ORIGINS` | `http://localhost,http://127.0.0.1` | Comma-separated exact permitted origins for cross-origin browser/API clients. |
|
||||
| `SECURE_COOKIES` | `false` | Set true when serving Odysseus through HTTPS at a trusted proxy or private access gateway. |
|
||||
| `DATABASE_URL` | `sqlite:///./data/app.db` | Database connection string |
|
||||
| `CHROMADB_HOST` | `localhost` | ChromaDB host for vector memory. Docker overrides this to `chromadb`. |
|
||||
| `CHROMADB_PORT` | `8100` | ChromaDB port for manual host runs. Docker overrides this to `8000`. |
|
||||
| `EMBEDDING_URL` | -- | OpenAI-compatible embeddings endpoint |
|
||||
| `ODYSSEUS_CHAT_UPLOAD_MAX_BYTES` | `10485760` | Chat/agent attachment cap in bytes. Raise for larger local PDFs or text documents. |
|
||||
| `ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES` | `104857600` | Gallery image upload cap in bytes (100 MB). |
|
||||
| `ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES` | `26214400` | Gallery transform input cap in bytes (25 MB). |
|
||||
| `ODYSSEUS_MEMORY_IMPORT_MAX_BYTES` | `10485760` | Memory import file cap in bytes (10 MB). |
|
||||
| `ODYSSEUS_PERSONAL_UPLOAD_MAX_BYTES` | `26214400` | Personal document upload cap in bytes (25 MB). |
|
||||
| `ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES` | `26214400` | Email compose attachment cap in bytes (25 MB). |
|
||||
| `ODYSSEUS_STT_MAX_AUDIO_BYTES` | `26214400` | Speech-to-text audio cap in bytes (25 MB). |
|
||||
| `ODYSSEUS_ICS_MAX_BYTES` | `10485760` | Calendar `.ics` import cap in bytes (10 MB). |
|
||||
|
||||
All upload-limit vars are validated (must be a positive integer) and optional; an invalid value fails fast at startup.
|
||||
|
||||
### Built-in MCP servers (optional setup)
|
||||
|
||||
Odysseus auto-registers a few built-in MCP servers at startup. The npx-based ones (currently the browser server, `@playwright/mcp`) only start when their npm package is already in the local npx cache. If a package isn't cached, that server is skipped with a startup log message explaining what to do, so a fresh install does not block on a multi-minute npm download or hang if Playwright system deps are missing.
|
||||
|
||||
To enable the browser MCP (page navigation, screenshots, vision), run once:
|
||||
|
||||
```bash
|
||||
npx -y @playwright/mcp@latest --version
|
||||
```
|
||||
|
||||
That installs `@playwright/mcp` plus Playwright (~300MB total). Restart Odysseus and the server will register at startup.
|
||||
|
||||
## Architecture
|
||||
```
|
||||
app.py # FastAPI entry point
|
||||
core/ auth, database, middleware, constants
|
||||
src/ llm_core, agent_loop, agent_tools, chat_processor, search/
|
||||
routes/ chat, session, document, memory, model … endpoints
|
||||
services/ docs, memory, search, hwfit (Cookbook) …
|
||||
static/ index.html + app.js + style.css + js/ (modular front-end)
|
||||
docs/ landing page (index.html) + preview clips
|
||||
```
|
||||
|
||||
## Data
|
||||
All user data lives in `data/` (gitignored): `app.db` (sessions, messages, documents),
|
||||
`memory.json`, `presets.json`, `uploads/`, `personal_docs/`, `chroma/`, `settings.json`.
|
||||
|
||||
To back up or restore everything in `data/`, see the
|
||||
[Backup & Restore guide](docs/backup-restore.md).
|
||||
@@ -102,6 +102,7 @@ python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py POST /api/codex/memory
|
||||
|
||||
## Email draft + send
|
||||
|
||||
- Prefer `POST /api/codex/emails/draft-document` for agent-written email replies. It creates an editable Odysseus Document with `language: "email"` and does not touch IMAP/send.
|
||||
- `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.
|
||||
|
||||
|
||||
@@ -17,6 +17,11 @@ 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 emails draft-doc JSON_PAYLOAD", file=sys.stderr)
|
||||
print(" odysseus_api.py documents list [limit]", file=sys.stderr)
|
||||
print(" odysseus_api.py documents read DOC_ID", file=sys.stderr)
|
||||
print(" odysseus_api.py documents create JSON_PAYLOAD", file=sys.stderr)
|
||||
print(" odysseus_api.py documents delete DOC_ID", 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)
|
||||
@@ -79,6 +84,33 @@ def main() -> int:
|
||||
method = "GET"
|
||||
path = f"/api/codex/emails/{sys.argv[3]}"
|
||||
body = None
|
||||
elif action in ("draft-doc", "draft_document") and len(sys.argv) >= 4:
|
||||
method = "POST"
|
||||
path = "/api/codex/emails/draft-document"
|
||||
body = " ".join(sys.argv[3:])
|
||||
else:
|
||||
return _usage()
|
||||
elif command in ("documents", "docs"):
|
||||
if len(sys.argv) < 3:
|
||||
return _usage()
|
||||
action = sys.argv[2].lower()
|
||||
if action == "list":
|
||||
method = "GET"
|
||||
limit = sys.argv[3] if len(sys.argv) >= 4 else "50"
|
||||
path = f"/api/codex/documents?limit={limit}"
|
||||
body = None
|
||||
elif action == "read" and len(sys.argv) >= 4:
|
||||
method = "GET"
|
||||
path = f"/api/codex/documents/{sys.argv[3]}"
|
||||
body = None
|
||||
elif action == "create" and len(sys.argv) >= 4:
|
||||
method = "POST"
|
||||
path = "/api/codex/documents"
|
||||
body = " ".join(sys.argv[3:])
|
||||
elif action == "delete" and len(sys.argv) >= 4:
|
||||
method = "DELETE"
|
||||
path = f"/api/codex/documents/{sys.argv[3]}"
|
||||
body = None
|
||||
else:
|
||||
return _usage()
|
||||
elif command == "cookbook":
|
||||
|
||||
@@ -17,6 +17,11 @@ 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 emails draft-doc JSON_PAYLOAD", file=sys.stderr)
|
||||
print(" odysseus_api.py documents list [limit]", file=sys.stderr)
|
||||
print(" odysseus_api.py documents read DOC_ID", file=sys.stderr)
|
||||
print(" odysseus_api.py documents create JSON_PAYLOAD", file=sys.stderr)
|
||||
print(" odysseus_api.py documents delete DOC_ID", 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)
|
||||
@@ -79,6 +84,33 @@ def main() -> int:
|
||||
method = "GET"
|
||||
path = f"/api/codex/emails/{sys.argv[3]}"
|
||||
body = None
|
||||
elif action in ("draft-doc", "draft_document") and len(sys.argv) >= 4:
|
||||
method = "POST"
|
||||
path = "/api/codex/emails/draft-document"
|
||||
body = " ".join(sys.argv[3:])
|
||||
else:
|
||||
return _usage()
|
||||
elif command in ("documents", "docs"):
|
||||
if len(sys.argv) < 3:
|
||||
return _usage()
|
||||
action = sys.argv[2].lower()
|
||||
if action == "list":
|
||||
method = "GET"
|
||||
limit = sys.argv[3] if len(sys.argv) >= 4 else "50"
|
||||
path = f"/api/codex/documents?limit={limit}"
|
||||
body = None
|
||||
elif action == "read" and len(sys.argv) >= 4:
|
||||
method = "GET"
|
||||
path = f"/api/codex/documents/{sys.argv[3]}"
|
||||
body = None
|
||||
elif action == "create" and len(sys.argv) >= 4:
|
||||
method = "POST"
|
||||
path = "/api/codex/documents"
|
||||
body = " ".join(sys.argv[3:])
|
||||
elif action == "delete" and len(sys.argv) >= 4:
|
||||
method = "DELETE"
|
||||
path = f"/api/codex/documents/{sys.argv[3]}"
|
||||
body = None
|
||||
else:
|
||||
return _usage()
|
||||
elif command == "cookbook":
|
||||
|
||||
@@ -102,6 +102,7 @@ python3 integrations/codex/scripts/odysseus_api.py POST /api/codex/memory '{"tex
|
||||
|
||||
## Email draft + send
|
||||
|
||||
- Prefer `POST /api/codex/emails/draft-document` for Codex-written email replies. It creates an editable Odysseus Document with `language: "email"` and does not touch IMAP/send.
|
||||
- `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.
|
||||
|
||||
|
||||
+14
-1
@@ -141,7 +141,20 @@ if (-not (Find-GitBash)) {
|
||||
Write-Host " https://git-scm.com/download/win" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
# 6. Start the server (use `python -m uvicorn` - bare `uvicorn` may not be on PATH)
|
||||
# 6. Point CUDA_PATH at a real CUDA toolkit so GPU llama-cpp-python can import.
|
||||
$cudaBase = "C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA"
|
||||
if (Test-Path $cudaBase) {
|
||||
$cudaBest = Get-ChildItem $cudaBase -Directory -ErrorAction SilentlyContinue |
|
||||
Where-Object { Test-Path (Join-Path $_.FullName "bin") } |
|
||||
Sort-Object { try { [version]($_.Name -replace "^v", "") } catch { [version]"0.0" } } -Descending |
|
||||
Select-Object -First 1
|
||||
if ($cudaBest) {
|
||||
$env:CUDA_PATH = $cudaBest.FullName
|
||||
Write-Host ("Using CUDA_PATH = " + $cudaBest.FullName) -ForegroundColor Cyan
|
||||
}
|
||||
}
|
||||
|
||||
# 7. Start the server (use `python -m uvicorn` - bare `uvicorn` may not be on PATH)
|
||||
Write-Step ("Starting Odysseus at http://{0}:{1}" -f $BindHost, $Port)
|
||||
Write-Host "Press Ctrl+C to stop."
|
||||
Write-Host ""
|
||||
|
||||
+102
-1
@@ -885,8 +885,109 @@ def _smtp_connect(account=None, cfg=None):
|
||||
return conn
|
||||
|
||||
|
||||
def _read_agent_email_confirm_setting() -> bool:
|
||||
"""True if the user wants agent send_email/reply_to_email calls to be
|
||||
queued for manual approval instead of SMTPed immediately. Defaults to
|
||||
True so a fresh install is safe — agents have been observed inventing
|
||||
signatures and sending to real recipients without the user's review."""
|
||||
try:
|
||||
from src.settings import get_setting
|
||||
return bool(get_setting("agent_email_confirm", True))
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
|
||||
def _stash_agent_draft(*, to, subject, body, in_reply_to=None, references=None,
|
||||
cc=None, bcc=None, account=None) -> dict:
|
||||
"""Insert the composed email into scheduled_emails with status
|
||||
'agent_draft' and a far-future send_at so the scheduled-send poller
|
||||
never picks it up. Returns the pending payload the model surfaces to
|
||||
the user (and that the chat UI can render as an approval card)."""
|
||||
try:
|
||||
from src.constants import SCHEDULED_EMAILS_DB
|
||||
except Exception:
|
||||
return {"success": False, "error": "Pending-email storage unavailable"}
|
||||
pending_id = uuid.uuid4().hex[:16]
|
||||
far_future = "9999-12-31T00:00:00"
|
||||
now = datetime.utcnow().isoformat()
|
||||
try:
|
||||
conn = sqlite3.connect(SCHEDULED_EMAILS_DB)
|
||||
# Touch the schema in case the email-routes init hasn't run yet
|
||||
# (MCP server can boot independently).
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS scheduled_emails (
|
||||
id TEXT PRIMARY KEY,
|
||||
to_addr TEXT NOT NULL,
|
||||
cc TEXT,
|
||||
bcc TEXT,
|
||||
subject TEXT,
|
||||
body TEXT NOT NULL,
|
||||
in_reply_to TEXT,
|
||||
references_hdr TEXT,
|
||||
attachments TEXT,
|
||||
send_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
error TEXT,
|
||||
owner TEXT DEFAULT '',
|
||||
account_id TEXT,
|
||||
odysseus_kind TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
INSERT INTO scheduled_emails
|
||||
(id, to_addr, cc, bcc, subject, body, in_reply_to, references_hdr,
|
||||
attachments, send_at, created_at, status, account_id, odysseus_kind, owner)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'agent_draft', ?, ?, ?)
|
||||
""", (
|
||||
pending_id,
|
||||
to if isinstance(to, str) else ", ".join(to),
|
||||
cc if isinstance(cc, str) else (", ".join(cc) if cc else None),
|
||||
bcc if isinstance(bcc, str) else (", ".join(bcc) if bcc else None),
|
||||
subject or "",
|
||||
body or "",
|
||||
in_reply_to or None,
|
||||
references if isinstance(references, str) else (" ".join(references) if references else None),
|
||||
"[]",
|
||||
far_future,
|
||||
now,
|
||||
account or None,
|
||||
"agent_draft",
|
||||
"",
|
||||
))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"Failed to stash draft: {e}"}
|
||||
return {
|
||||
"success": True,
|
||||
"pending": True,
|
||||
"pending_id": pending_id,
|
||||
"to": to if isinstance(to, str) else ", ".join(to),
|
||||
"subject": subject or "",
|
||||
"body": body or "",
|
||||
"message": (
|
||||
"✋ Draft staged for your approval — nothing has been sent yet.\n"
|
||||
"Review the To/Subject/Body above. Reply 'send' to deliver, or "
|
||||
"'cancel' to discard."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _send_email(to, subject, body, in_reply_to=None, references=None, cc=None, bcc=None, account=None):
|
||||
"""Send an email via SMTP. Returns dict with status."""
|
||||
"""Send an email via SMTP. Returns dict with status.
|
||||
|
||||
When the `agent_email_confirm` setting is on (the default), the email
|
||||
is NOT SMTPed — instead it lands in scheduled_emails as an
|
||||
`agent_draft` row and the user reviews + approves it from the chat
|
||||
UI. This closes the auto-send hole that let earlier models invent
|
||||
signatures and ship them to real recipients without confirmation."""
|
||||
if _read_agent_email_confirm_setting():
|
||||
return _stash_agent_draft(
|
||||
to=to, subject=subject, body=body,
|
||||
in_reply_to=in_reply_to, references=references,
|
||||
cc=cc, bcc=bcc, account=account,
|
||||
)
|
||||
send_account, cfg = _resolve_send_config(account)
|
||||
msg = EmailMessage()
|
||||
msg["From"] = _clean_header_value(cfg["from_address"])
|
||||
|
||||
@@ -93,16 +93,15 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
||||
if category_filter:
|
||||
msg += f" in category '{category_filter}'"
|
||||
return [TextContent(type="text", text=msg + ".")]
|
||||
|
||||
lines = [f"Found {len(memories)} memory entries:\n"]
|
||||
for m in memories[:100]:
|
||||
for m in memories:
|
||||
cat = m.get("category", "fact")
|
||||
mid = m.get("id", "?")[:8]
|
||||
text = m.get("text", "")
|
||||
if len(text) > 150:
|
||||
text = text[:150] + "..."
|
||||
lines.append(f"- [{cat}] `{mid}` — {text}")
|
||||
if len(memories) > 100:
|
||||
lines.append(f"... and {len(memories) - 100} more")
|
||||
return [TextContent(type="text", text="\n".join(lines))]
|
||||
|
||||
elif action == "add":
|
||||
|
||||
Generated
+12
-9
@@ -5,16 +5,16 @@
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.98.0"
|
||||
"@anthropic-ai/sdk": "^0.104.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antithesishq/bombadil": "^0.3.2"
|
||||
"@antithesishq/bombadil": "^0.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk": {
|
||||
"version": "0.98.0",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.98.0.tgz",
|
||||
"integrity": "sha512-N7aXtCvC5g6T1Y4V29lJjceu/zTkVkIZF0jdBvagr0TRFHuKeImffalGWEfqZKrvjH+IQbzJWw6TmSmUzrlMgg==",
|
||||
"version": "0.104.1",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.104.1.tgz",
|
||||
"integrity": "sha512-gGACa/+IaiXzRRmF96aOhamoBgapKRBiFWbmmTFP8aMkpaEcuStF+Q61bjo4vPxBM7gqWJNZqsngslRdnLHv0Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"json-schema-to-ts": "^3.1.1",
|
||||
@@ -33,11 +33,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@antithesishq/bombadil": {
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@antithesishq/bombadil/-/bombadil-0.3.2.tgz",
|
||||
"integrity": "sha512-ATy1w9ZY5gbny1H8DFc7rxZitT7DLLLFDiGcRZe+8TQiUrV5tLO+IJGOVNNLp3RpCqjZqSsxGiKoQsx31ipV1g==",
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@antithesishq/bombadil/-/bombadil-0.5.0.tgz",
|
||||
"integrity": "sha512-s0zImmr0iyvSP6QcVLvf40CUiZYIdWBAxiq20uhzujwvfitYa3PGJN652k/pLtVccHM/JrGQxZdvLnihZpltHA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"bombadil": "bin/bombadil.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.7",
|
||||
|
||||
+2
-2
@@ -4,9 +4,9 @@
|
||||
"url": "https://github.com/pewdiepie-archdaemon/odysseus.git"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antithesishq/bombadil": "^0.3.2"
|
||||
"@antithesishq/bombadil": "^0.5.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.98.0"
|
||||
"@anthropic-ai/sdk": "^0.104.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,4 +33,4 @@ PyMuPDF
|
||||
# magika (onnxruntime), already a core dep via fastembed. We avoid the
|
||||
# [all]/Azure/audio extras (cloud + heavy). Pinned to a release >30 days old per
|
||||
# the dependency-age discussion in issue #485.
|
||||
markitdown[docx,pptx,xlsx,xls]==0.1.5
|
||||
markitdown[docx,pptx,xlsx,xls]==0.1.6
|
||||
|
||||
+2
-2
@@ -3,8 +3,8 @@ uvicorn
|
||||
python-multipart
|
||||
python-dotenv
|
||||
httpx
|
||||
pydantic>=2.0
|
||||
pydantic-settings>=2.0
|
||||
pydantic>=2.13.4
|
||||
pydantic-settings>=2.14.1
|
||||
SQLAlchemy
|
||||
pypdf
|
||||
beautifulsoup4
|
||||
|
||||
@@ -31,6 +31,7 @@ ALLOWED_SCOPES = {
|
||||
TOKEN_PROFILES = {
|
||||
"chat": ["chat"],
|
||||
"codex_todos": ["todos:read", "todos:write"],
|
||||
"codex_documents": ["documents:read", "documents:write"],
|
||||
"codex_email_drafts": ["email:read", "email:draft", "documents:read", "documents:write"],
|
||||
}
|
||||
|
||||
|
||||
+31
-1
@@ -12,7 +12,7 @@ import re
|
||||
from pathlib import Path
|
||||
|
||||
from core.atomic_io import atomic_write_json, atomic_write_text
|
||||
from core.auth import AuthManager
|
||||
from core.auth import AuthManager, SetAdminResult
|
||||
from src.constants import DEEP_RESEARCH_DIR, MEMORY_FILE, SKILLS_DIR
|
||||
from src.rate_limiter import RateLimiter
|
||||
from src.settings_scrub import scrub_settings
|
||||
@@ -73,6 +73,11 @@ class DeleteUserRequest(BaseModel):
|
||||
class RenameUserRequest(BaseModel):
|
||||
username: str
|
||||
|
||||
|
||||
class SetAdminRequest(BaseModel):
|
||||
is_admin: bool
|
||||
|
||||
|
||||
class SetOpenRegistrationRequest(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
@@ -487,6 +492,31 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
invalidator()
|
||||
return {"ok": True, "username": new_username, "renamed_self": old_username == user}
|
||||
|
||||
@router.put("/users/{username}/admin")
|
||||
async def set_user_admin(username: str, body: SetAdminRequest, request: Request):
|
||||
"""Promote/demote a user to/from admin. Admin only.
|
||||
|
||||
The last remaining admin can't be demoted (no lockout). Self-demotion
|
||||
is allowed while another admin exists; the `self` flag tells the UI to
|
||||
reload the acting user into the normal-user view.
|
||||
"""
|
||||
user = _get_current_user(request)
|
||||
if not user or not auth_manager.is_admin(user):
|
||||
raise HTTPException(403, "Admin only")
|
||||
result = auth_manager.set_admin(username, body.is_admin, user)
|
||||
if result is SetAdminResult.USER_NOT_FOUND:
|
||||
raise HTTPException(404, "User not found")
|
||||
if result is SetAdminResult.NOT_AUTHORIZED:
|
||||
raise HTTPException(403, "Admin only")
|
||||
if result is SetAdminResult.LAST_ADMIN:
|
||||
raise HTTPException(400, "Cannot demote the last admin")
|
||||
target = (username or "").strip().lower()
|
||||
return {
|
||||
"ok": True,
|
||||
"is_admin": body.is_admin,
|
||||
"self": target == (user or "").strip().lower(),
|
||||
}
|
||||
|
||||
@router.post("/signup-toggle", deprecated=True)
|
||||
async def toggle_signup(request: Request):
|
||||
"""
|
||||
|
||||
+65
-29
@@ -11,7 +11,7 @@ from pydantic import BaseModel
|
||||
from sqlalchemy import or_, and_
|
||||
from dateutil.rrule import rrulestr
|
||||
|
||||
from core.database import SessionLocal, CalendarCal, CalendarEvent
|
||||
from core.database import SessionLocal, CalendarCal, CalendarDeletedEvent, CalendarEvent
|
||||
from src.auth_helpers import require_user
|
||||
from src.upload_limits import read_upload_limited, ICS_MAX_BYTES
|
||||
|
||||
@@ -126,6 +126,54 @@ def _resolve_base_uid(uid: str) -> str:
|
||||
raise ValueError("malformed compound UID: missing base before ::")
|
||||
return base
|
||||
|
||||
|
||||
async def _push_caldav_event_after_commit(owner: str, uid: str, action: str):
|
||||
"""Best-effort CalDAV write-through. Local writes stay authoritative if
|
||||
the remote server is unreachable; pending flags let /sync retry later."""
|
||||
try:
|
||||
result = {"ok": True}
|
||||
if action == "create":
|
||||
from src.caldav_sync import push_event_create
|
||||
result = await push_event_create(owner, uid)
|
||||
elif action == "update":
|
||||
from src.caldav_sync import push_event_update
|
||||
result = await push_event_update(owner, uid)
|
||||
elif action == "delete":
|
||||
from src.caldav_sync import push_event_delete
|
||||
result = await push_event_delete(owner, uid)
|
||||
if result and not result.get("ok") and not result.get("skipped"):
|
||||
raise RuntimeError(result.get("error") or result)
|
||||
except Exception as e:
|
||||
logger.warning("CalDAV %s push failed for uid=%s: %s", action, uid, e)
|
||||
if action in {"create", "update"}:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ev = _get_or_404_event(db, uid, owner)
|
||||
ev.caldav_sync_pending = action
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _record_caldav_delete_tombstone(db, ev: CalendarEvent, owner: str) -> None:
|
||||
if not (ev.calendar and ev.calendar.source == "caldav"):
|
||||
return
|
||||
tombstone = db.query(CalendarDeletedEvent).filter(
|
||||
CalendarDeletedEvent.uid == ev.uid,
|
||||
CalendarDeletedEvent.owner == owner,
|
||||
).first()
|
||||
if not tombstone:
|
||||
tombstone = CalendarDeletedEvent(uid=ev.uid, owner=owner)
|
||||
db.add(tombstone)
|
||||
tombstone.calendar_id = ev.calendar_id
|
||||
tombstone.remote_href = ev.remote_href
|
||||
tombstone.remote_etag = ev.remote_etag
|
||||
tombstone.caldav_base_url = getattr(ev.calendar, "caldav_base_url", None)
|
||||
tombstone.summary = ev.summary or ""
|
||||
tombstone.last_error = None
|
||||
|
||||
# ── Pydantic models ──
|
||||
|
||||
class EventCreate(BaseModel):
|
||||
@@ -843,13 +891,13 @@ def setup_calendar_routes() -> APIRouter:
|
||||
return {"ok": False, "error": str(e)[:200]}
|
||||
|
||||
@router.post("/sync")
|
||||
async def sync_caldav_endpoint(request: Request):
|
||||
"""Pull events from the configured CalDAV server into local DB.
|
||||
async def sync_caldav_endpoint(request: Request, direction: str = "pull"):
|
||||
"""Sync events with the configured CalDAV server.
|
||||
Returns counts + any per-calendar errors. Called by the frontend
|
||||
on calendar open and by the periodic scheduler loop."""
|
||||
owner = _require_user(request)
|
||||
from src.caldav_sync import sync_caldav
|
||||
return await sync_caldav(owner)
|
||||
from src.caldav_sync import sync_caldav_direction
|
||||
return await sync_caldav_direction(owner, direction)
|
||||
|
||||
|
||||
@router.delete("/calendars/{cal_id}")
|
||||
@@ -1002,19 +1050,12 @@ def setup_calendar_routes() -> APIRouter:
|
||||
is_utc=_is_utc and not data.all_day,
|
||||
rrule=data.rrule or "",
|
||||
color=data.color or None,
|
||||
caldav_sync_pending="create" if cal.source == "caldav" else None,
|
||||
)
|
||||
db.add(ev)
|
||||
db.commit()
|
||||
if cal.source == "caldav":
|
||||
# Push the new event to the remote so it appears on the user's
|
||||
# other devices — the sync is otherwise pull-only (#800).
|
||||
from src.caldav_writeback import writeback_event
|
||||
await writeback_event(owner, cal.source, cal.id, {
|
||||
"uid": uid, "summary": data.summary, "description": data.description,
|
||||
"location": data.location, "dtstart": dtstart, "dtend": dtend,
|
||||
"all_day": data.all_day, "is_utc": _is_utc and not data.all_day,
|
||||
"rrule": data.rrule or "",
|
||||
})
|
||||
await _push_caldav_event_after_commit(owner, uid, "create")
|
||||
return {"ok": True, "uid": uid}
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -1060,15 +1101,12 @@ def setup_calendar_routes() -> APIRouter:
|
||||
ev.rrule = data.rrule
|
||||
if data.color is not None:
|
||||
ev.color = data.color if data.color else None
|
||||
is_caldav = ev.calendar and ev.calendar.source == "caldav"
|
||||
if is_caldav:
|
||||
ev.caldav_sync_pending = "update"
|
||||
db.commit()
|
||||
cal = db.query(CalendarCal).filter(CalendarCal.id == ev.calendar_id).first()
|
||||
if cal and cal.source == "caldav":
|
||||
from src.caldav_writeback import writeback_event
|
||||
await writeback_event(owner, cal.source, cal.id, {
|
||||
"uid": ev.uid, "summary": ev.summary, "description": ev.description,
|
||||
"location": ev.location, "dtstart": ev.dtstart, "dtend": ev.dtend,
|
||||
"all_day": ev.all_day, "is_utc": ev.is_utc, "rrule": ev.rrule or "",
|
||||
})
|
||||
if is_caldav:
|
||||
await _push_caldav_event_after_commit(owner, base_uid, "update")
|
||||
return {"ok": True}
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -1089,15 +1127,13 @@ def setup_calendar_routes() -> APIRouter:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ev = _get_or_404_event(db, base_uid, owner)
|
||||
# Capture what the remote push needs BEFORE the row is gone.
|
||||
_cal = db.query(CalendarCal).filter(CalendarCal.id == ev.calendar_id).first()
|
||||
_is_caldav = bool(_cal and _cal.source == "caldav")
|
||||
_cal_id, _ev_uid = ev.calendar_id, ev.uid
|
||||
is_caldav = ev.calendar and ev.calendar.source == "caldav"
|
||||
if is_caldav:
|
||||
_record_caldav_delete_tombstone(db, ev, owner)
|
||||
db.delete(ev)
|
||||
db.commit()
|
||||
if _is_caldav:
|
||||
from src.caldav_writeback import writeback_event
|
||||
await writeback_event(owner, "caldav", _cal_id, {"uid": _ev_uid}, delete=True)
|
||||
if is_caldav:
|
||||
await _push_caldav_event_after_commit(owner, base_uid, "delete")
|
||||
return {"ok": True}
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
+33
-2
@@ -505,6 +505,29 @@ def _normalize_model_id_from_cache(sess) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def _session_is_research_spinoff(sess) -> bool:
|
||||
"""True if this session was created via research "Discuss" spin-off.
|
||||
|
||||
Detected by the primer system message the spin-off endpoint seeds into
|
||||
history (metadata ``research_spinoff_from``). Such sessions are grounded
|
||||
on the seeded report, so global memory + personal-doc RAG injection is
|
||||
suppressed for them (the report is the sole knowledge base). Handles both
|
||||
ChatMessage objects and plain dicts.
|
||||
"""
|
||||
for m in getattr(sess, "history", []) or []:
|
||||
role = getattr(m, "role", None)
|
||||
if role is None and isinstance(m, dict):
|
||||
role = m.get("role")
|
||||
if role != "system":
|
||||
continue
|
||||
md = getattr(m, "metadata", None)
|
||||
if md is None and isinstance(m, dict):
|
||||
md = m.get("metadata")
|
||||
if (md or {}).get("research_spinoff_from"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def build_chat_context(
|
||||
sess,
|
||||
request,
|
||||
@@ -570,9 +593,17 @@ async def build_chat_context(
|
||||
mem_enabled, user, incognito, no_memory, uprefs.get("memory_enabled", "NOT_SET"),
|
||||
)
|
||||
|
||||
# Research-spinoff ("Discuss") sessions are grounded on the seeded report:
|
||||
# the primer system message IS the knowledge base. Injecting global memory
|
||||
# or personal-doc RAG on every turn pulls in keyword-matched but off-topic
|
||||
# facts ("wrong data") and competes with the report, so suppress both here.
|
||||
is_research_spinoff = _session_is_research_spinoff(sess)
|
||||
if is_research_spinoff:
|
||||
mem_enabled = False
|
||||
|
||||
# Use RAG?
|
||||
use_rag_val = (str(use_rag).lower() != "false") if use_rag is not None else True
|
||||
if incognito or not allow_tool_preprocessing:
|
||||
if incognito or not allow_tool_preprocessing or is_research_spinoff:
|
||||
use_rag_val = False
|
||||
|
||||
# If pre-fetched search context was provided (compare mode), skip live web search
|
||||
@@ -595,7 +626,7 @@ async def build_chat_context(
|
||||
incognito=incognito,
|
||||
use_skills=skills_enabled,
|
||||
)
|
||||
if use_rag is not None:
|
||||
if use_rag is not None or is_research_spinoff:
|
||||
_preface_kwargs["use_rag"] = use_rag_val
|
||||
preface, rag_sources, web_sources = chat_processor.build_context_preface(**_preface_kwargs)
|
||||
|
||||
|
||||
+97
-9
@@ -6,7 +6,7 @@ import os
|
||||
import time
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, AsyncGenerator, List
|
||||
from typing import Dict, Any, AsyncGenerator, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException, Form, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
@@ -526,6 +526,66 @@ def setup_chat_routes(
|
||||
active_doc_id = form_data.get("active_doc_id", "").strip()
|
||||
logger.info(f"[doc-inject] chat_mode={chat_mode}, active_doc_id={active_doc_id!r}")
|
||||
|
||||
# Active email reader — when the user has an email open in the UI, the
|
||||
# frontend passes its uid/folder/account so "reply", "summarize this",
|
||||
# etc. resolve to the real email instead of the agent inventing a
|
||||
# fake markdown draft.
|
||||
active_email_uid = form_data.get("active_email_uid", "").strip()
|
||||
active_email_folder = form_data.get("active_email_folder", "INBOX").strip() or "INBOX"
|
||||
active_email_account = form_data.get("active_email_account", "").strip()
|
||||
active_email_ctx: Optional[Dict[str, str]] = None
|
||||
# Always reset between requests so a stale active-email pointer from
|
||||
# a previous turn (different reader closed, different account, etc.)
|
||||
# can't leak in when the user has no email open this turn.
|
||||
try:
|
||||
from src.tool_implementations import clear_active_email
|
||||
clear_active_email()
|
||||
except Exception:
|
||||
pass
|
||||
if active_email_uid:
|
||||
active_email_ctx = {
|
||||
"uid": active_email_uid,
|
||||
"folder": active_email_folder,
|
||||
"account": active_email_account,
|
||||
}
|
||||
# Try to enrich with subject + from so the agent's system prompt
|
||||
# block can quote them. Best-effort: a stale cache is fine, a
|
||||
# missing email just means we pass uid/folder/account only.
|
||||
try:
|
||||
from routes.email_routes import _read_cache_get, _read_cache_key
|
||||
_ck = _read_cache_key(active_email_account or None, active_email_folder, active_email_uid, owner=get_current_user(request))
|
||||
_cached_email = _read_cache_get(_ck)
|
||||
if _cached_email and isinstance(_cached_email, dict):
|
||||
active_email_ctx["subject"] = str(_cached_email.get("subject") or "")
|
||||
active_email_ctx["from"] = str(
|
||||
_cached_email.get("from_address")
|
||||
or _cached_email.get("from")
|
||||
or _cached_email.get("from_name")
|
||||
or ""
|
||||
)
|
||||
_body_preview = (_cached_email.get("body") or "")[:2000]
|
||||
if _body_preview:
|
||||
active_email_ctx["body_preview"] = _body_preview
|
||||
except Exception as _e:
|
||||
logger.debug(f"[email-inject] cache enrich skipped: {_e}")
|
||||
# Stash so email tools can resolve "this email" without UID guessing.
|
||||
try:
|
||||
from src.tool_implementations import set_active_email
|
||||
set_active_email(
|
||||
uid=active_email_uid,
|
||||
folder=active_email_folder,
|
||||
account=active_email_account or None,
|
||||
subject=active_email_ctx.get("subject"),
|
||||
sender=active_email_ctx.get("from"),
|
||||
)
|
||||
except Exception as _e:
|
||||
logger.debug(f"[email-inject] set_active_email failed: {_e}")
|
||||
logger.info(
|
||||
"[email-inject] active_email uid=%s folder=%s account=%s subject=%r",
|
||||
active_email_uid, active_email_folder, active_email_account or "(default)",
|
||||
active_email_ctx.get("subject", ""),
|
||||
)
|
||||
|
||||
try:
|
||||
# Attachment-only sends: skip the message-required check when the
|
||||
# user has attached one or more files (the attachment IS the action).
|
||||
@@ -641,15 +701,27 @@ def setup_chat_routes(
|
||||
active_doc_id,
|
||||
)
|
||||
active_doc = None
|
||||
elif doc_session and doc_session != session:
|
||||
logger.warning(
|
||||
"[doc-inject] ignoring stale active_doc_id %s from session %s while in session %s",
|
||||
active_doc_id,
|
||||
doc_session,
|
||||
session,
|
||||
)
|
||||
active_doc = None
|
||||
else:
|
||||
# NOTE: previously dropped the doc when doc.session_id
|
||||
# != current chat session — but that broke the common
|
||||
# case of "open an email draft from one chat, ask a
|
||||
# different chat to write into it". The frontend only
|
||||
# sends active_doc_id for docs currently visible in
|
||||
# the UI, and we already owner-checked above, so trust
|
||||
# the explicit signal. We just log the mismatch and
|
||||
# re-bind the doc to the current session so future
|
||||
# turns find it via the session-fallback path too.
|
||||
if doc_session and doc_session != session:
|
||||
logger.info(
|
||||
"[doc-inject] cross-session active_doc_id %s (was session %s, now %s) — accepting and rebinding",
|
||||
active_doc_id, doc_session, session,
|
||||
)
|
||||
try:
|
||||
active_doc.session_id = session
|
||||
_doc_db.commit()
|
||||
except Exception as _e:
|
||||
_doc_db.rollback()
|
||||
logger.warning(f"[doc-inject] session rebind failed: {_e}")
|
||||
logger.info(f"[doc-inject] found by ID: title={active_doc.title!r}, lang={active_doc.language!r}, is_active={active_doc.is_active}, content_len={len(active_doc.current_content or '')}")
|
||||
else:
|
||||
logger.warning(f"[doc-inject] NOT FOUND by ID {active_doc_id}")
|
||||
@@ -714,6 +786,21 @@ def setup_chat_routes(
|
||||
"manage_skills", # skill presets tied to user
|
||||
})
|
||||
|
||||
# Active email reader open → strip the tools that let the agent
|
||||
# "drift" to a new compose: create_document (writes a fake email-
|
||||
# shaped .md file) and send_email (sends fresh to a recipient the
|
||||
# agent invented). With those gone, the only paths left for "write
|
||||
# email saying X" are ui_control open_email_reply (draft) and
|
||||
# reply_to_email (immediate send) — both of which use the open
|
||||
# email's UID. Code-level enforcement instead of relying on a
|
||||
# prompt rule the model can ignore.
|
||||
if active_email_ctx and active_email_ctx.get("uid"):
|
||||
disabled_tools.update({
|
||||
"create_document",
|
||||
"send_email",
|
||||
"mcp__email__send_email",
|
||||
})
|
||||
|
||||
# Enforce per-user privileges
|
||||
_privs = {}
|
||||
_user = ctx.user
|
||||
@@ -1181,6 +1268,7 @@ def setup_chat_routes(
|
||||
max_rounds=_max_rounds,
|
||||
context_length=ctx.context_length,
|
||||
active_document=active_doc,
|
||||
active_email=active_email_ctx,
|
||||
session_id=session,
|
||||
disabled_tools=disabled_tools if disabled_tools else None,
|
||||
tool_policy=tool_policy,
|
||||
|
||||
+66
-2
@@ -91,6 +91,20 @@ def _scope_owner(request: Request, allowed: set[str]) -> str:
|
||||
return require_user(request)
|
||||
|
||||
|
||||
def _scope_owner_all(request: Request, required: set[str]) -> str:
|
||||
"""Return owner only when an API token has every required scope."""
|
||||
if getattr(request.state, "api_token", False):
|
||||
scopes = set(getattr(request.state, "api_token_scopes", []) or [])
|
||||
missing = required - scopes
|
||||
if missing:
|
||||
raise HTTPException(403, f"API token missing required scope: {' and '.join(sorted(missing))}")
|
||||
owner = getattr(request.state, "api_token_owner", None)
|
||||
if not owner:
|
||||
raise HTTPException(403, "API token has no owner")
|
||||
return owner
|
||||
return require_user(request)
|
||||
|
||||
|
||||
def _find_endpoint(router: APIRouter | None, method: str, path: str):
|
||||
if router is None:
|
||||
return None
|
||||
@@ -138,7 +152,7 @@ def setup_codex_routes(
|
||||
"read": scoped(EMAIL_READ_SCOPES),
|
||||
"draft": scoped(EMAIL_DRAFT_SCOPES),
|
||||
"send": scoped(EMAIL_SEND_SCOPES),
|
||||
"actions": ["list", "read", "draft", "send"],
|
||||
"actions": ["list", "read", "draft_document", "draft", "send"],
|
||||
},
|
||||
"memory": {
|
||||
"read": scoped(MEMORY_READ_SCOPES),
|
||||
@@ -262,6 +276,56 @@ def setup_codex_routes(
|
||||
# Both handlers in routes/email_routes.py already accept `owner=` via
|
||||
# FastAPI Depends, so we call them directly without patching state.
|
||||
|
||||
def _email_draft_document_content(body: dict[str, Any]) -> str:
|
||||
def clean(v: Any) -> str:
|
||||
if isinstance(v, list):
|
||||
return ", ".join(str(x).strip() for x in v if str(x).strip())
|
||||
return str(v or "").strip()
|
||||
|
||||
to = clean(body.get("to"))
|
||||
cc = clean(body.get("cc"))
|
||||
bcc = clean(body.get("bcc"))
|
||||
subject = clean(body.get("subject"))
|
||||
in_reply_to = clean(body.get("in_reply_to"))
|
||||
references = clean(body.get("references"))
|
||||
body_text = str(body.get("body") or body.get("body_html") or "").strip()
|
||||
lines = [
|
||||
f"To: {to}",
|
||||
]
|
||||
if cc:
|
||||
lines.append(f"Cc: {cc}")
|
||||
if bcc:
|
||||
lines.append(f"Bcc: {bcc}")
|
||||
lines.append(f"Subject: {subject}")
|
||||
if in_reply_to:
|
||||
lines.append(f"In-Reply-To: {in_reply_to}")
|
||||
if references:
|
||||
lines.append(f"References: {references}")
|
||||
lines.extend(["---", body_text])
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
@router.post("/emails/draft-document")
|
||||
async def codex_email_draft_document(request: Request, body: dict[str, Any] = Body(default_factory=dict)):
|
||||
owner = _scope_owner_all(request, {"email:draft", "documents:write"})
|
||||
if documents_create_endpoint is None:
|
||||
raise HTTPException(503, "Documents integration is not available")
|
||||
from routes.document_routes import DocumentCreate
|
||||
|
||||
subject = str(body.get("subject") or "Email draft").strip() or "Email draft"
|
||||
title = str(body.get("title") or subject).strip() or "Email draft"
|
||||
req = DocumentCreate(
|
||||
session_id=body.get("session_id"),
|
||||
title=title,
|
||||
language="email",
|
||||
content=_email_draft_document_content(body),
|
||||
)
|
||||
result = await _as_owner(request, owner, documents_create_endpoint, request, req)
|
||||
if isinstance(result, dict):
|
||||
result = dict(result)
|
||||
result["draft_type"] = "document"
|
||||
result["send_required_confirmation"] = True
|
||||
return result
|
||||
|
||||
@router.post("/emails/draft")
|
||||
async def codex_email_draft(request: Request, body: dict[str, Any] = Body(default_factory=dict)):
|
||||
owner = _scope_owner(request, EMAIL_DRAFT_SCOPES)
|
||||
@@ -726,7 +790,7 @@ def setup_codex_routes(
|
||||
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()
|
||||
host = validate_remote_host((norm.get("host") or norm.get("remote_host") or "").strip() or None) or ""
|
||||
port = norm.get("port") or 8000
|
||||
import re as _re
|
||||
if not sess or not _re.fullmatch(r"[a-zA-Z0-9_-]+", sess):
|
||||
|
||||
+75
-21
@@ -12,6 +12,7 @@ import json
|
||||
import csv
|
||||
import io
|
||||
import os
|
||||
import inspect
|
||||
import httpx
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
@@ -45,10 +46,14 @@ def _save_settings(settings):
|
||||
def _get_carddav_config():
|
||||
import os
|
||||
settings = _load_settings()
|
||||
password = settings.get("carddav_password", os.environ.get("CARDDAV_PASSWORD", ""))
|
||||
if password and "carddav_password" in settings:
|
||||
from src.secret_storage import decrypt
|
||||
password = decrypt(password)
|
||||
return {
|
||||
"url": settings.get("carddav_url", os.environ.get("CARDDAV_URL", "")),
|
||||
"username": settings.get("carddav_username", os.environ.get("CARDDAV_USERNAME", "")),
|
||||
"password": settings.get("carddav_password", os.environ.get("CARDDAV_PASSWORD", "")),
|
||||
"password": password,
|
||||
}
|
||||
|
||||
|
||||
@@ -86,11 +91,13 @@ def _normalize_contact(contact: Dict) -> Dict:
|
||||
name = str(contact.get("name") or "").strip()
|
||||
if not name and emails:
|
||||
name = emails[0].split("@")[0]
|
||||
address = str(contact.get("address") or "").strip()
|
||||
return {
|
||||
"uid": str(contact.get("uid") or uuid.uuid4()),
|
||||
"name": name,
|
||||
"emails": emails,
|
||||
"phones": phones,
|
||||
"address": address,
|
||||
}
|
||||
|
||||
|
||||
@@ -146,7 +153,7 @@ def _parse_vcards(text: str) -> List[Dict]:
|
||||
for block in re.split(r"BEGIN:VCARD", text):
|
||||
if not block.strip():
|
||||
continue
|
||||
contact = {"name": "", "emails": [], "phones": [], "uid": ""}
|
||||
contact = {"name": "", "emails": [], "phones": [], "uid": "", "address": ""}
|
||||
for line in block.split("\n"):
|
||||
line = line.strip()
|
||||
# Strip an optional RFC 6350 group prefix (e.g. "item1.EMAIL;...")
|
||||
@@ -169,6 +176,15 @@ def _parse_vcards(text: str) -> List[Dict]:
|
||||
phone = _vunesc(name_part.split(":", 1)[1])
|
||||
if phone and phone not in contact["phones"]:
|
||||
contact["phones"].append(phone)
|
||||
elif name_part.startswith("ADR"):
|
||||
# vCard ADR is 7 semicolon-separated components:
|
||||
# post-office-box;extended-address;street;locality;region;postal-code;country.
|
||||
# Recover a human-readable string by joining non-empty
|
||||
# components with ", ".
|
||||
if ":" in name_part:
|
||||
raw = name_part.split(":", 1)[1]
|
||||
parts = [_vunesc(p).strip() for p in raw.split(";")]
|
||||
contact["address"] = ", ".join(p for p in parts if p)
|
||||
elif name_part.startswith("UID:"):
|
||||
contact["uid"] = _vunesc(name_part[4:])
|
||||
if contact["name"] or contact["emails"]:
|
||||
@@ -193,7 +209,8 @@ def _vesc(value: str) -> str:
|
||||
|
||||
def _build_vcard(name: str, email: str, uid: Optional[str] = None,
|
||||
emails: Optional[List[str]] = None,
|
||||
phones: Optional[List[str]] = None) -> str:
|
||||
phones: Optional[List[str]] = None,
|
||||
address: Optional[str] = None) -> str:
|
||||
"""Build a vCard. Accepts either a single `email` (legacy callers) or
|
||||
full `emails`/`phones` lists (edit path). The first email is marked
|
||||
PREF=1. All values are RFC-6350-escaped."""
|
||||
@@ -226,6 +243,12 @@ def _build_vcard(name: str, email: str, uid: Optional[str] = None,
|
||||
lines.append(f"EMAIL;PREF=1:{_vesc(em)}" if i == 0 else f"EMAIL:{_vesc(em)}")
|
||||
for ph in phone_list:
|
||||
lines.append(f"TEL:{_vesc(ph)}")
|
||||
# Address: stuff the whole human-readable string into the street
|
||||
# component of ADR. vCard ADR has 7 semicolon-separated components:
|
||||
# post-office-box;extended-address;street;locality;region;postal-code;country.
|
||||
addr = (address or "").strip()
|
||||
if addr:
|
||||
lines.append(f"ADR:;;{_vesc(addr)};;;;")
|
||||
lines.append("END:VCARD")
|
||||
return "\r\n".join(lines) + "\r\n"
|
||||
|
||||
@@ -362,7 +385,7 @@ def _resolve_resource_url(uid: str) -> str:
|
||||
return _lookup() or _vcard_url(uid)
|
||||
|
||||
|
||||
def _create_contact(name: str, email: str) -> bool:
|
||||
def _create_contact(name: str, email: str, address: str = "") -> bool:
|
||||
"""Add a new contact via CardDAV or local contacts."""
|
||||
cfg = _get_carddav_config()
|
||||
if not _carddav_configured(cfg):
|
||||
@@ -371,12 +394,12 @@ def _create_contact(name: str, email: str) -> bool:
|
||||
for c in contacts:
|
||||
if email_l and email_l in [e.lower() for e in c.get("emails", [])]:
|
||||
return True
|
||||
contacts.append(_normalize_contact({"name": name, "emails": [email]}))
|
||||
contacts.append(_normalize_contact({"name": name, "emails": [email], "address": address}))
|
||||
_save_local_contacts(contacts)
|
||||
return True
|
||||
|
||||
contact_uid = str(uuid.uuid4())
|
||||
vcard = _build_vcard(name, email, contact_uid)
|
||||
vcard = _build_vcard(name, email, contact_uid, address=address)
|
||||
try:
|
||||
url = _carddav_base_url(cfg) + "/" + contact_uid + ".vcf"
|
||||
auth = None
|
||||
@@ -609,7 +632,7 @@ def _contacts_to_csv(contacts: List[Dict]) -> str:
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
def _update_contact(uid: str, name: str, emails: List[str], phones: List[str]) -> bool:
|
||||
def _update_contact(uid: str, name: str, emails: List[str], phones: List[str], address: str = "") -> bool:
|
||||
"""Rewrite an existing contact via CardDAV or local contacts."""
|
||||
cfg = _get_carddav_config()
|
||||
if not _carddav_configured(cfg):
|
||||
@@ -618,16 +641,19 @@ def _update_contact(uid: str, name: str, emails: List[str], phones: List[str]) -
|
||||
out = []
|
||||
for c in contacts:
|
||||
if c.get("uid") == uid:
|
||||
out.append(_normalize_contact({"uid": uid, "name": name, "emails": emails, "phones": phones}))
|
||||
# Preserve existing address when caller passes "" (only
|
||||
# updating name/emails/phones, not touching address).
|
||||
addr = address if address else c.get("address", "")
|
||||
out.append(_normalize_contact({"uid": uid, "name": name, "emails": emails, "phones": phones, "address": addr}))
|
||||
found = True
|
||||
else:
|
||||
out.append(c)
|
||||
if not found:
|
||||
out.append(_normalize_contact({"uid": uid, "name": name, "emails": emails, "phones": phones}))
|
||||
out.append(_normalize_contact({"uid": uid, "name": name, "emails": emails, "phones": phones, "address": address}))
|
||||
_save_local_contacts(out)
|
||||
return True
|
||||
|
||||
vcard = _build_vcard(name, "", uid=uid, emails=emails, phones=phones)
|
||||
vcard = _build_vcard(name, "", uid=uid, emails=emails, phones=phones, address=address)
|
||||
# Use the real resource href (handles externally-created contacts whose
|
||||
# filename != UID); falls back to the <uid>.vcf guess.
|
||||
try:
|
||||
@@ -714,16 +740,39 @@ def setup_contacts_routes():
|
||||
"""Add a new contact."""
|
||||
name = (data.get("name") or "").strip()
|
||||
email = (data.get("email") or "").strip()
|
||||
phone = (data.get("phone") or "").strip()
|
||||
address = (data.get("address") or "").strip()
|
||||
if not email:
|
||||
return {"success": False, "error": "Email required"}
|
||||
# Check if already exists
|
||||
contacts = _fetch_contacts()
|
||||
for c in contacts:
|
||||
if email.lower() in [e.lower() for e in c["emails"]]:
|
||||
return {"success": True, "message": "Already exists", "contact": c}
|
||||
# Check if already exists by email
|
||||
if email:
|
||||
contacts = _fetch_contacts()
|
||||
for c in contacts:
|
||||
if email.lower() in [e.lower() for e in c["emails"]]:
|
||||
return {"success": True, "message": "Already exists", "contact": c}
|
||||
if not name:
|
||||
name = email.split("@")[0]
|
||||
ok = _create_contact(name, email)
|
||||
create_params = inspect.signature(_create_contact).parameters
|
||||
if len(create_params) >= 3:
|
||||
ok = _create_contact(name, email, address)
|
||||
else:
|
||||
ok = _create_contact(name, email)
|
||||
# If a phone was provided, do an immediate update to thread it
|
||||
# through (the simple _create_contact signature only takes name +
|
||||
# email + address; phones happen via update).
|
||||
if ok and phone:
|
||||
try:
|
||||
fresh = _fetch_contacts(force=True)
|
||||
created = next((c for c in fresh if name == c.get("name") and (not email or email in c.get("emails", []))), None)
|
||||
if created:
|
||||
_update_contact(
|
||||
created["uid"], name,
|
||||
created.get("emails", []),
|
||||
[phone],
|
||||
address,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return {"success": ok}
|
||||
|
||||
@router.post("/import")
|
||||
@@ -785,7 +834,11 @@ def setup_contacts_routes():
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
else:
|
||||
settings[key] = data[key]
|
||||
value = data[key]
|
||||
if key == "carddav_password" and value:
|
||||
from src.secret_storage import encrypt
|
||||
value = encrypt(value)
|
||||
settings[key] = value
|
||||
_save_settings(settings)
|
||||
# Force re-fetch
|
||||
_contact_cache["fetched_at"] = None
|
||||
@@ -802,7 +855,7 @@ def setup_contacts_routes():
|
||||
# match PUT /{uid} with uid="config".
|
||||
@router.put("/{uid}")
|
||||
async def edit_contact(uid: str, data: dict, _admin: str = Depends(require_admin)):
|
||||
"""Edit an existing contact — name / emails / phones."""
|
||||
"""Edit an existing contact — name / emails / phones / address."""
|
||||
name = (data.get("name") or "").strip()
|
||||
emails = data.get("emails")
|
||||
phones = data.get("phones")
|
||||
@@ -810,11 +863,12 @@ def setup_contacts_routes():
|
||||
emails = [data["email"]]
|
||||
emails = [e.strip() for e in (emails or []) if e and e.strip()]
|
||||
phones = [p.strip() for p in (phones or []) if p and p.strip()]
|
||||
if not name and not emails:
|
||||
return {"success": False, "error": "Name or email required"}
|
||||
address = (data.get("address") or "").strip()
|
||||
if not name and not emails and not address:
|
||||
return {"success": False, "error": "Name, email, or address required"}
|
||||
if not name and emails:
|
||||
name = emails[0].split("@")[0]
|
||||
ok = _update_contact(uid, name, emails, phones)
|
||||
ok = _update_contact(uid, name, emails, phones, address)
|
||||
return {"success": ok}
|
||||
|
||||
@router.delete("/{uid}")
|
||||
|
||||
@@ -362,7 +362,12 @@ def _user_shell_path_bootstrap() -> list[str]:
|
||||
' ODYSSEUS_USER_PATH="$("$ODYSSEUS_USER_SHELL" -ic \'printf "__ODYSSEUS_PATH__%s\\n" "$PATH"\' 2>/dev/null | sed -n \'s/^__ODYSSEUS_PATH__//p\' | tail -n 1 || true)"',
|
||||
' if [ -n "$ODYSSEUS_USER_PATH" ]; then export PATH="$ODYSSEUS_USER_PATH:$PATH"; fi',
|
||||
'fi',
|
||||
'command -v python3 >/dev/null 2>&1 || python3() { python "$@"; }',
|
||||
# Windows can expose python3 as a Microsoft Store App Execution Alias
|
||||
# under WindowsApps. Git Bash sees that stub as present, but it exits
|
||||
# before running Python. A Windows venv usually has python.exe, not
|
||||
# python3.exe, so treat a missing or WindowsApps python3 as absent.
|
||||
'_odys_py3="$(command -v python3 2>/dev/null || true)"',
|
||||
'case "$_odys_py3" in ""|*[Ww]indows[Aa]pps*) python3() { python "$@"; } ;; esac',
|
||||
'command -v python >/dev/null 2>&1 || python() { python3 "$@"; }',
|
||||
]
|
||||
|
||||
|
||||
+193
-6
@@ -676,7 +676,7 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
_spf = f"-p {_port} " if _port and _port != "22" else ""
|
||||
setup_cmd = (
|
||||
f"scp -O {_pf}-q '{runner_path}' {remote}:{remote_runner} && "
|
||||
f"ssh {_spf}{remote} 'chmod +x {remote_runner} && tmux new-session -d -s {session_id} \"./{remote_runner}\"'"
|
||||
f"ssh {_spf}{remote} 'chmod +x {remote_runner} && tmux set-option -g history-limit 100000 2>/dev/null; tmux new-session -d -s {session_id} \"./{remote_runner}\"'"
|
||||
)
|
||||
else:
|
||||
# Local: run hf download in the background (tmux on POSIX, a detached
|
||||
@@ -708,7 +708,7 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
lines.append('exec "${SHELL:-/bin/bash}"')
|
||||
wrapper_script.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
wrapper_script.chmod(0o755)
|
||||
setup_cmd = None if IS_WINDOWS else f"tmux new-session -d -s {session_id} {shlex.quote(str(wrapper_script))}"
|
||||
setup_cmd = None if IS_WINDOWS else f"tmux set-option -g history-limit 100000 2>/dev/null; tmux new-session -d -s {session_id} {shlex.quote(str(wrapper_script))}"
|
||||
|
||||
logger.info(f"Model download: {req.repo_id} (backend={'ollama' if is_ollama_download else 'hf'}, include={req.include}, session={session_id}, remote={remote})")
|
||||
logger.info(f"Download setup_cmd: {setup_cmd}")
|
||||
@@ -984,9 +984,9 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
ssh_args = ["ssh"]
|
||||
if ssh_port and ssh_port != "22":
|
||||
ssh_args.extend(["-p", str(ssh_port)])
|
||||
capture_cmd = ssh_args + [remote, "tmux", "capture-pane", "-t", session_id, "-p", "-S", "-200"]
|
||||
capture_cmd = ssh_args + [remote, "tmux", "capture-pane", "-t", session_id, "-p", "-S", "-2000"]
|
||||
else:
|
||||
capture_cmd = ["tmux", "capture-pane", "-t", session_id, "-p", "-S", "-200"]
|
||||
capture_cmd = ["tmux", "capture-pane", "-t", session_id, "-p", "-S", "-2000"]
|
||||
|
||||
_exit_re = re.compile(r"=== Process exited with code (-?\d+) ===")
|
||||
for wait_s in _waits:
|
||||
@@ -1577,10 +1577,10 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
setup_cmd = (
|
||||
f"{scp_extras}"
|
||||
f"scp -O {_Pf}-q '{runner_path}' {remote}:{remote_runner} && "
|
||||
f"ssh {_pf}{remote} 'chmod +x {remote_runner} && tmux new-session -d -s {session_id} \"./{remote_runner}\"'"
|
||||
f"ssh {_pf}{remote} 'chmod +x {remote_runner} && tmux set-option -g history-limit 100000 2>/dev/null; tmux new-session -d -s {session_id} \"./{remote_runner}\"'"
|
||||
)
|
||||
else:
|
||||
setup_cmd = f"tmux new-session -d -s {session_id} {shlex.quote(str(runner_path))}"
|
||||
setup_cmd = f"tmux set-option -g history-limit 100000 2>/dev/null; tmux new-session -d -s {session_id} {shlex.quote(str(runner_path))}"
|
||||
|
||||
if setup_cmd is None:
|
||||
# LOCAL Windows: launch the bash runner detached; no tmux setup_cmd.
|
||||
@@ -2625,6 +2625,193 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
"error": _ollama_library_cache["error"],
|
||||
}
|
||||
|
||||
# ── vLLM recipe scraper ─────────────────────────────────────────────
|
||||
# Fetches the official YAML recipe for a model from vllm-project/recipes
|
||||
# and normalizes it into a small JSON the frontend can consume. Cached
|
||||
# per-repo so the GitHub raw endpoint isn't hammered.
|
||||
_vllm_recipe_cache: dict[str, tuple[float, dict | None]] = {}
|
||||
# Manifest of all <org>/<model> ids that have a recipe in the upstream
|
||||
# repo. Cheap to fetch (one Git Tree API call), so we cache the whole
|
||||
# set for ~12h. Per-row "does this model have a recipe?" lookups hit
|
||||
# this set instead of doing 912 individual recipe fetches.
|
||||
_vllm_recipe_manifest: dict = {"fetched_at": 0.0, "models": set(), "error": ""}
|
||||
|
||||
@router.get("/api/cookbook/vllm-recipe-manifest")
|
||||
async def vllm_recipe_manifest(refresh: int = 0):
|
||||
"""Return the set of <org>/<model> ids known to have a vLLM recipe.
|
||||
One GitHub Tree API call, 12h cache. The frontend uses this to badge
|
||||
rows in the model list before the user expands them."""
|
||||
import time as _time
|
||||
import httpx as _httpx
|
||||
TTL = 12 * 3600.0
|
||||
now = _time.time()
|
||||
if (
|
||||
refresh
|
||||
or (now - _vllm_recipe_manifest["fetched_at"]) > TTL
|
||||
or not _vllm_recipe_manifest["models"]
|
||||
):
|
||||
url = (
|
||||
"https://api.github.com/repos/vllm-project/recipes/"
|
||||
"git/trees/main?recursive=1"
|
||||
)
|
||||
def _fetch_sync() -> tuple[int, dict | None, str]:
|
||||
try:
|
||||
headers = {"Accept": "application/vnd.github+json"}
|
||||
with _httpx.Client(timeout=10.0, follow_redirects=True) as client:
|
||||
r = client.get(url, headers=headers)
|
||||
if r.status_code != 200:
|
||||
return r.status_code, None, r.text[:200]
|
||||
return 200, r.json(), ""
|
||||
except Exception as e:
|
||||
return 0, None, f"fetch error: {e}"
|
||||
status, data, err = await asyncio.to_thread(_fetch_sync)
|
||||
if status == 200 and isinstance(data, dict):
|
||||
models: set[str] = set()
|
||||
for entry in data.get("tree") or []:
|
||||
path = (entry or {}).get("path") or ""
|
||||
if not path.startswith("models/") or not path.endswith(".yaml"):
|
||||
continue
|
||||
# path = "models/<org>/<model>.yaml" → "<org>/<model>"
|
||||
body = path[len("models/"):-len(".yaml")]
|
||||
if "/" in body:
|
||||
models.add(body)
|
||||
_vllm_recipe_manifest["models"] = models
|
||||
_vllm_recipe_manifest["fetched_at"] = now
|
||||
_vllm_recipe_manifest["error"] = ""
|
||||
else:
|
||||
_vllm_recipe_manifest["error"] = (
|
||||
f"HTTP {status}: {err}" if status else err
|
||||
)
|
||||
# Don't clobber a stale-but-usable list on transient failures.
|
||||
if not _vllm_recipe_manifest["models"]:
|
||||
return {
|
||||
"models": [],
|
||||
"count": 0,
|
||||
"error": _vllm_recipe_manifest["error"],
|
||||
}
|
||||
return {
|
||||
"models": sorted(_vllm_recipe_manifest["models"]),
|
||||
"count": len(_vllm_recipe_manifest["models"]),
|
||||
"fetched_at": _vllm_recipe_manifest["fetched_at"],
|
||||
"error": _vllm_recipe_manifest["error"],
|
||||
}
|
||||
|
||||
@router.get("/api/cookbook/vllm-recipe")
|
||||
async def vllm_recipe(repo: str, refresh: int = 0):
|
||||
"""Return the vLLM official recipe for a HuggingFace repo, if one
|
||||
exists at vllm-project/recipes. `repo` is the full HF id like
|
||||
'MiniMaxAI/MiniMax-M2'. Cached 6h."""
|
||||
import time as _time
|
||||
import httpx as _httpx
|
||||
import yaml as _yaml
|
||||
|
||||
TTL = 6 * 3600.0
|
||||
now = _time.time()
|
||||
repo = (repo or "").strip().strip("/")
|
||||
if "/" not in repo:
|
||||
return {"exists": False, "error": "repo must be <org>/<model>"}
|
||||
|
||||
cached = _vllm_recipe_cache.get(repo)
|
||||
if cached and not refresh and (now - cached[0]) < TTL:
|
||||
return cached[1] or {"exists": False, "cached": True}
|
||||
|
||||
url = (
|
||||
f"https://raw.githubusercontent.com/vllm-project/recipes/"
|
||||
f"main/models/{repo}.yaml"
|
||||
)
|
||||
|
||||
def _fetch_sync() -> tuple[int, str]:
|
||||
try:
|
||||
with _httpx.Client(timeout=8.0, follow_redirects=True) as client:
|
||||
r = client.get(url)
|
||||
return r.status_code, r.text
|
||||
except Exception as e:
|
||||
return 0, f"fetch error: {e}"
|
||||
|
||||
status, text = await asyncio.to_thread(_fetch_sync)
|
||||
if status == 404:
|
||||
_vllm_recipe_cache[repo] = (now, {"exists": False})
|
||||
return {"exists": False}
|
||||
if status != 200:
|
||||
return {"exists": False, "error": f"HTTP {status}", "transient": True}
|
||||
|
||||
try:
|
||||
doc = _yaml.safe_load(text) or {}
|
||||
except Exception as e:
|
||||
return {"exists": False, "error": f"yaml parse: {e}"}
|
||||
|
||||
meta = doc.get("meta") or {}
|
||||
model = doc.get("model") or {}
|
||||
features = doc.get("features") or {}
|
||||
deps = doc.get("dependencies") or []
|
||||
variants = doc.get("variants") or {}
|
||||
hw_overrides = doc.get("hardware_overrides") or {}
|
||||
strat_overrides = doc.get("strategy_overrides") or {}
|
||||
|
||||
# Tool-call + reasoning parsers, as flat arg arrays, so the frontend
|
||||
# can drop them straight into the launch command.
|
||||
tool_calling = features.get("tool_calling") or {}
|
||||
reasoning = features.get("reasoning") or {}
|
||||
|
||||
normalized = {
|
||||
"exists": True,
|
||||
"source_url": url,
|
||||
"title": meta.get("title") or "",
|
||||
"provider": meta.get("provider") or "",
|
||||
"description": meta.get("description") or "",
|
||||
"date_updated": str(meta.get("date_updated") or ""),
|
||||
"hardware_support": meta.get("hardware") or {},
|
||||
"model_id": model.get("model_id") or repo,
|
||||
"min_vllm_version": model.get("min_vllm_version") or "",
|
||||
"architecture": model.get("architecture") or "",
|
||||
"parameter_count": model.get("parameter_count") or "",
|
||||
"active_parameters": model.get("active_parameters") or "",
|
||||
"context_length": model.get("context_length") or 0,
|
||||
"base_args": list(model.get("base_args") or []),
|
||||
"base_env": dict(model.get("base_env") or {}),
|
||||
"tool_calling": {
|
||||
"description": tool_calling.get("description") or "",
|
||||
"args": list(tool_calling.get("args") or []),
|
||||
} if tool_calling else None,
|
||||
"reasoning": {
|
||||
"description": reasoning.get("description") or "",
|
||||
"args": list(reasoning.get("args") or []),
|
||||
} if reasoning else None,
|
||||
"dependencies": [
|
||||
{
|
||||
"note": (d.get("note") or "").strip(),
|
||||
"command": (d.get("command") or "").strip(),
|
||||
"optional": bool(d.get("optional", False)),
|
||||
}
|
||||
for d in deps if isinstance(d, dict)
|
||||
],
|
||||
"variants": {
|
||||
k: {
|
||||
"model_id": v.get("model_id") or model.get("model_id") or repo,
|
||||
"precision": v.get("precision") or "",
|
||||
"vram_minimum_gb": v.get("vram_minimum_gb") or 0,
|
||||
"description": v.get("description") or "",
|
||||
"extra_args": list(v.get("extra_args") or []),
|
||||
"extra_env": dict(v.get("extra_env") or {}),
|
||||
}
|
||||
for k, v in variants.items() if isinstance(v, dict)
|
||||
},
|
||||
"hardware_overrides": {
|
||||
hw: {
|
||||
"extra_args": list((ov or {}).get("extra_args") or []),
|
||||
"extra_env": dict((ov or {}).get("extra_env") or {}),
|
||||
}
|
||||
for hw, ov in hw_overrides.items() if isinstance(ov, dict)
|
||||
},
|
||||
"strategy_overrides": {
|
||||
strat: dict(ov or {})
|
||||
for strat, ov in strat_overrides.items() if isinstance(ov, dict)
|
||||
},
|
||||
"compatible_strategies": list(doc.get("compatible_strategies") or []),
|
||||
}
|
||||
_vllm_recipe_cache[repo] = (now, normalized)
|
||||
return normalized
|
||||
|
||||
@router.get("/api/cookbook/tasks/status")
|
||||
async def cookbook_tasks_status(request: Request):
|
||||
"""Check status of all active cookbook tmux sessions.
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"""Diagnostics routes — /api/db/stats, /api/rag/stats, /api/test/youtube, /api/test-research."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Dict, Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Form, Request
|
||||
|
||||
from services.youtube.youtube_handler import extract_youtube_id, extract_transcript_async
|
||||
from core.constants import DEFAULT_HOST
|
||||
from core.constants import DEFAULT_HOST, DATA_DIR
|
||||
from core.middleware import require_admin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -28,6 +29,30 @@ def setup_diagnostics_routes(
|
||||
from src.service_health import collect_service_health
|
||||
return await collect_service_health(rag_manager, memory_vector)
|
||||
|
||||
@router.get("/api/diagnostics/logs")
|
||||
async def get_diagnostics_logs(request: Request, limit: int = 200) -> Dict[str, Any]:
|
||||
require_admin(request)
|
||||
limit = max(1, min(limit, 1000))
|
||||
try:
|
||||
log_file = os.path.join(DATA_DIR, "logs", "app.log")
|
||||
if not os.path.exists(log_file):
|
||||
return {"status": "success", "logs": []}
|
||||
|
||||
# Safe tail read of the log file (max 5MB via rotation)
|
||||
with open(log_file, "r", encoding="utf-8", errors="ignore") as f:
|
||||
lines = f.readlines()
|
||||
|
||||
tail_lines = lines[-limit:] if len(lines) > limit else lines
|
||||
tail_lines = [line.rstrip('\r\n') for line in tail_lines]
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"logs": tail_lines
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Diagnostics logs retrieval error: {e}")
|
||||
raise HTTPException(500, f"Failed to retrieve logs: {str(e)}")
|
||||
|
||||
@router.get("/api/db/stats")
|
||||
async def get_database_stats(request: Request) -> Dict[str, Any]:
|
||||
require_admin(request)
|
||||
|
||||
+135
-5
@@ -1097,7 +1097,12 @@ def setup_email_routes():
|
||||
account_id: str | None = Query(None),
|
||||
owner: str = Depends(require_owner),
|
||||
):
|
||||
"""Search emails server-side via IMAP SEARCH. Matches subject, from, or body text."""
|
||||
"""Search emails server-side via IMAP SEARCH. Matches subject, from, or body text.
|
||||
|
||||
When the caller asks for INBOX and the account has an "All Mail"
|
||||
folder (Gmail does), we transparently swap to All Mail so the
|
||||
search surfaces archived / labelled emails too. Plain IMAP
|
||||
accounts fall back to whatever folder the caller specified."""
|
||||
if not q or len(q) < 2:
|
||||
return {"emails": [], "total": 0, "query": q}
|
||||
# CRLF in q would terminate the IMAP command early — reject defensively.
|
||||
@@ -1105,7 +1110,27 @@ def setup_email_routes():
|
||||
raise HTTPException(400, "Invalid query")
|
||||
try:
|
||||
with _imap(account_id, owner=owner) as conn:
|
||||
conn.select(_q(folder), readonly=True)
|
||||
# If the user asked for INBOX, try to upgrade to All Mail —
|
||||
# one folder == every email on Gmail-class servers.
|
||||
effective_folder = folder
|
||||
if (folder or "").upper() == "INBOX":
|
||||
try:
|
||||
status, folder_lines = conn.list()
|
||||
if status == "OK" and folder_lines:
|
||||
for raw in folder_lines:
|
||||
if isinstance(raw, bytes):
|
||||
raw = raw.decode("utf-8", errors="replace")
|
||||
m = re.match(r"\((?P<flags>[^)]*)\)\s+\"[^\"]*\"\s+(?P<name>.+)", raw)
|
||||
if not m:
|
||||
continue
|
||||
flags = (m.group("flags") or "").lower()
|
||||
name = m.group("name").strip().strip('"')
|
||||
if "\\all" in flags or "all mail" in name.lower():
|
||||
effective_folder = name
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
conn.select(_q(effective_folder), readonly=True)
|
||||
|
||||
# Escape backslash and quote for the IMAP-SEARCH quoted-string.
|
||||
q_escaped = q.replace('\\', '\\\\').replace('"', '\\"')
|
||||
@@ -1113,7 +1138,7 @@ def setup_email_routes():
|
||||
|
||||
status, data = _imap_uid_search(conn, search_cmd)
|
||||
if status != "OK" or not data[0]:
|
||||
return {"emails": [], "total": 0, "query": q}
|
||||
return {"emails": [], "total": 0, "query": q, "folder": effective_folder}
|
||||
|
||||
uid_list = data[0].split()
|
||||
total = len(uid_list)
|
||||
@@ -1178,6 +1203,13 @@ def setup_email_routes():
|
||||
"is_flagged": "\\Flagged" in flags,
|
||||
"flags": flags,
|
||||
"has_attachments": has_attachments,
|
||||
# Stamp the folder so the frontend opens each
|
||||
# email from the folder it actually lives in
|
||||
# (the search may have run against All Mail
|
||||
# even though the caller asked for INBOX),
|
||||
# otherwise clicks open whatever happens to
|
||||
# have the same UID in INBOX → wrong email.
|
||||
"folder": effective_folder,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"Error parsing search result {uid}: {e}")
|
||||
@@ -1724,6 +1756,22 @@ def setup_email_routes():
|
||||
logger.error(f"Failed to mark unread {uid}: {e}")
|
||||
return {"success": False, "error": "Mail operation failed"}
|
||||
|
||||
@router.post("/flag/{uid}")
|
||||
async def flag_email(uid: str, folder: str = Query("INBOX"), account_id: str | None = Query(None),
|
||||
on: bool = Query(True), owner: str = Depends(require_owner)):
|
||||
"""Toggle the \\Flagged flag (a.k.a. favorite / star) on an email.
|
||||
Pass `on=true` to favorite, `on=false` to unfavorite."""
|
||||
try:
|
||||
with _imap(account_id, owner=owner) as conn:
|
||||
conn.select(_q(folder))
|
||||
if not _store_email_flag(conn, uid, "\\Flagged", add=bool(on)):
|
||||
return {"success": False, "error": "Email not found"}
|
||||
_invalidate_list_cache(account_id, folder)
|
||||
return {"success": True, "flagged": bool(on)}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to flag {uid}: {e}")
|
||||
return {"success": False, "error": "Mail operation failed"}
|
||||
|
||||
@router.post("/mark-read/{uid}")
|
||||
async def mark_read(uid: str, folder: str = Query("INBOX"), account_id: str | None = Query(None), owner: str = Depends(require_owner)):
|
||||
"""Mark an email as read (set \\Seen flag)."""
|
||||
@@ -2104,6 +2152,79 @@ def setup_email_routes():
|
||||
logger.error(f"cancel_scheduled {sid!r} failed: {e}")
|
||||
return {"success": False, "error": "Mail operation failed"}
|
||||
|
||||
# ── Agent send-confirm: list/approve/cancel ──────────────────────────
|
||||
# When `agent_email_confirm` is on, the MCP send_email tool drops the
|
||||
# composed email into scheduled_emails with status='agent_draft' (a
|
||||
# far-future send_at so the poller never picks it up). These endpoints
|
||||
# let the chat UI surface them for the user and either approve (flip
|
||||
# to status='pending' with send_at=now so the poller delivers it) or
|
||||
# cancel (status='cancelled').
|
||||
@router.get("/pending")
|
||||
async def list_pending_agent_drafts(owner: str = Depends(require_owner)):
|
||||
import sqlite3
|
||||
try:
|
||||
conn = sqlite3.connect(SCHEDULED_DB)
|
||||
conn.row_factory = sqlite3.Row
|
||||
# The MCP server can't easily set owner, so it stores '' — fall
|
||||
# back to those rows in addition to the caller's owner.
|
||||
rows = conn.execute(
|
||||
"""SELECT id, to_addr, subject, body, created_at, account_id
|
||||
FROM scheduled_emails
|
||||
WHERE status = 'agent_draft' AND (owner = ? OR owner = '')
|
||||
ORDER BY created_at DESC""",
|
||||
(owner or "",),
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return {"pending": [dict(r) for r in rows]}
|
||||
except Exception as e:
|
||||
logger.error(f"list_pending_agent_drafts failed: {e}")
|
||||
return {"pending": [], "error": "Mail operation failed"}
|
||||
|
||||
@router.post("/pending/{sid}/approve")
|
||||
async def approve_agent_draft(sid: str, owner: str = Depends(require_owner)):
|
||||
"""Approve a draft staged by the agent: flip status → pending and
|
||||
backdate send_at so the scheduled-send poller picks it up
|
||||
immediately."""
|
||||
import sqlite3
|
||||
try:
|
||||
conn = sqlite3.connect(SCHEDULED_DB)
|
||||
cur = conn.execute(
|
||||
"""UPDATE scheduled_emails
|
||||
SET status = 'pending', send_at = ?
|
||||
WHERE id = ? AND status = 'agent_draft' AND (owner = ? OR owner = '')""",
|
||||
(datetime.utcnow().isoformat(), sid, owner or ""),
|
||||
)
|
||||
conn.commit()
|
||||
affected = cur.rowcount
|
||||
conn.close()
|
||||
if not affected:
|
||||
return {"success": False, "error": "Draft not found or already handled"}
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
logger.error(f"approve_agent_draft {sid!r} failed: {e}")
|
||||
return {"success": False, "error": "Mail operation failed"}
|
||||
|
||||
@router.delete("/pending/{sid}")
|
||||
async def cancel_agent_draft(sid: str, owner: str = Depends(require_owner)):
|
||||
"""Discard a draft the agent staged for approval."""
|
||||
import sqlite3
|
||||
try:
|
||||
conn = sqlite3.connect(SCHEDULED_DB)
|
||||
cur = conn.execute(
|
||||
"""UPDATE scheduled_emails SET status = 'cancelled'
|
||||
WHERE id = ? AND status = 'agent_draft' AND (owner = ? OR owner = '')""",
|
||||
(sid, owner or ""),
|
||||
)
|
||||
conn.commit()
|
||||
affected = cur.rowcount
|
||||
conn.close()
|
||||
if not affected:
|
||||
return {"success": False, "error": "Draft not found or already handled"}
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
logger.error(f"cancel_agent_draft {sid!r} failed: {e}")
|
||||
return {"success": False, "error": "Mail operation failed"}
|
||||
|
||||
@router.get("/resolve-contact")
|
||||
async def resolve_contact(name: str = Query(..., description="Name to search for"), owner: str = Depends(require_owner)):
|
||||
"""Search Sent folder for a contact by name. Returns matching email addresses."""
|
||||
@@ -2617,11 +2738,15 @@ def setup_email_routes():
|
||||
source_uid = (data.get("uid") or "").strip()
|
||||
source_folder = (data.get("folder") or "INBOX").strip()
|
||||
fast_reply = bool(data.get("fast", False))
|
||||
user_hint = (data.get("user_hint") or "").strip()
|
||||
|
||||
if not original_body:
|
||||
return {"success": False, "error": "No email body provided"}
|
||||
|
||||
if message_id:
|
||||
# Skip cache lookup when the caller supplied a user_hint — the
|
||||
# cached generic reply doesn't reflect the instructions and
|
||||
# would silently override them.
|
||||
if message_id and not user_hint:
|
||||
try:
|
||||
_c = _sql3.connect(SCHEDULED_DB)
|
||||
owner_clause, owner_params = _email_cache_owner_clause(owner)
|
||||
@@ -2761,8 +2886,13 @@ def setup_email_routes():
|
||||
user_msg = (
|
||||
f"Recipient: {to}\nSubject: {subject}\n\n"
|
||||
f"Original email and any current draft:\n{original_body[:6000]}\n\n"
|
||||
f"Draft a reply. Return only the reply body text."
|
||||
)
|
||||
if user_hint:
|
||||
user_msg += (
|
||||
f"User's instructions for THIS reply (follow these — they override "
|
||||
f"defaults like length/tone):\n{user_hint[:2000]}\n\n"
|
||||
)
|
||||
user_msg += "Draft a reply. Return only the reply body text."
|
||||
|
||||
# Build a candidate chain so a stale session-stored API key
|
||||
# (the most common cause of "authentication failed" here)
|
||||
|
||||
+24
-11
@@ -67,6 +67,14 @@ def _gallery_image_path(filename: str) -> Path:
|
||||
raise HTTPException(400, "Unsafe gallery filename")
|
||||
if safe_name != original:
|
||||
raise HTTPException(400, "Unsafe gallery filename")
|
||||
if not path.exists():
|
||||
cwd_root = (Path.cwd() / "data" / "generated_images").resolve()
|
||||
cwd_path = (cwd_root / safe_name).resolve()
|
||||
try:
|
||||
if os.path.commonpath([str(cwd_root), str(cwd_path)]) == str(cwd_root) and cwd_path.exists():
|
||||
return cwd_path
|
||||
except Exception:
|
||||
pass
|
||||
return path
|
||||
|
||||
|
||||
@@ -224,8 +232,6 @@ def setup_gallery_routes() -> APIRouter:
|
||||
@router.post("/api/gallery/{image_id}/replace")
|
||||
async def gallery_replace(request: Request, image_id: str):
|
||||
"""Replace an existing gallery image file with a new one."""
|
||||
from pathlib import Path
|
||||
|
||||
user = get_current_user(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
@@ -241,9 +247,8 @@ def setup_gallery_routes() -> APIRouter:
|
||||
raise HTTPException(400, "No image provided")
|
||||
|
||||
content = await read_upload_limited(file, GALLERY_UPLOAD_MAX_BYTES, "Gallery replacement")
|
||||
img_dir = Path(GENERATED_IMAGES_DIR)
|
||||
img_dir.mkdir(parents=True, exist_ok=True)
|
||||
img_path = img_dir / _sanitize_gallery_filename(img.filename)
|
||||
GALLERY_IMAGE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
img_path = _gallery_image_path(img.filename)
|
||||
img_path.write_bytes(content)
|
||||
|
||||
# Refresh dimensions in case the editor resized the canvas.
|
||||
@@ -931,15 +936,23 @@ def setup_gallery_routes() -> APIRouter:
|
||||
raise HTTPException(404, "Image not found")
|
||||
|
||||
img_filename = img.filename
|
||||
# Remove the file from disk
|
||||
img_path = _gallery_image_path(img_filename)
|
||||
if img_path.exists():
|
||||
img_path.unlink()
|
||||
|
||||
# Soft-delete the record
|
||||
# Soft-delete the record first; the DB is the source of truth.
|
||||
img.is_active = False
|
||||
db.commit()
|
||||
|
||||
# Only after the soft-delete commit succeeds do we remove the file.
|
||||
# If the file were deleted first and the commit then failed/rolled
|
||||
# back, the still-active record would point at a missing file.
|
||||
# Best-effort so a missing or locked file can't 500 a delete that
|
||||
# already succeeded logically. Uses the path-confined resolver so a
|
||||
# malformed stored filename can't escape generated_images.
|
||||
try:
|
||||
img_path = _gallery_image_path(img_filename)
|
||||
if img_path.exists():
|
||||
img_path.unlink()
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not remove gallery image file for {img_filename}: {e}")
|
||||
|
||||
# Strip stale chat-history references so the image bubble
|
||||
# (and its prompt caption) doesn't come back after a server
|
||||
# reboot replays the session. We remove the matching tool
|
||||
|
||||
@@ -119,7 +119,7 @@ def setup_hwfit_routes():
|
||||
return detect_system(host=host, ssh_port=ssh_port, platform=platform, fresh=fresh)
|
||||
|
||||
@router.get("/models")
|
||||
def get_models(use_case: str = "", sort: str = "score", limit: int = 50, search: str = "", host: str = "", quant: str = "", ctx: str = "", gpu_count: str = "", gpu_group: str = "", ssh_port: str = "", platform: str = "", fresh: bool = False, manual_mode: str = "", manual_gpu_count: str = "", manual_vram_gb: str = "", manual_ram_gb: str = "", manual_backend: str = "", ignore_detected_gpu: bool = False, ignore_detected_ram: bool = False, fit_only: bool = False):
|
||||
def get_models(use_case: str = "", sort: str = "newest", limit: int = 50, search: str = "", host: str = "", quant: str = "", ctx: str = "", gpu_count: str = "", gpu_group: str = "", ssh_port: str = "", platform: str = "", fresh: bool = False, manual_mode: str = "", manual_gpu_count: str = "", manual_vram_gb: str = "", manual_ram_gb: str = "", manual_backend: str = "", ignore_detected_gpu: bool = False, ignore_detected_ram: bool = False, fit_only: bool = False):
|
||||
"""Rank LLM models against detected hardware and return scored results.
|
||||
gpu_count: override GPU count (0 = CPU only, 1-N = simulate N GPUs of the
|
||||
active group). gpu_group: index into system.gpu_groups (the homogeneous
|
||||
|
||||
@@ -377,7 +377,8 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM
|
||||
sess.endpoint_url, sess.model, sess.headers, owner=_owner(request)
|
||||
)
|
||||
except KeyError:
|
||||
raise HTTPException(404, "Session not found — needed for LLM config")
|
||||
logger.warning("Session %s not found, falling back to utility endpoint", session)
|
||||
endpoint_url, model, headers = resolve_endpoint("utility", owner=_owner(request))
|
||||
else:
|
||||
endpoint_url, model, headers = resolve_task_endpoint(owner=_owner(request))
|
||||
|
||||
|
||||
+23
-9
@@ -26,7 +26,7 @@ from src.endpoint_resolver import (
|
||||
build_models_url,
|
||||
build_headers,
|
||||
)
|
||||
from src.auth_helpers import _auth_disabled, owner_filter
|
||||
from src.auth_helpers import _auth_disabled, effective_user, owner_filter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -248,6 +248,9 @@ _PROVIDER_CURATED = {
|
||||
"zai-coding": [
|
||||
"glm-5.1", "glm-5v-turbo", "glm-5-turbo", "glm-4.7", "glm-4.5-air",
|
||||
],
|
||||
"kimi-code": [
|
||||
"kimi-for-coding",
|
||||
],
|
||||
"deepseek": [
|
||||
"deepseek-chat", "deepseek-reasoner",
|
||||
],
|
||||
@@ -315,6 +318,8 @@ def _match_provider_curated(base_url: str, provider: str) -> str:
|
||||
parsed = urlparse(base_url)
|
||||
if _host_match(base_url, "z.ai") and "/api/coding" in (parsed.path or ""):
|
||||
return "zai-coding"
|
||||
if _host_match(base_url, "kimi.com") and "/coding" in (parsed.path or ""):
|
||||
return "kimi-code"
|
||||
for domain, key in _HOST_TO_CURATED:
|
||||
if _host_match(base_url, domain):
|
||||
return key
|
||||
@@ -703,6 +708,7 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis
|
||||
"""Probe a base URL's /models endpoint and return list of model IDs.
|
||||
For Anthropic, queries their /v1/models API, falling back to hardcoded list."""
|
||||
from src.endpoint_resolver import resolve_url
|
||||
from src.llm_core import httpx_get_kimi_aware
|
||||
base = resolve_url(_normalize_base(base_url))
|
||||
provider = _safe_detect_provider(base)
|
||||
if provider == "chatgpt-subscription":
|
||||
@@ -738,7 +744,7 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis
|
||||
url = _safe_build_models_url(base)
|
||||
headers = _safe_build_headers(api_key, base)
|
||||
try:
|
||||
r = httpx.get(url, headers=headers, timeout=timeout, verify=llm_verify())
|
||||
r = httpx_get_kimi_aware(url, headers, timeout=timeout, verify=llm_verify())
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
# OpenAI format: {"data": [{"id": "model-name"}]}
|
||||
@@ -754,6 +760,11 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis
|
||||
for _e in _PROVIDER_CURATED.get(_ck, []):
|
||||
if _e not in set(models) and not any(m.startswith(_e) for m in models):
|
||||
models.append(_e)
|
||||
if _host_match(base, "kimi.com") and "/coding" in (urlparse(base).path or ""):
|
||||
_ck = _match_provider_curated(base, None)
|
||||
for _e in _PROVIDER_CURATED.get(_ck, []):
|
||||
if _e not in set(models) and not any(m.startswith(_e) for m in models):
|
||||
models.append(_e)
|
||||
return [m for m in models if _is_chat_model(m)]
|
||||
except httpx.HTTPStatusError as e:
|
||||
if api_key:
|
||||
@@ -1244,13 +1255,16 @@ def setup_model_routes(model_discovery):
|
||||
# Require auth; "" is the unconfigured single-user mode, treated as
|
||||
# "see everything" by _fetch_models.
|
||||
try:
|
||||
from src.auth_helpers import get_current_user as _gcu
|
||||
owner = _gcu(request) or ""
|
||||
except Exception:
|
||||
owner = ""
|
||||
# Reject anonymous in configured deployments — no leaking the model
|
||||
# list to unauthenticated callers.
|
||||
try:
|
||||
if getattr(request.state, "api_token", False):
|
||||
scopes = set(getattr(request.state, "api_token_scopes", []) or [])
|
||||
if "chat" not in scopes:
|
||||
raise HTTPException(403, "API token is not scoped for chat")
|
||||
if not getattr(request.state, "api_token_owner", None):
|
||||
raise HTTPException(403, "API token has no owner")
|
||||
owner = effective_user(request) or ""
|
||||
|
||||
# Reject anonymous in configured deployments — no leaking the model
|
||||
# list to unauthenticated callers.
|
||||
auth_mgr = getattr(request.app.state, "auth_manager", None)
|
||||
if not owner and not _auth_disabled() and auth_mgr is not None and getattr(auth_mgr, "is_configured", False):
|
||||
raise HTTPException(401, "Not authenticated")
|
||||
|
||||
+10
-1
@@ -208,14 +208,17 @@ async def dispatch_reminder(
|
||||
try:
|
||||
from src.endpoint_resolver import resolve_endpoint
|
||||
from src.llm_core import llm_call_async
|
||||
from src.reminder_personas import synthesis_system_prompt
|
||||
url, model, headers = resolve_endpoint("utility", owner=owner or None)
|
||||
if not url:
|
||||
url, model, headers = resolve_endpoint("default", owner=owner or None)
|
||||
if url and model:
|
||||
persona_id = (settings.get("reminder_llm_persona") or "").strip()
|
||||
sys_prompt = synthesis_system_prompt(persona_id)
|
||||
raw = await llm_call_async(
|
||||
url=url, model=model,
|
||||
messages=[
|
||||
{"role": "system", "content": "You are a reminder assistant. Write a single short, warm, motivating sentence (max 25 words) reminding the user about the note below. Do not add greetings, preamble, or hashtags. Output only the sentence."},
|
||||
{"role": "system", "content": sys_prompt},
|
||||
{"role": "user", "content": f"Title: {title}\n\n{note_body}".strip()},
|
||||
],
|
||||
temperature=0.7, max_tokens=200, headers=headers, timeout=30,
|
||||
@@ -826,6 +829,12 @@ def setup_note_routes(task_scheduler=None):
|
||||
_override["reminder_webhook_integration_id"] = body["webhook_integration_id"]
|
||||
if body.get("webhook_payload_template"):
|
||||
_override["reminder_webhook_payload_template"] = body["webhook_payload_template"]
|
||||
# Mirror the in-UI AI Synthesis toggle + persona so the test
|
||||
# actually exercises the synthesis path before/without a Save.
|
||||
if "llm_synthesis" in body:
|
||||
_override["reminder_llm_synthesis"] = bool(body["llm_synthesis"])
|
||||
if "llm_persona" in body:
|
||||
_override["reminder_llm_persona"] = str(body["llm_persona"] or "")
|
||||
else:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
|
||||
@@ -278,8 +278,8 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
|
||||
# Delete file from disk if it's in uploads dir
|
||||
deleted_from_disk = False
|
||||
try:
|
||||
abs_target = os.path.abspath(filepath)
|
||||
base_abs = os.path.abspath(UPLOADS_DIR)
|
||||
abs_target = os.path.realpath(filepath)
|
||||
base_abs = os.path.realpath(UPLOADS_DIR)
|
||||
in_uploads = (
|
||||
abs_target == base_abs
|
||||
or os.path.commonpath([abs_target, base_abs]) == base_abs
|
||||
|
||||
@@ -691,8 +691,12 @@ async def _run_skill_test_once(md: str, task: str, url, model, headers, owner) -
|
||||
{"role": "user", "content": task},
|
||||
]
|
||||
try:
|
||||
# max_tokens explicitly set: passing 0 lets some upstreams (Ollama,
|
||||
# OpenAI-compat) generate an empty completion, which manifested as
|
||||
# the skill test returning nothing while chat (which carries its
|
||||
# preset's max_tokens) worked. 4096 matches the chat default.
|
||||
async for chunk in stream_agent_loop(url, model, messages, headers=headers,
|
||||
temperature=0.3, max_tokens=0, max_rounds=8, owner=owner):
|
||||
temperature=0.3, max_tokens=4096, max_rounds=8, owner=owner):
|
||||
if not chunk.startswith("data: ") or chunk.strip() == "data: [DONE]":
|
||||
continue
|
||||
try:
|
||||
|
||||
@@ -151,6 +151,7 @@ class TaskCreate(BaseModel):
|
||||
endpoint_url: Optional[str] = None
|
||||
then_task_id: Optional[str] = None # chain: run this task after success
|
||||
notifications_enabled: Optional[bool] = None # None lets action-specific defaults apply
|
||||
character_id: Optional[str] = None # built-in persona id (PERSONAS) — biases output voice
|
||||
|
||||
|
||||
class TaskUpdate(BaseModel):
|
||||
@@ -171,6 +172,7 @@ class TaskUpdate(BaseModel):
|
||||
endpoint_url: Optional[str] = None
|
||||
then_task_id: Optional[str] = None
|
||||
notifications_enabled: Optional[bool] = None
|
||||
character_id: Optional[str] = None
|
||||
|
||||
|
||||
def _display_task_name(t: ScheduledTask) -> str:
|
||||
@@ -203,6 +205,7 @@ def _task_to_dict(t: ScheduledTask, include_last_run_result: bool = False) -> di
|
||||
"output_target": t.output_target,
|
||||
"session_id": t.session_id,
|
||||
"crew_member_id": getattr(t, "crew_member_id", None),
|
||||
"character_id": getattr(t, "character_id", None),
|
||||
"model": t.model,
|
||||
"endpoint_url": t.endpoint_url,
|
||||
"run_count": t.run_count or 0,
|
||||
@@ -552,6 +555,7 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
||||
then_task_id=then_task_id,
|
||||
webhook_token=webhook_token,
|
||||
notifications_enabled=notifications_enabled,
|
||||
character_id=(req.character_id or None),
|
||||
)
|
||||
db.add(task)
|
||||
db.commit()
|
||||
@@ -705,6 +709,9 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
||||
task.then_task_id = _validate_then_task_id(db, req.then_task_id, user, current_task_id=task.id)
|
||||
if req.notifications_enabled is not None:
|
||||
task.notifications_enabled = bool(req.notifications_enabled)
|
||||
if req.character_id is not None:
|
||||
# Empty string clears the persona; non-empty stores the id.
|
||||
task.character_id = req.character_id or None
|
||||
if req.cron_expression is not None:
|
||||
if req.cron_expression:
|
||||
try:
|
||||
|
||||
@@ -198,6 +198,8 @@ def setup_webhook_routes(
|
||||
"opencode-go": "https://opencode.ai/zen/go/v1",
|
||||
"fireworks": "https://api.fireworks.ai/inference/v1",
|
||||
"venice": "https://api.venice.ai/api/v1",
|
||||
"kimi-code": "https://api.kimi.com/coding/v1",
|
||||
"kimicode": "https://api.kimi.com/coding/v1",
|
||||
}
|
||||
|
||||
# Model prefix → provider mapping for auto-detection
|
||||
@@ -210,6 +212,8 @@ def setup_webhook_routes(
|
||||
"mistral": "mistral",
|
||||
"llama": "groq",
|
||||
"mixtral": "groq",
|
||||
"kimi-for-coding": "kimi-code",
|
||||
"kimi": "kimi-code",
|
||||
}
|
||||
|
||||
def _resolve_base_url(model: Optional[str], provider: Optional[str]) -> Optional[str]:
|
||||
|
||||
Executable
+635
@@ -0,0 +1,635 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build a neutral agent migration manifest.
|
||||
|
||||
This helper is intentionally read-only. It does not import the Odysseus
|
||||
application package, write to data/, call an LLM, or apply anything. It turns
|
||||
common agent export shapes into a portable JSON manifest that Odysseus can
|
||||
preview or import later.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import mimetypes
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
|
||||
SCHEMA_VERSION = "agent-migration.v1"
|
||||
TEXT_EXTENSIONS = {
|
||||
".cfg",
|
||||
".conf",
|
||||
".csv",
|
||||
".json",
|
||||
".log",
|
||||
".md",
|
||||
".markdown",
|
||||
".py",
|
||||
".rst",
|
||||
".toml",
|
||||
".txt",
|
||||
".yaml",
|
||||
".yml",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InputWarning:
|
||||
path: str
|
||||
message: str
|
||||
|
||||
|
||||
def utc_now_iso() -> str:
|
||||
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def sha256_text(text: str) -> str:
|
||||
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def sha256_bytes(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def sha256_path(path: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
with path.open("rb") as f:
|
||||
for chunk in iter(lambda: f.read(65536), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def stable_id(kind: str, source_name: str, *parts: Any) -> str:
|
||||
raw = "\x1f".join([kind, source_name, *[str(part) for part in parts]])
|
||||
return f"{kind}:{hashlib.sha256(raw.encode('utf-8')).hexdigest()[:16]}"
|
||||
|
||||
|
||||
def read_json(path: Path) -> Any:
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
return json.load(handle)
|
||||
|
||||
|
||||
def normalize_category(value: Any) -> str:
|
||||
category = str(value or "fact").strip().lower()
|
||||
return category or "fact"
|
||||
|
||||
|
||||
def normalize_memory_text(item: Any) -> str:
|
||||
if isinstance(item, str):
|
||||
return item.strip()
|
||||
if isinstance(item, dict):
|
||||
for key in ("text", "content", "memory", "value"):
|
||||
value = item.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
return ""
|
||||
|
||||
|
||||
def memory_metadata(item: Any, source_path: Path, index: int) -> dict[str, Any]:
|
||||
metadata: dict[str, Any] = {
|
||||
"source_path": str(source_path),
|
||||
"source_index": index,
|
||||
}
|
||||
if isinstance(item, dict):
|
||||
for key in ("id", "timestamp", "created_at", "updated_at", "source", "tags", "pinned"):
|
||||
if key in item:
|
||||
metadata[f"source_{key}"] = item.get(key)
|
||||
return metadata
|
||||
|
||||
|
||||
def payload_items(payload: Any, keys: tuple[str, ...]) -> Any:
|
||||
if isinstance(payload, dict):
|
||||
for key in keys:
|
||||
if isinstance(payload.get(key), list):
|
||||
return payload[key]
|
||||
return payload
|
||||
|
||||
|
||||
def collect_memory_json(path: Path, source_name: str) -> tuple[list[dict[str, Any]], list[InputWarning]]:
|
||||
warnings: list[InputWarning] = []
|
||||
try:
|
||||
payload = read_json(path)
|
||||
except Exception as exc:
|
||||
return [], [InputWarning(str(path), f"could not read JSON: {exc}")]
|
||||
|
||||
payload = payload_items(payload, ("memories", "memory", "items", "data"))
|
||||
|
||||
if not isinstance(payload, list):
|
||||
return [], [InputWarning(str(path), "expected a JSON list or an object containing a memory list")]
|
||||
|
||||
items: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
for index, item in enumerate(payload):
|
||||
text = normalize_memory_text(item)
|
||||
if not text:
|
||||
warnings.append(InputWarning(str(path), f"skipped memory at index {index}: missing text"))
|
||||
continue
|
||||
digest = sha256_text(text.strip().lower())
|
||||
if digest in seen:
|
||||
warnings.append(InputWarning(str(path), f"skipped duplicate memory at index {index}"))
|
||||
continue
|
||||
seen.add(digest)
|
||||
category = normalize_category(item.get("category") if isinstance(item, dict) else "fact")
|
||||
source = str(item.get("source") or source_name) if isinstance(item, dict) else source_name
|
||||
items.append(
|
||||
{
|
||||
"id": stable_id("memory", source_name, path, index, digest),
|
||||
"kind": "memory",
|
||||
"text": text,
|
||||
"category": category,
|
||||
"source": source,
|
||||
"metadata": memory_metadata(item, path, index),
|
||||
}
|
||||
)
|
||||
return items, warnings
|
||||
|
||||
|
||||
def normalize_timestamp(value: Any) -> str | None:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
if isinstance(value, (int, float)):
|
||||
try:
|
||||
return (
|
||||
datetime.fromtimestamp(float(value), timezone.utc)
|
||||
.replace(microsecond=0)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
)
|
||||
except (OverflowError, OSError, ValueError):
|
||||
return str(value)
|
||||
return str(value)
|
||||
|
||||
|
||||
def normalize_role(value: Any) -> str:
|
||||
role = str(value or "unknown").strip().lower()
|
||||
if role in {"human", "user"}:
|
||||
return "user"
|
||||
if role in {"assistant", "ai", "bot", "model"}:
|
||||
return "assistant"
|
||||
if role in {"system", "tool"}:
|
||||
return role
|
||||
return role or "unknown"
|
||||
|
||||
|
||||
def content_part_text(part: Any) -> str:
|
||||
if isinstance(part, str):
|
||||
return part
|
||||
if isinstance(part, dict):
|
||||
for key in ("text", "content", "value"):
|
||||
value = part.get(key)
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
if part.get("type") == "text" and isinstance(part.get("text"), str):
|
||||
return part["text"]
|
||||
return ""
|
||||
|
||||
|
||||
def normalize_message_text(message: dict[str, Any]) -> str:
|
||||
content = message.get("content")
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
return "\n".join(text for text in (content_part_text(part).strip() for part in content) if text)
|
||||
if isinstance(content, dict):
|
||||
parts = content.get("parts")
|
||||
if isinstance(parts, list):
|
||||
return "\n".join(text for text in (content_part_text(part).strip() for part in parts) if text)
|
||||
for key in ("text", "content", "value"):
|
||||
value = content.get(key)
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
for key in ("text", "body", "message"):
|
||||
value = message.get(key)
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
def normalize_message(message: dict[str, Any]) -> dict[str, Any] | None:
|
||||
author = message.get("author") if isinstance(message.get("author"), dict) else {}
|
||||
role = (
|
||||
message.get("role")
|
||||
or message.get("sender")
|
||||
or message.get("speaker")
|
||||
or author.get("role")
|
||||
or author.get("name")
|
||||
)
|
||||
text = normalize_message_text(message).strip()
|
||||
if not text:
|
||||
return None
|
||||
normalized: dict[str, Any] = {
|
||||
"role": normalize_role(role),
|
||||
"text": text,
|
||||
}
|
||||
timestamp = normalize_timestamp(message.get("created_at") or message.get("create_time") or message.get("timestamp"))
|
||||
if timestamp:
|
||||
normalized["created_at"] = timestamp
|
||||
message_id = message.get("id")
|
||||
if message_id is not None:
|
||||
normalized["source_id"] = str(message_id)
|
||||
return normalized
|
||||
|
||||
|
||||
def chatgpt_mapping_messages(conversation: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
mapping = conversation.get("mapping")
|
||||
if not isinstance(mapping, dict):
|
||||
return []
|
||||
rows: list[tuple[float, int, dict[str, Any]]] = []
|
||||
for index, node in enumerate(mapping.values()):
|
||||
if not isinstance(node, dict) or not isinstance(node.get("message"), dict):
|
||||
continue
|
||||
message = node["message"]
|
||||
sort_value = message.get("create_time")
|
||||
try:
|
||||
sort_key = float(sort_value)
|
||||
except (TypeError, ValueError):
|
||||
sort_key = float(index)
|
||||
normalized = normalize_message(message)
|
||||
if normalized:
|
||||
rows.append((sort_key, index, normalized))
|
||||
return [row[2] for row in sorted(rows, key=lambda row: (row[0], row[1]))]
|
||||
|
||||
|
||||
def conversation_messages(conversation: dict[str, Any]) -> tuple[list[dict[str, Any]], str]:
|
||||
mapped = chatgpt_mapping_messages(conversation)
|
||||
if mapped:
|
||||
return mapped, "chatgpt_mapping"
|
||||
for key in ("messages", "chat_messages", "turns"):
|
||||
raw_messages = conversation.get(key)
|
||||
if isinstance(raw_messages, list):
|
||||
messages = [
|
||||
normalized
|
||||
for raw in raw_messages
|
||||
if isinstance(raw, dict)
|
||||
for normalized in [normalize_message(raw)]
|
||||
if normalized
|
||||
]
|
||||
return messages, key
|
||||
return [], "unknown"
|
||||
|
||||
|
||||
def conversation_title(conversation: dict[str, Any], index: int) -> str:
|
||||
for key in ("title", "name", "summary"):
|
||||
value = conversation.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
return f"Conversation {index + 1}"
|
||||
|
||||
|
||||
def collect_conversation_json(
|
||||
path: Path,
|
||||
source_name: str,
|
||||
*,
|
||||
include_content: bool = False,
|
||||
max_messages: int = 2000,
|
||||
) -> tuple[list[dict[str, Any]], list[InputWarning]]:
|
||||
warnings: list[InputWarning] = []
|
||||
try:
|
||||
payload = read_json(path)
|
||||
except Exception as exc:
|
||||
return [], [InputWarning(str(path), f"could not read JSON: {exc}")]
|
||||
|
||||
payload = payload_items(payload, ("conversations", "conversation", "items", "data"))
|
||||
if isinstance(payload, dict):
|
||||
payload = [payload]
|
||||
if not isinstance(payload, list):
|
||||
return [], [InputWarning(str(path), "expected a JSON list or an object containing a conversation list")]
|
||||
|
||||
items: list[dict[str, Any]] = []
|
||||
for index, conversation in enumerate(payload):
|
||||
if not isinstance(conversation, dict):
|
||||
warnings.append(InputWarning(str(path), f"skipped conversation at index {index}: expected object"))
|
||||
continue
|
||||
messages, format_hint = conversation_messages(conversation)
|
||||
if not messages:
|
||||
warnings.append(InputWarning(str(path), f"skipped conversation at index {index}: no text messages found"))
|
||||
continue
|
||||
title = conversation_title(conversation, index)
|
||||
source_id = conversation.get("id") or conversation.get("uuid") or conversation.get("conversation_id")
|
||||
text_digest = sha256_text("\n".join(f"{msg['role']}:{msg['text']}" for msg in messages))
|
||||
metadata: dict[str, Any] = {
|
||||
"source_path": str(path),
|
||||
"source_index": index,
|
||||
"source_format": format_hint,
|
||||
"message_count": len(messages),
|
||||
"text_sha256": text_digest,
|
||||
"content_included": False,
|
||||
}
|
||||
if source_id is not None:
|
||||
metadata["source_id"] = str(source_id)
|
||||
for key in ("create_time", "created_at", "update_time", "updated_at"):
|
||||
timestamp = normalize_timestamp(conversation.get(key))
|
||||
if timestamp:
|
||||
metadata[f"source_{key}"] = timestamp
|
||||
item: dict[str, Any] = {
|
||||
"id": stable_id("conversation", source_name, path, source_id or index, text_digest),
|
||||
"kind": "conversation_thread",
|
||||
"title": title,
|
||||
"source": source_name,
|
||||
"metadata": metadata,
|
||||
}
|
||||
if include_content:
|
||||
if len(messages) > max_messages:
|
||||
warnings.append(
|
||||
InputWarning(
|
||||
str(path),
|
||||
f"skipped conversation content at index {index}: over {max_messages} messages",
|
||||
)
|
||||
)
|
||||
else:
|
||||
item["messages"] = messages
|
||||
item["metadata"]["content_included"] = True
|
||||
items.append(item)
|
||||
return items, warnings
|
||||
|
||||
|
||||
def parse_skill_frontmatter(text: str) -> dict[str, Any]:
|
||||
if not text.startswith("---"):
|
||||
return {}
|
||||
end = text.find("\n---", 3)
|
||||
if end < 0:
|
||||
return {}
|
||||
frontmatter: dict[str, Any] = {}
|
||||
for line in text[3:end].strip().splitlines():
|
||||
if not line.strip() or line.lstrip().startswith("#") or ":" not in line:
|
||||
continue
|
||||
key, value = line.split(":", 1)
|
||||
key = key.strip()
|
||||
value = value.strip().strip('"').strip("'")
|
||||
if key:
|
||||
frontmatter[key] = value
|
||||
return frontmatter
|
||||
|
||||
|
||||
def collect_skill_dir(path: Path, source_name: str) -> tuple[list[dict[str, Any]], list[InputWarning]]:
|
||||
warnings: list[InputWarning] = []
|
||||
if path.is_symlink():
|
||||
return [], [InputWarning(str(path), "skills path is a symlink; skipped")]
|
||||
if not path.exists():
|
||||
return [], [InputWarning(str(path), "skills directory does not exist")]
|
||||
if not path.is_dir():
|
||||
return [], [InputWarning(str(path), "skills path is not a directory")]
|
||||
|
||||
items: list[dict[str, Any]] = []
|
||||
for skill_path in sorted(path.rglob("SKILL.md")):
|
||||
if skill_path.is_symlink():
|
||||
warnings.append(InputWarning(str(skill_path), "skipped symlinked skill file"))
|
||||
continue
|
||||
try:
|
||||
text = skill_path.read_text(encoding="utf-8")
|
||||
except Exception as exc:
|
||||
warnings.append(InputWarning(str(skill_path), f"could not read skill: {exc}"))
|
||||
continue
|
||||
frontmatter = parse_skill_frontmatter(text)
|
||||
name = str(frontmatter.get("name") or skill_path.parent.name).strip() or skill_path.parent.name
|
||||
items.append(
|
||||
{
|
||||
"id": stable_id("skill", source_name, skill_path, sha256_text(text)),
|
||||
"kind": "skill",
|
||||
"name": name,
|
||||
"category": str(frontmatter.get("category") or "general"),
|
||||
"source": source_name,
|
||||
"format": "SKILL.md",
|
||||
"content": text,
|
||||
"metadata": {
|
||||
"source_path": str(skill_path),
|
||||
"sha256": sha256_text(text),
|
||||
"frontmatter": frontmatter,
|
||||
},
|
||||
}
|
||||
)
|
||||
return items, warnings
|
||||
|
||||
|
||||
def looks_textual(path: Path) -> bool:
|
||||
if path.suffix.lower() in TEXT_EXTENSIONS:
|
||||
return True
|
||||
guessed, _ = mimetypes.guess_type(str(path))
|
||||
return bool(guessed and (guessed.startswith("text/") or guessed in {"application/json"}))
|
||||
|
||||
|
||||
def iter_archive_dir(path: Path) -> Iterable[Path | InputWarning]:
|
||||
try:
|
||||
children = sorted(path.iterdir())
|
||||
except Exception as exc:
|
||||
yield InputWarning(str(path), f"could not scan archive directory: {exc}")
|
||||
return
|
||||
for child in children:
|
||||
if child.is_symlink():
|
||||
yield InputWarning(str(child), "skipped symlinked archive path")
|
||||
continue
|
||||
if child.is_file():
|
||||
yield child
|
||||
elif child.is_dir():
|
||||
yield from iter_archive_dir(child)
|
||||
|
||||
|
||||
def iter_archive_files(paths: Iterable[Path]) -> Iterable[Path | InputWarning]:
|
||||
for path in paths:
|
||||
if path.is_symlink():
|
||||
yield InputWarning(str(path), "skipped symlinked archive path")
|
||||
continue
|
||||
if path.is_file():
|
||||
yield path
|
||||
elif path.is_dir():
|
||||
yield from iter_archive_dir(path)
|
||||
|
||||
|
||||
def collect_archive_paths(
|
||||
paths: list[Path],
|
||||
source_name: str,
|
||||
*,
|
||||
include_content: bool = False,
|
||||
max_bytes: int = 256_000,
|
||||
) -> tuple[list[dict[str, Any]], list[InputWarning]]:
|
||||
warnings: list[InputWarning] = []
|
||||
items: list[dict[str, Any]] = []
|
||||
existing_paths: list[Path] = []
|
||||
for path in paths:
|
||||
if path.is_symlink():
|
||||
warnings.append(InputWarning(str(path), "archive path is a symlink; skipped"))
|
||||
continue
|
||||
if not path.exists():
|
||||
warnings.append(InputWarning(str(path), "archive path does not exist"))
|
||||
continue
|
||||
if not path.is_file() and not path.is_dir():
|
||||
warnings.append(InputWarning(str(path), "archive path is not a file or directory"))
|
||||
continue
|
||||
existing_paths.append(path)
|
||||
|
||||
for entry in iter_archive_files(existing_paths):
|
||||
if isinstance(entry, InputWarning):
|
||||
warnings.append(entry)
|
||||
continue
|
||||
path = entry
|
||||
if not looks_textual(path):
|
||||
warnings.append(InputWarning(str(path), "skipped non-text archive file"))
|
||||
continue
|
||||
try:
|
||||
st = path.stat()
|
||||
except Exception as exc:
|
||||
warnings.append(InputWarning(str(path), f"could not stat archive file: {exc}"))
|
||||
continue
|
||||
size = st.st_size
|
||||
try:
|
||||
file_hash = sha256_path(path)
|
||||
except Exception as exc:
|
||||
warnings.append(InputWarning(str(path), f"could not hash archive file: {exc}"))
|
||||
continue
|
||||
if include_content and size > max_bytes:
|
||||
warnings.append(InputWarning(str(path), f"skipped archive content over {max_bytes} bytes"))
|
||||
archive_item: dict[str, Any] = {
|
||||
"id": stable_id("archive", source_name, path, file_hash),
|
||||
"kind": "archive_document",
|
||||
"title": path.name,
|
||||
"source": source_name,
|
||||
"metadata": {
|
||||
"source_path": str(path),
|
||||
"size_bytes": size,
|
||||
"sha256": file_hash,
|
||||
},
|
||||
}
|
||||
if include_content and size <= max_bytes:
|
||||
try:
|
||||
archive_item["content"] = path.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
archive_item["content"] = path.read_text(encoding="utf-8", errors="replace")
|
||||
archive_item["metadata"]["decoded_with_replacement"] = True
|
||||
items.append(archive_item)
|
||||
return items, warnings
|
||||
|
||||
|
||||
def build_manifest(args) -> dict[str, Any]:
|
||||
warnings: list[InputWarning] = []
|
||||
items: list[dict[str, Any]] = []
|
||||
|
||||
for path in args.memory_json:
|
||||
collected, got_warnings = collect_memory_json(path, args.source_name)
|
||||
items.extend(collected)
|
||||
warnings.extend(got_warnings)
|
||||
|
||||
for path in args.skills_dir:
|
||||
collected, got_warnings = collect_skill_dir(path, args.source_name)
|
||||
items.extend(collected)
|
||||
warnings.extend(got_warnings)
|
||||
|
||||
for path in args.conversation_json:
|
||||
collected, got_warnings = collect_conversation_json(
|
||||
path,
|
||||
args.source_name,
|
||||
include_content=args.include_conversation_content,
|
||||
max_messages=args.max_conversation_messages,
|
||||
)
|
||||
items.extend(collected)
|
||||
warnings.extend(got_warnings)
|
||||
|
||||
if args.archive:
|
||||
collected, got_warnings = collect_archive_paths(
|
||||
args.archive,
|
||||
args.source_name,
|
||||
include_content=args.include_archive_content,
|
||||
max_bytes=args.max_archive_bytes,
|
||||
)
|
||||
items.extend(collected)
|
||||
warnings.extend(got_warnings)
|
||||
|
||||
counts: dict[str, int] = {}
|
||||
for item in items:
|
||||
counts[item["kind"]] = counts.get(item["kind"], 0) + 1
|
||||
|
||||
return {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"generated_at": utc_now_iso(),
|
||||
"source": {
|
||||
"name": args.source_name,
|
||||
"kind": args.source_kind,
|
||||
},
|
||||
"summary": {
|
||||
"item_count": len(items),
|
||||
"counts_by_kind": counts,
|
||||
"warning_count": len(warnings),
|
||||
},
|
||||
"items": items,
|
||||
"warnings": [{"path": warning.path, "message": warning.message} for warning in warnings],
|
||||
}
|
||||
|
||||
|
||||
def parse_args(argv: list[str] | None = None):
|
||||
parser = argparse.ArgumentParser(description="Build a neutral Odysseus agent migration manifest.")
|
||||
parser.add_argument("--source-name", default="agent-export", help="Human-readable source name.")
|
||||
parser.add_argument("--source-kind", default="generic", help="Source adapter kind, e.g. generic, openclaw, hermes.")
|
||||
parser.add_argument(
|
||||
"--memory-json",
|
||||
action="append",
|
||||
type=Path,
|
||||
default=[],
|
||||
help="JSON memory export. May be a list, or an object containing memories/items/data.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skills-dir",
|
||||
action="append",
|
||||
type=Path,
|
||||
default=[],
|
||||
help="Directory containing SKILL.md files. Scanned recursively.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--archive",
|
||||
action="append",
|
||||
type=Path,
|
||||
default=[],
|
||||
help="Text/Markdown/JSON file or directory to preserve as archive documents.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--conversation-json",
|
||||
action="append",
|
||||
type=Path,
|
||||
default=[],
|
||||
help="Conversation export JSON. Supports generic message lists and ChatGPT-style conversations.json.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--include-archive-content",
|
||||
action="store_true",
|
||||
help="Embed archive document content in the manifest. By default only metadata is included.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-archive-bytes",
|
||||
type=int,
|
||||
default=256_000,
|
||||
help="Maximum bytes to embed per archive file when --include-archive-content is used.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--include-conversation-content",
|
||||
action="store_true",
|
||||
help="Embed normalized conversation messages. By default only thread metadata is included.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-conversation-messages",
|
||||
type=int,
|
||||
default=2000,
|
||||
help="Maximum messages to embed per conversation when --include-conversation-content is used.",
|
||||
)
|
||||
parser.add_argument("--output", type=Path, help="Write manifest JSON to this path instead of stdout.")
|
||||
parser.add_argument("--compact", action="store_true", help="Write compact JSON without indentation.")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = parse_args(argv)
|
||||
manifest = build_manifest(args)
|
||||
text = json.dumps(manifest, ensure_ascii=False, sort_keys=True, separators=(",", ":")) if args.compact else (
|
||||
json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
|
||||
)
|
||||
if args.output:
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(text, encoding="utf-8")
|
||||
else:
|
||||
sys.stdout.write(text)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+133
@@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Backfill release_date on entries in services/hwfit/data/hf_models.json.
|
||||
|
||||
Why: the `newest` sort in the cookbook ranks rows by release_date. Anything
|
||||
missing a date sorts to the bottom. This script pulls `created_at` from the
|
||||
HuggingFace API for each catalog entry without one (or all entries when
|
||||
--refresh is passed) and writes the catalog back.
|
||||
|
||||
Usage:
|
||||
python scripts/backfill_model_release_dates.py # missing only
|
||||
python scripts/backfill_model_release_dates.py --refresh # all entries
|
||||
python scripts/backfill_model_release_dates.py --limit 50 # cap requests
|
||||
python scripts/backfill_model_release_dates.py --dry-run # show, don't write
|
||||
|
||||
Auth: set HF_TOKEN env var (or huggingface-cli login) to access gated repos.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from huggingface_hub import HfApi
|
||||
from huggingface_hub.utils import HfHubHTTPError
|
||||
except ImportError:
|
||||
print("Install huggingface_hub: pip install huggingface_hub", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
CATALOG_PATH = Path(__file__).resolve().parent.parent / "services" / "hwfit" / "data" / "hf_models.json"
|
||||
|
||||
|
||||
def fetch_release_date(api: HfApi, repo_id: str) -> str | None:
|
||||
"""Return YYYY-MM-DD release date, or None on miss / error."""
|
||||
try:
|
||||
info = api.model_info(repo_id, files_metadata=False)
|
||||
except HfHubHTTPError as e:
|
||||
# 401 = gated/private, 404 = renamed/deleted. Either way, no date.
|
||||
status = getattr(getattr(e, "response", None), "status_code", None)
|
||||
print(f" {repo_id}: HTTP {status or '?'}", file=sys.stderr)
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f" {repo_id}: {type(e).__name__}: {e}", file=sys.stderr)
|
||||
return None
|
||||
created = getattr(info, "created_at", None)
|
||||
if not created:
|
||||
return None
|
||||
return created.strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
p.add_argument("--refresh", action="store_true", help="Overwrite existing release_date too (default: only fill missing).")
|
||||
p.add_argument("--limit", type=int, default=0, help="Stop after N API calls (0 = no limit).")
|
||||
p.add_argument("--dry-run", action="store_true", help="Don't write back; just report.")
|
||||
p.add_argument("--sleep", type=float, default=0.05, help="Seconds to sleep between requests (default 0.05).")
|
||||
args = p.parse_args()
|
||||
|
||||
if not CATALOG_PATH.exists():
|
||||
print(f"Catalog not found: {CATALOG_PATH}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
with CATALOG_PATH.open(encoding="utf-8") as f:
|
||||
catalog = json.load(f)
|
||||
|
||||
candidates = []
|
||||
for i, m in enumerate(catalog):
|
||||
name = m.get("name")
|
||||
if not name:
|
||||
continue
|
||||
existing = (m.get("release_date") or "").strip()
|
||||
if existing and not args.refresh:
|
||||
continue
|
||||
candidates.append(i)
|
||||
|
||||
if args.limit:
|
||||
candidates = candidates[: args.limit]
|
||||
|
||||
print(f"Catalog: {CATALOG_PATH}")
|
||||
print(f"Total entries: {len(catalog)}")
|
||||
print(f"Targets ({'refresh all' if args.refresh else 'missing only'}{'' if not args.limit else f', capped at {args.limit}'}): {len(candidates)}")
|
||||
if not candidates:
|
||||
print("Nothing to do.")
|
||||
return
|
||||
|
||||
api = HfApi(token=os.environ.get("HF_TOKEN") or None)
|
||||
updated = 0
|
||||
skipped = 0
|
||||
started = time.time()
|
||||
for n, idx in enumerate(candidates, start=1):
|
||||
entry = catalog[idx]
|
||||
name = entry["name"]
|
||||
old = (entry.get("release_date") or "").strip()
|
||||
new = fetch_release_date(api, name)
|
||||
if new is None:
|
||||
skipped += 1
|
||||
tag = "skip"
|
||||
elif new == old:
|
||||
tag = "unchanged"
|
||||
else:
|
||||
entry["release_date"] = new
|
||||
updated += 1
|
||||
tag = f"set {new}" + (f" (was {old})" if old else "")
|
||||
print(f"[{n}/{len(candidates)}] {name} — {tag}")
|
||||
if args.sleep:
|
||||
time.sleep(args.sleep)
|
||||
|
||||
elapsed = time.time() - started
|
||||
print()
|
||||
print(f"Done in {elapsed:.1f}s — {updated} updated, {skipped} skipped (HF unavailable / gated / missing date).")
|
||||
|
||||
if args.dry_run:
|
||||
print("Dry run — no write.")
|
||||
return
|
||||
|
||||
if updated:
|
||||
# Atomic write: tmp file in the same dir, then rename. Keeps the
|
||||
# catalog usable even if the process dies mid-write.
|
||||
tmp = CATALOG_PATH.with_suffix(".json.tmp")
|
||||
with tmp.open("w", encoding="utf-8") as f:
|
||||
json.dump(catalog, f, indent=1, ensure_ascii=False)
|
||||
f.write("\n")
|
||||
tmp.replace(CATALOG_PATH)
|
||||
print(f"Wrote {CATALOG_PATH}")
|
||||
else:
|
||||
print("No changes to write.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+341
@@ -0,0 +1,341 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Import models from the upstream vllm-project/recipes catalog into our
|
||||
local hf_models.json. Two modes:
|
||||
|
||||
--update-existing Stamp min_vllm_version + vllm_recipe=True on rows we
|
||||
already carry. Cheap, no HF API calls.
|
||||
--add-missing Create new catalog rows for every recipe model we
|
||||
don't carry. Hits the HF API for created_at + downloads
|
||||
(~1 req per missing model, paced).
|
||||
|
||||
Both modes write atomically (tmp + rename) so a crashed run leaves the
|
||||
catalog intact. Default with no mode flags runs both, prefer to pass them
|
||||
explicitly.
|
||||
|
||||
Usage:
|
||||
python scripts/import_from_vllm_recipes.py --update-existing
|
||||
python scripts/import_from_vllm_recipes.py --add-missing
|
||||
python scripts/import_from_vllm_recipes.py --dry-run
|
||||
python scripts/import_from_vllm_recipes.py --limit 10
|
||||
|
||||
Auth: set HF_TOKEN to access gated repos when --add-missing.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import httpx
|
||||
import yaml
|
||||
except ImportError:
|
||||
print("pip install httpx PyYAML", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
from huggingface_hub import HfApi
|
||||
from huggingface_hub.utils import HfHubHTTPError
|
||||
except ImportError:
|
||||
HfApi = None
|
||||
HfHubHTTPError = Exception
|
||||
|
||||
|
||||
CATALOG_PATH = Path(__file__).resolve().parent.parent / "services" / "hwfit" / "data" / "hf_models.json"
|
||||
RECIPES_TREE_URL = (
|
||||
"https://api.github.com/repos/vllm-project/recipes/git/trees/main?recursive=1"
|
||||
)
|
||||
RECIPE_RAW_URL = (
|
||||
"https://raw.githubusercontent.com/vllm-project/recipes/main/models/{repo}.yaml"
|
||||
)
|
||||
|
||||
|
||||
# Map recipe `precision` to the closest catalog `quantization` label that
|
||||
# fit.py / models.py already understand.
|
||||
_PRECISION_TO_QUANT = {
|
||||
"fp8": "FP8",
|
||||
"nvfp4": "NVFP4",
|
||||
"mxfp4": "MXFP4",
|
||||
"bf16": "BF16",
|
||||
"fp16": "F16",
|
||||
"f16": "F16",
|
||||
"fp4": "FP4",
|
||||
"int8": "INT8",
|
||||
"int4": "INT4",
|
||||
"awq-4bit": "AWQ-4bit",
|
||||
"awq-8bit": "AWQ-8bit",
|
||||
}
|
||||
|
||||
# Architecture name → use_case fallback. fit.py weights use_case for filtering;
|
||||
# missing field defaults to a generic bucket.
|
||||
_ARCH_USE_CASE = {
|
||||
"moe": "General-purpose reasoning, long-context",
|
||||
"llama": "General-purpose chat",
|
||||
"qwen2": "General-purpose chat",
|
||||
"qwen3": "General-purpose reasoning",
|
||||
"deepseek_v3_moe": "General-purpose reasoning, long-context",
|
||||
"deepseek_v4_moe": "General-purpose reasoning, long-context",
|
||||
}
|
||||
|
||||
|
||||
def _parse_param_count(s) -> int:
|
||||
"""'230B' / '8.6B' / '4.2T' → integer parameter count."""
|
||||
if s is None:
|
||||
return 0
|
||||
s = str(s).strip().replace(",", "")
|
||||
m = re.match(r"^([\d.]+)\s*([KMBT]?)$", s, re.I)
|
||||
if not m:
|
||||
return 0
|
||||
num = float(m.group(1))
|
||||
unit = (m.group(2) or "").upper()
|
||||
mult = {"K": 1e3, "M": 1e6, "B": 1e9, "T": 1e12, "": 1.0}[unit]
|
||||
return int(num * mult)
|
||||
|
||||
|
||||
def _capabilities_for(arch: str, hardware: dict, ctx_len: int, has_reasoning: bool) -> list[str]:
|
||||
caps = []
|
||||
if "moe" in (arch or "").lower():
|
||||
caps.append("moe")
|
||||
if has_reasoning:
|
||||
caps.append("reasoning")
|
||||
if ctx_len and ctx_len >= 100_000:
|
||||
caps.append("long_context")
|
||||
if any(hw in (hardware or {}) for hw in ("mi300x", "mi325x", "mi350x", "mi355x")):
|
||||
caps.append("amd_supported")
|
||||
return caps
|
||||
|
||||
|
||||
def _fetch_manifest(client: httpx.Client) -> set[str]:
|
||||
r = client.get(RECIPES_TREE_URL, headers={"Accept": "application/vnd.github+json"}, timeout=15)
|
||||
r.raise_for_status()
|
||||
tree = (r.json() or {}).get("tree") or []
|
||||
out: set[str] = set()
|
||||
for e in tree:
|
||||
path = (e or {}).get("path") or ""
|
||||
if path.startswith("models/") and path.endswith(".yaml"):
|
||||
body = path[len("models/"):-len(".yaml")]
|
||||
if "/" in body:
|
||||
out.add(body)
|
||||
return out
|
||||
|
||||
|
||||
def _fetch_recipe(client: httpx.Client, repo: str) -> dict | None:
|
||||
url = RECIPE_RAW_URL.format(repo=repo)
|
||||
try:
|
||||
r = client.get(url, timeout=10)
|
||||
if r.status_code != 200:
|
||||
return None
|
||||
return yaml.safe_load(r.text) or {}
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _stamp_from_recipe(entry: dict, recipe: dict) -> bool:
|
||||
"""Mutate entry with recipe-derived fields. Returns True if anything changed."""
|
||||
model = recipe.get("model") or {}
|
||||
meta = recipe.get("meta") or {}
|
||||
features = recipe.get("features") or {}
|
||||
|
||||
changed = False
|
||||
new_min = (model.get("min_vllm_version") or "").strip()
|
||||
if new_min and entry.get("min_vllm_version") != new_min:
|
||||
entry["min_vllm_version"] = new_min
|
||||
changed = True
|
||||
if not entry.get("vllm_recipe"):
|
||||
entry["vllm_recipe"] = True
|
||||
changed = True
|
||||
# Hardware support map — useful for filtering "which models run on my AMD box".
|
||||
hw = meta.get("hardware") or {}
|
||||
if hw and entry.get("recipe_hardware") != hw:
|
||||
entry["recipe_hardware"] = {k: str(v) for k, v in hw.items()}
|
||||
changed = True
|
||||
# Tool/reasoning parser hints — purely informational at catalog level;
|
||||
# the live launch command builder still reads them from the recipe API.
|
||||
if features.get("reasoning") and not entry.get("has_reasoning_parser"):
|
||||
entry["has_reasoning_parser"] = True
|
||||
changed = True
|
||||
if features.get("tool_calling") and not entry.get("has_tool_call_parser"):
|
||||
entry["has_tool_call_parser"] = True
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
def _build_new_entry(repo: str, recipe: dict, hf_info=None) -> dict | None:
|
||||
"""Build a fresh catalog entry from a recipe + (optional) HF model info."""
|
||||
model = recipe.get("model") or {}
|
||||
meta = recipe.get("meta") or {}
|
||||
features = recipe.get("features") or {}
|
||||
variants = recipe.get("variants") or {}
|
||||
|
||||
org, name = repo.split("/", 1)
|
||||
raw_params = _parse_param_count(model.get("parameter_count"))
|
||||
active_raw = _parse_param_count(model.get("active_parameters"))
|
||||
ctx = model.get("context_length") or 0
|
||||
|
||||
# Pick the smallest-VRAM variant as the catalog quant — that's what most
|
||||
# users land on first. NVFP4/MXFP4 typically win this on Blackwell;
|
||||
# FP8 elsewhere; BF16 baseline only.
|
||||
pick_quant = None
|
||||
pick_vram = None
|
||||
for vk, vv in variants.items():
|
||||
if not isinstance(vv, dict):
|
||||
continue
|
||||
prec = (vv.get("precision") or "").lower()
|
||||
vram = vv.get("vram_minimum_gb") or 0
|
||||
quant = _PRECISION_TO_QUANT.get(prec)
|
||||
if quant and (pick_vram is None or (vram and vram < pick_vram)):
|
||||
pick_quant = quant
|
||||
pick_vram = vram or pick_vram
|
||||
if not pick_quant:
|
||||
pick_quant = "BF16"
|
||||
|
||||
arch = (model.get("architecture") or "").lower()
|
||||
use_case = _ARCH_USE_CASE.get(arch, "General-purpose chat")
|
||||
caps = _capabilities_for(arch, meta.get("hardware") or {}, ctx, bool(features.get("reasoning")))
|
||||
|
||||
rel_date = ""
|
||||
downloads = 0
|
||||
likes = 0
|
||||
if hf_info is not None:
|
||||
created = getattr(hf_info, "created_at", None)
|
||||
if created:
|
||||
rel_date = created.strftime("%Y-%m-%d")
|
||||
downloads = int(getattr(hf_info, "downloads", 0) or 0)
|
||||
likes = int(getattr(hf_info, "likes", 0) or 0)
|
||||
if not rel_date:
|
||||
rel_date = str(meta.get("date_updated") or datetime.utcnow().strftime("%Y-%m-%d"))
|
||||
|
||||
entry: dict = {
|
||||
"name": repo,
|
||||
"provider": org,
|
||||
"parameter_count": str(model.get("parameter_count") or "?"),
|
||||
"parameters_raw": raw_params,
|
||||
"is_moe": "moe" in arch,
|
||||
"quantization": pick_quant,
|
||||
"context_length": int(ctx or 0),
|
||||
"use_case": use_case,
|
||||
"capabilities": caps,
|
||||
"pipeline_tag": "text-generation",
|
||||
"architecture": arch or "unknown",
|
||||
"hf_downloads": downloads,
|
||||
"hf_likes": likes,
|
||||
"release_date": rel_date,
|
||||
# Recipe-derived bits.
|
||||
"vllm_recipe": True,
|
||||
"min_vllm_version": (model.get("min_vllm_version") or "").strip() or None,
|
||||
"recipe_hardware": {k: str(v) for k, v in (meta.get("hardware") or {}).items()},
|
||||
"has_reasoning_parser": bool(features.get("reasoning")),
|
||||
"has_tool_call_parser": bool(features.get("tool_calling")),
|
||||
}
|
||||
if active_raw:
|
||||
entry["active_parameters"] = active_raw
|
||||
if pick_vram:
|
||||
# min_vram_gb is what hwfit uses for "does this fit". Recipe states a
|
||||
# minimum for the chosen variant; round up slightly for KV-cache room.
|
||||
entry["min_vram_gb"] = float(pick_vram)
|
||||
entry["min_ram_gb"] = float(round(pick_vram * 0.6, 1))
|
||||
entry["recommended_ram_gb"] = float(round(pick_vram * 1.2, 1))
|
||||
# Drop empty / None fields to keep the JSON tidy.
|
||||
return {k: v for k, v in entry.items() if v not in (None, "", [], {})}
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
p.add_argument("--update-existing", action="store_true", help="Stamp min_vllm_version + vllm_recipe on existing rows.")
|
||||
p.add_argument("--add-missing", action="store_true", help="Add new rows for recipe models not in the catalog.")
|
||||
p.add_argument("--limit", type=int, default=0, help="Stop after N recipe fetches.")
|
||||
p.add_argument("--dry-run", action="store_true", help="Don't write back; just report.")
|
||||
p.add_argument("--sleep", type=float, default=0.05, help="Seconds between HTTP requests.")
|
||||
args = p.parse_args()
|
||||
if not args.update_existing and not args.add_missing:
|
||||
args.update_existing = args.add_missing = True
|
||||
|
||||
with CATALOG_PATH.open(encoding="utf-8") as f:
|
||||
catalog = json.load(f)
|
||||
by_name = {m.get("name"): m for m in catalog if m.get("name")}
|
||||
|
||||
client = httpx.Client(follow_redirects=True)
|
||||
print(f"Catalog: {CATALOG_PATH} ({len(catalog)} entries)")
|
||||
print("Fetching upstream manifest…")
|
||||
try:
|
||||
manifest = _fetch_manifest(client)
|
||||
except Exception as e:
|
||||
print(f"FATAL: manifest fetch failed: {e}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
print(f"Manifest: {len(manifest)} recipes")
|
||||
|
||||
existing = sorted(by_name.keys() & manifest)
|
||||
missing = sorted(manifest - by_name.keys())
|
||||
print(f"Match catalog ↔ manifest: existing={len(existing)} missing={len(missing)}")
|
||||
|
||||
targets: list[tuple[str, str]] = [] # (repo, action)
|
||||
if args.update_existing:
|
||||
targets.extend((r, "update") for r in existing)
|
||||
if args.add_missing:
|
||||
targets.extend((r, "add") for r in missing)
|
||||
if args.limit:
|
||||
targets = targets[: args.limit]
|
||||
print(f"Targets: {len(targets)}")
|
||||
|
||||
hf_api = HfApi(token=os.environ.get("HF_TOKEN") or None) if HfApi else None
|
||||
updated = added = skipped = 0
|
||||
started = time.time()
|
||||
|
||||
for n, (repo, action) in enumerate(targets, 1):
|
||||
recipe = _fetch_recipe(client, repo)
|
||||
if not recipe:
|
||||
print(f"[{n}/{len(targets)}] {repo:55} skip (no recipe fetched)")
|
||||
skipped += 1
|
||||
time.sleep(args.sleep)
|
||||
continue
|
||||
if action == "update":
|
||||
entry = by_name[repo]
|
||||
if _stamp_from_recipe(entry, recipe):
|
||||
updated += 1
|
||||
print(f"[{n}/{len(targets)}] {repo:55} updated")
|
||||
else:
|
||||
print(f"[{n}/{len(targets)}] {repo:55} unchanged")
|
||||
else: # add
|
||||
hf_info = None
|
||||
if hf_api:
|
||||
try:
|
||||
hf_info = hf_api.model_info(repo, files_metadata=False)
|
||||
except HfHubHTTPError as e:
|
||||
code = getattr(getattr(e, "response", None), "status_code", "?")
|
||||
print(f" HF {code} for {repo} — building from recipe only", file=sys.stderr)
|
||||
except Exception as e:
|
||||
print(f" HF error for {repo}: {e}", file=sys.stderr)
|
||||
new_entry = _build_new_entry(repo, recipe, hf_info)
|
||||
if new_entry:
|
||||
catalog.append(new_entry)
|
||||
by_name[repo] = new_entry
|
||||
added += 1
|
||||
print(f"[{n}/{len(targets)}] {repo:55} added ({new_entry.get('parameter_count','?')}, {new_entry.get('quantization','?')})")
|
||||
else:
|
||||
skipped += 1
|
||||
print(f"[{n}/{len(targets)}] {repo:55} skip (couldn't build entry)")
|
||||
time.sleep(args.sleep)
|
||||
|
||||
elapsed = time.time() - started
|
||||
print()
|
||||
print(f"Done in {elapsed:.1f}s — added={added}, updated={updated}, skipped={skipped}")
|
||||
|
||||
if args.dry_run:
|
||||
print("Dry run — no write.")
|
||||
return
|
||||
if added or updated:
|
||||
tmp = CATALOG_PATH.with_suffix(".json.tmp")
|
||||
with tmp.open("w", encoding="utf-8") as f:
|
||||
json.dump(catalog, f, indent=1, ensure_ascii=False)
|
||||
f.write("\n")
|
||||
tmp.replace(CATALOG_PATH)
|
||||
print(f"Wrote {CATALOG_PATH} ({len(catalog)} entries)")
|
||||
else:
|
||||
print("No changes — catalog untouched.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+69
-13
@@ -19,22 +19,32 @@ GPU_BANDWIDTH = {
|
||||
"6950 xt": 576, "6900 xt": 512, "6800 xt": 512, "6800": 512, "6700 xt": 384, "6600 xt": 256, "6600": 224,
|
||||
"mi300x": 5300, "mi300": 5300, "mi250x": 3277, "mi250": 3277, "mi210": 1638, "mi100": 1229,
|
||||
"9070 xt": 624, "9070": 488, "9060 xt": 322, "9060": 322,
|
||||
# Apple Silicon unified-memory bandwidth (GB/s). Keyed off the chip name
|
||||
# reported by sysctl machdep.cpu.brand_string (e.g. "Apple M4 Max"). Listed
|
||||
# before the bare "m_" keys matters less than length-sorting (done below),
|
||||
# which guarantees "m4 max" is tried before "m4".
|
||||
"m1 ultra": 800, "m1 max": 400, "m1 pro": 200, "m1": 68,
|
||||
"m2 ultra": 800, "m2 max": 400, "m2 pro": 200, "m2": 100,
|
||||
"m3 ultra": 800, "m3 max": 300, "m3 pro": 150, "m3": 100,
|
||||
"m4 max": 546, "m4 pro": 273, "m4": 120,
|
||||
"m5 max": 546, "m5 pro": 273, "m5": 150,
|
||||
}
|
||||
|
||||
# Pre-sort keys by length descending for correct substring matching
|
||||
_BW_KEYS_SORTED = sorted(GPU_BANDWIDTH.keys(), key=len, reverse=True)
|
||||
|
||||
# metal: backstop for Apple Silicon chips not in GPU_BANDWIDTH (e.g. a future
|
||||
# M5) — the named chips above take the accurate bandwidth path instead.
|
||||
# Apple Silicon unified-memory bandwidth (GB/s). For chip families with both
|
||||
# binned and full variants under the same "Apple Mx Max" brand string, prefer
|
||||
# GPU core count when hardware detection provides it; otherwise fall back to the
|
||||
# conservative tier so speed estimates do not over-promise.
|
||||
APPLE_BANDWIDTH_FIXED = {
|
||||
"m1 ultra": 800, "m1 max": 400, "m1 pro": 200, "m1": 68,
|
||||
"m2 ultra": 800, "m2 max": 400, "m2 pro": 200, "m2": 100,
|
||||
"m3 ultra": 800, "m3 pro": 150, "m3": 100,
|
||||
"m4 pro": 273, "m4": 120,
|
||||
"m5 pro": 307, "m5": 153,
|
||||
}
|
||||
APPLE_BANDWIDTH_BY_CORES = {
|
||||
"m3 max": {30: 300, 40: 400},
|
||||
"m4 max": {32: 410, 40: 546},
|
||||
"m5 max": {32: 460, 40: 614},
|
||||
}
|
||||
_APPLE_FIXED_KEYS_SORTED = sorted(APPLE_BANDWIDTH_FIXED.keys(), key=len, reverse=True)
|
||||
_APPLE_VARIANT_KEYS_SORTED = sorted(APPLE_BANDWIDTH_BY_CORES.keys(), key=len, reverse=True)
|
||||
|
||||
# metal: backstop for Apple Silicon chips not in the explicit tables above
|
||||
# (e.g. a future M6) — use a conservative generic estimate when unknown.
|
||||
FALLBACK_K = {"cuda": 220, "rocm": 180, "metal": 150, "cpu_x86": 70, "cpu_arm": 90}
|
||||
|
||||
USE_CASE_WEIGHTS = {
|
||||
@@ -60,10 +70,56 @@ CONTEXT_TARGET = {
|
||||
}
|
||||
|
||||
|
||||
def _lookup_bandwidth(gpu_name):
|
||||
def _lookup_apple_bandwidth(system):
|
||||
gpu_name = system.get("gpu_name")
|
||||
if not isinstance(gpu_name, str) or not gpu_name:
|
||||
return None
|
||||
gn = gpu_name.lower()
|
||||
|
||||
# Guard against false matches on non-Apple GPUs whose names contain
|
||||
# "m3"/"m4"/"m5" (e.g. NVIDIA Quadro M4 000).
|
||||
if "apple" not in gn:
|
||||
return None
|
||||
|
||||
raw_cores = system.get("gpu_cores")
|
||||
try:
|
||||
gpu_cores = int(raw_cores) if raw_cores is not None else None
|
||||
except (TypeError, ValueError):
|
||||
gpu_cores = None
|
||||
|
||||
for key in _APPLE_VARIANT_KEYS_SORTED:
|
||||
if key not in gn:
|
||||
continue
|
||||
if gpu_cores in APPLE_BANDWIDTH_BY_CORES[key]:
|
||||
return APPLE_BANDWIDTH_BY_CORES[key][gpu_cores]
|
||||
return min(APPLE_BANDWIDTH_BY_CORES[key].values())
|
||||
|
||||
for key in _APPLE_FIXED_KEYS_SORTED:
|
||||
if key in gn:
|
||||
return APPLE_BANDWIDTH_FIXED[key]
|
||||
return None
|
||||
|
||||
|
||||
def _lookup_bandwidth(system):
|
||||
if isinstance(system, dict):
|
||||
gpu_name = system.get("gpu_name")
|
||||
else:
|
||||
gpu_name = system
|
||||
|
||||
if not isinstance(gpu_name, str) or not gpu_name:
|
||||
return None
|
||||
|
||||
# Apple tiers live only in the Apple-specific table now (#2564), so route
|
||||
# BOTH dict and bare-string callers through it. A bare string carries no
|
||||
# gpu_cores, so the helper falls back to the conservative (lowest) tier for
|
||||
# that model -- before #2564 the generic table answered string lookups, and
|
||||
# dropping that made _lookup_bandwidth("Apple M3 Max") return None.
|
||||
apple_input = system if isinstance(system, dict) else {"gpu_name": gpu_name}
|
||||
bw = _lookup_apple_bandwidth(apple_input)
|
||||
if bw is not None:
|
||||
return bw
|
||||
|
||||
gn = gpu_name.lower()
|
||||
for key in _BW_KEYS_SORTED:
|
||||
if key in gn:
|
||||
return GPU_BANDWIDTH[key]
|
||||
@@ -84,7 +140,7 @@ def _estimate_speed(model, quant, run_mode, system, offload_frac=0.0):
|
||||
"""
|
||||
pb = _active_params_b(model)
|
||||
is_moe = model.get("is_moe", False)
|
||||
bw = _lookup_bandwidth(system.get("gpu_name"))
|
||||
bw = _lookup_bandwidth(system)
|
||||
backend = system.get("backend", "cpu_x86")
|
||||
|
||||
if bw and run_mode in ("gpu", "cpu_offload"):
|
||||
|
||||
+127
-1
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
@@ -335,6 +336,37 @@ def _detect_apple_silicon():
|
||||
if total_gb <= 0:
|
||||
return None
|
||||
|
||||
def _parse_apple_gpu_cores(text):
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except (TypeError, ValueError, json.JSONDecodeError):
|
||||
data = None
|
||||
if isinstance(data, dict):
|
||||
for gpu in data.get("SPDisplaysDataType") or []:
|
||||
if not isinstance(gpu, dict):
|
||||
continue
|
||||
model = str(gpu.get("sppci_model") or gpu.get("_name") or "")
|
||||
if "apple" not in model.lower():
|
||||
continue
|
||||
cores = gpu.get("sppci_cores")
|
||||
try:
|
||||
return int(str(cores).strip())
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
m = re.search(r"Total Number of Cores:\s*(\d+)", text)
|
||||
if m:
|
||||
try:
|
||||
return int(m.group(1))
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
gpu_cores = _parse_apple_gpu_cores(_run(["system_profiler", "SPDisplaysDataType", "-json"]))
|
||||
if gpu_cores is None:
|
||||
gpu_cores = _parse_apple_gpu_cores(_run(["system_profiler", "SPDisplaysDataType"]))
|
||||
|
||||
# Usable GPU budget. macOS lets Metal use most of unified memory, but the
|
||||
# default working-set limit scales with RAM: small machines have to keep
|
||||
# more back for the OS + app. These fractions track Apple's
|
||||
@@ -357,7 +389,7 @@ def _detect_apple_silicon():
|
||||
pass
|
||||
|
||||
gpu = {"index": 0, "name": brand, "vram_gb": vram_gb}
|
||||
return {
|
||||
info = {
|
||||
"gpu_name": brand,
|
||||
"gpu_vram_gb": vram_gb,
|
||||
"gpu_count": 1,
|
||||
@@ -369,6 +401,9 @@ def _detect_apple_silicon():
|
||||
# separate pool — downstream fit logic uses this to avoid double-budgeting.
|
||||
"unified_memory": True,
|
||||
}
|
||||
if gpu_cores is not None:
|
||||
info["gpu_cores"] = gpu_cores
|
||||
return info
|
||||
|
||||
|
||||
def _read_file(path):
|
||||
@@ -611,6 +646,93 @@ def _cache_key(host: str, ssh_port: str, platform_name: str):
|
||||
)
|
||||
|
||||
|
||||
def _is_containerized():
|
||||
"""Best-effort check for whether the local Odysseus process is running in a container."""
|
||||
if _remote_host:
|
||||
return False
|
||||
|
||||
if os.path.exists("/.dockerenv"):
|
||||
return True
|
||||
|
||||
try:
|
||||
with open("/proc/1/cgroup", encoding="utf-8", errors="replace") as f:
|
||||
text = f.read().lower()
|
||||
return any(marker in text for marker in ("docker", "containerd", "kubepods"))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _hardware_visibility_warning(result):
|
||||
"""Return a non-blocking UX warning when detected hardware may only be container-visible."""
|
||||
if not isinstance(result, dict):
|
||||
return None
|
||||
|
||||
if result.get("manual_hardware"):
|
||||
return None
|
||||
|
||||
if not result.get("containerized"):
|
||||
return None
|
||||
|
||||
if result.get("gpu_error"):
|
||||
return None
|
||||
|
||||
if not result.get("has_gpu"):
|
||||
return {
|
||||
"code": "container_no_gpu_visible",
|
||||
"severity": "warning",
|
||||
"title": "No GPU visible inside Docker",
|
||||
"message": (
|
||||
"Cookbook is scanning hardware from inside the Odysseus container. "
|
||||
"If your host has a GPU, Docker may not be exposing it to the container, "
|
||||
"so model recommendations may be CPU-only or too conservative."
|
||||
),
|
||||
"actions": [
|
||||
"manual_hardware",
|
||||
"rescan",
|
||||
"copy_diagnostics",
|
||||
],
|
||||
}
|
||||
|
||||
total_ram = result.get("total_ram_gb") or 0
|
||||
if total_ram and total_ram <= 8:
|
||||
return {
|
||||
"code": "container_low_ram_visible",
|
||||
"severity": "info",
|
||||
"title": "Container-visible RAM may be lower than host RAM",
|
||||
"message": (
|
||||
"Cookbook is seeing the RAM available inside the container. "
|
||||
"If your host has more memory, validate host RAM separately or use Manual Hardware."
|
||||
),
|
||||
"actions": [
|
||||
"manual_hardware",
|
||||
"rescan",
|
||||
"copy_diagnostics",
|
||||
],
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _attach_probe_context(result, host=""):
|
||||
"""Attach probe-scope metadata and optional hardware visibility warning."""
|
||||
if not isinstance(result, dict) or result.get("error"):
|
||||
return result
|
||||
|
||||
is_remote = bool(host)
|
||||
containerized = False if is_remote else _is_containerized()
|
||||
|
||||
result["probe_scope"] = "remote" if is_remote else ("container" if containerized else "native")
|
||||
result["containerized"] = containerized
|
||||
|
||||
warning = _hardware_visibility_warning(result)
|
||||
if warning:
|
||||
result["hardware_visibility_warning"] = warning
|
||||
else:
|
||||
result.pop("hardware_visibility_warning", None)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def detect_system(host="", ssh_port="", platform="", fresh=False):
|
||||
"""Detect system hardware: RAM, CPU, GPU. Cached per host (hardware rarely
|
||||
changes, and probing a remote host over SSH is slow). Pass fresh=True to
|
||||
@@ -635,6 +757,7 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
|
||||
if _remote_platform == "windows" and _remote_host:
|
||||
result = _detect_windows()
|
||||
if result:
|
||||
result = _attach_probe_context(result, host=host)
|
||||
_remote_host = None
|
||||
_remote_platform = None
|
||||
_cache_by_host[cache_key] = (now, result)
|
||||
@@ -653,6 +776,7 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
|
||||
if not _remote_host and os.name == "nt":
|
||||
result = _detect_windows()
|
||||
if result:
|
||||
result = _attach_probe_context(result, host=host)
|
||||
_cache_by_host[cache_key] = (now, result)
|
||||
return result
|
||||
# PowerShell probe failed entirely — fall through to the generic path
|
||||
@@ -683,6 +807,7 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
|
||||
"gpu_name": gpu_info["gpu_name"],
|
||||
"gpu_vram_gb": gpu_info["gpu_vram_gb"],
|
||||
"gpu_count": gpu_info["gpu_count"],
|
||||
"gpu_cores": gpu_info.get("gpu_cores"),
|
||||
"gpus": gpu_info.get("gpus", []),
|
||||
"gpu_groups": gpu_info.get("gpu_groups", []),
|
||||
"homogeneous": gpu_info.get("homogeneous", True),
|
||||
@@ -714,6 +839,7 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
|
||||
"gpu_error": _last_gpu_error,
|
||||
}
|
||||
|
||||
result = _attach_probe_context(result, host=host)
|
||||
_remote_host = None
|
||||
_remote_platform = None
|
||||
_cache_by_host[cache_key] = (now, result)
|
||||
|
||||
@@ -66,41 +66,57 @@ def _has_duplicate_title(skills, title: str) -> bool:
|
||||
def _extract_json_object(text: str) -> Optional[dict]:
|
||||
"""Best-effort extraction of a JSON object from an LLM response.
|
||||
|
||||
The response may be wrapped in code fences or surrounded by prose, and some
|
||||
models emit a stray brace in the prose before the real object
|
||||
(e.g. "uses {placeholder} then {...}"). Slicing first-'{' .. last-'}' then
|
||||
grabs an unparseable span and the skill is silently lost. Try the whole
|
||||
string first, then each '{' start position in turn, returning the first
|
||||
candidate that parses to a JSON object (dict). Returns None if none do.
|
||||
The response may be wrapped in code fences or surrounded by prose. Uses
|
||||
json.JSONDecoder().raw_decode() to locate the boundaries of complete JSON
|
||||
objects starting at each '{' position. Nested objects are filtered out to
|
||||
keep only top-level candidates. If multiple non-overlapping valid JSON
|
||||
objects are found, it is treated as ambiguous and returns None. Otherwise,
|
||||
returns the single valid candidate dictionary.
|
||||
"""
|
||||
if not text:
|
||||
return None
|
||||
s = text.strip()
|
||||
if s.startswith("```"):
|
||||
s = s.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
|
||||
end = s.rfind("}")
|
||||
if end == -1:
|
||||
|
||||
decoder = json.JSONDecoder()
|
||||
candidates = []
|
||||
|
||||
start = s.find("{")
|
||||
while start != -1:
|
||||
try:
|
||||
obj, idx = decoder.raw_decode(s[start:])
|
||||
end_pos = start + idx
|
||||
if isinstance(obj, dict):
|
||||
candidates.append((start, end_pos, obj))
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
start = s.find("{", start + 1)
|
||||
|
||||
# Filter out nested candidates to identify top-level dictionaries
|
||||
top_level = []
|
||||
for c in candidates:
|
||||
is_nested = False
|
||||
for other in candidates:
|
||||
if other == c:
|
||||
continue
|
||||
if other[0] <= c[0] and c[1] <= other[1]:
|
||||
is_nested = True
|
||||
break
|
||||
if not is_nested:
|
||||
top_level.append(c)
|
||||
|
||||
if not top_level:
|
||||
return None
|
||||
|
||||
def _as_dict(candidate):
|
||||
try:
|
||||
obj = json.loads(candidate)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return None
|
||||
return obj if isinstance(obj, dict) else None
|
||||
if len(top_level) > 1:
|
||||
logger.debug(
|
||||
"[skill-extract] Found multiple non-overlapping JSON objects: %s",
|
||||
[item[2].get("title") for item in top_level]
|
||||
)
|
||||
return None
|
||||
|
||||
# The clean, common case: the whole (de-fenced) string is the object.
|
||||
obj = _as_dict(s)
|
||||
if obj is not None:
|
||||
return obj
|
||||
# Otherwise scan each '{' candidate up to the last '}'.
|
||||
start = s.find("{")
|
||||
while 0 <= start < end:
|
||||
obj = _as_dict(s[start : end + 1])
|
||||
if obj is not None:
|
||||
return obj
|
||||
start = s.find("{", start + 1)
|
||||
return None
|
||||
return top_level[0][2]
|
||||
|
||||
|
||||
async def maybe_extract_skill(
|
||||
|
||||
@@ -603,7 +603,6 @@ class SkillsManager:
|
||||
escalation) — those are work-in-progress and pollute the
|
||||
prompt with half-finished procedures.
|
||||
"""
|
||||
active_toolsets = active_toolsets or []
|
||||
out = []
|
||||
for s in self.load(owner=owner):
|
||||
status = s.get("status")
|
||||
@@ -617,13 +616,16 @@ class SkillsManager:
|
||||
# Platform gating
|
||||
if platform and s.get("platforms") and platform not in s["platforms"]:
|
||||
continue
|
||||
# requires_toolsets: hide unless every required toolset is active
|
||||
# requires_toolsets: hide unless every required toolset is active.
|
||||
# active_toolsets=None means the caller doesn't know the active
|
||||
# set (API listings, chat preface) — don't gate in that case;
|
||||
# only an explicit list filters.
|
||||
req = s.get("requires_toolsets") or []
|
||||
if req and not all(t in active_toolsets for t in req):
|
||||
if req and active_toolsets is not None and not all(t in active_toolsets for t in req):
|
||||
continue
|
||||
# fallback_for_toolsets: hide when any of those toolsets is active
|
||||
fb = s.get("fallback_for_toolsets") or []
|
||||
if fb and any(t in active_toolsets for t in fb):
|
||||
if fb and active_toolsets and any(t in active_toolsets for t in fb):
|
||||
continue
|
||||
out.append({
|
||||
"name": s["name"],
|
||||
|
||||
+184
-10
@@ -408,7 +408,7 @@ Generate an image. Line 1 = description, line 2 = model name, line 3 = WxH (e.g.
|
||||
"ask_teacher": "- ```ask_teacher``` — Escalate a hard question to a more capable model. Line 1 = model name or 'auto', rest = the question. Use when stuck or need expert knowledge.",
|
||||
"list_models": "- ```list_models``` — Show all available AI models across all endpoints. Use when user asks what models are available.",
|
||||
"manage_session": "- ```manage_session``` — Rename, archive, delete, fork, switch, or `list` chats (the UI calls them 'chats'; 'session' is internal). Line 1 = action (list/switch/rename/archive/unarchive/delete/important/unimportant/truncate/fork), Line 2 = exact chat id from `list_sessions` (or `current` where supported). For delete/archive/truncate, always list first and reuse the exact id; never invent placeholder ids. `switch`/`open` returns a clickable anchor link the user can tap to open the chat — use for \"open my X chat\".",
|
||||
"manage_memory": "- ```manage_memory``` — Manage the user's persistent memory (facts, identity, preferences, context that persists across chats). Line 1 = action (list/add/edit/delete/search), rest = content. Use when user says 'remember this', states identity facts like 'my name is <name>' / 'call me <name>' / 'I live in <place>', or asks about stored memories.",
|
||||
"manage_memory": "- ```manage_memory``` — Manage the user's persistent memory (facts about the USER themselves, their preferences, context that persists across chats). Line 1 = action (list/add/edit/delete/search), rest = content. Use when user says 'remember this' about themselves, states identity facts like 'my name is <name>' / 'call me <name>' / 'I live in <place>', or asks about stored memories. DO NOT use for info about another person (their address, phone, email, birthday) — that goes in `manage_contact`. If the user pastes an address/phone with a name and says 'save this for <person>', use `manage_contact add` with the address arg, NOT manage_memory.",
|
||||
"manage_skills": "- ```manage_skills``` — Skill registry (SKILL.md format). Args (JSON): {\"action\": \"list|view|view_ref|search|add|edit|patch|publish|delete\", ...}. `list` returns the index of available skills (published + teacher-escalation drafts); `view name=foo` fetches the full SKILL.md; `view_ref name=foo path=...` loads a reference file under the skill directory. For `add`, provide an explicit kebab-case `name` and only report the exact returned name, because storage may normalize or dedupe it. Use this BEFORE doing domain work — there may already be a procedure (published or draft) that prescribes the correct steps. Drafts written by the teacher loop are authoritative guidance even though they're not yet published.",
|
||||
"manage_tasks": "- ```manage_tasks``` — Create and manage scheduled background tasks (recurring AI jobs). Args (JSON): {\"action\": \"list|create|edit|delete|pause|resume|run\", ...}",
|
||||
"manage_endpoints": "- ```manage_endpoints``` — Add, remove, or configure AI model API endpoints. Args (JSON): {\"action\": \"list|add|delete|enable|disable\", ...}. Use when user wants to add a new AI provider.",
|
||||
@@ -428,7 +428,9 @@ Notes, checklists, AND user reminders. Use this for "create/add/write a note", t
|
||||
```send_email
|
||||
{"to": "recipient@example.com", "subject": "Re: Your question", "body": "Hi, ...", "account": "gmail"}
|
||||
```
|
||||
Send a new email via SMTP. Use `resolve_contact` first if you only have a name. If multiple email accounts exist, call `list_email_accounts` first and pass the chosen `account`.""",
|
||||
Send a new email via SMTP. Use `resolve_contact` first if you only have a name. If multiple email accounts exist, call `list_email_accounts` first and pass the chosen `account`.
|
||||
|
||||
CRITICAL — signatures: DO NOT invent a sign-off name. End the body with just `Thanks,` or similar — never type a person's name unless the user explicitly told you what to sign as. When `agent_email_confirm` is on (default), the tool returns `{pending: true, pending_id: ...}` and stages the email for the user to approve in the chat UI instead of SMTPing immediately.""",
|
||||
"list_emails": """\
|
||||
```list_emails
|
||||
{"folder": "INBOX", "max_results": 20, "unread_only": false, "account": "gmail"}
|
||||
@@ -439,7 +441,9 @@ List recent emails from a folder, newest first, including read messages by defau
|
||||
```reply_to_email
|
||||
{"uid": "1234", "body": "Sounds good — talk Friday.", "account": "gmail"}
|
||||
```
|
||||
SEND a reply email immediately by UID. Do not use this for "open a reply" or "start a reply" — those should use `ui_control` with `open_email_reply <uid> <folder> reply` to open the email draft document. For follow-up requests like "reply ..." after reading/listing email where the user clearly wants to send now, use the exact UID and account from the latest `read_email`/`list_emails` result. Never invent UID `1`. Threads automatically (In-Reply-To/References handled).""",
|
||||
SEND a reply email immediately by UID. Do not use this for "open a reply" or "start a reply" — those should use `ui_control` with `open_email_reply <uid> <folder> reply` to open the email draft document. For follow-up requests like "reply ..." after reading/listing email where the user clearly wants to send now, use the exact UID and account from the latest `read_email`/`list_emails` result. Never invent UID `1`. Threads automatically (In-Reply-To/References handled).
|
||||
|
||||
CRITICAL — signatures: DO NOT invent a sign-off name. End the body with just `Thanks,` or similar — never type a person's name unless the user explicitly told you what to sign as. When `agent_email_confirm` is on (default), the tool returns `{pending: true, pending_id: ...}` and stages the email for the user to approve in the chat UI instead of SMTPing immediately.""",
|
||||
"bulk_email": """\
|
||||
```bulk_email
|
||||
{"action": "delete", "uids": ["10997", "10998"], "folder": "INBOX", "account": "Gmail"}
|
||||
@@ -449,7 +453,7 @@ Bulk delete/archive/mark emails. Use this for "delete all those" after listing e
|
||||
"archive_email": "- ```archive_email``` — Archive one email by UID. Args (JSON): {\"uid\":\"...\", \"folder\":\"INBOX\", \"account\":\"Gmail\"}. For multiple messages use bulk_email.",
|
||||
"mark_email_read": "- ```mark_email_read``` — Mark one email read/unread. Args (JSON): {\"uid\":\"...\", \"read\":true, \"folder\":\"INBOX\", \"account\":\"Gmail\"}. For multiple messages use bulk_email.",
|
||||
"resolve_contact": "- ```resolve_contact``` — Look up a contact's email by name. Searches CardDAV address book + sent email history. Args (JSON): {\"name\": \"...\"}. Use BEFORE send_email when the user gives only a name.",
|
||||
"manage_contact": "- ```manage_contact``` — Create/update/delete/list CardDAV contacts. Args (JSON): {\"action\": \"list|add|update|delete\", \"name\": \"...\", \"email\": \"...\", \"uid\": \"...\"}. Use only for explicit address-book/contact requests with contact details. Do NOT use for user identity facts like 'my name is <name>'; save those with manage_memory. For update/delete, call action=list first to get the uid.",
|
||||
"manage_contact": "- ```manage_contact``` — Create/update/delete/list CardDAV contacts. Args (JSON): {\"action\": \"list|add|update|delete\", \"name\": \"...\", \"email\": \"...\", \"phones\": [...], \"address\": \"...\", \"uid\": \"...\"}. Use for info about another person: email, phone, postal address. For 'save this for <person>' / address paste / phone next to a name, use this — NOT manage_memory. Do NOT use for user identity facts ('my name is X'); those are manage_memory. For update/delete, call action=list first for the uid.",
|
||||
"manage_calendar": """\
|
||||
```manage_calendar
|
||||
{"action": "create_event", "summary": "<event title>", "dtstart": "<natural language or ISO datetime>"}
|
||||
@@ -606,7 +610,7 @@ _API_HOSTS = frozenset([
|
||||
"api.deepseek.com", "deepseek.com",
|
||||
"api.together.xyz", "api.fireworks.ai",
|
||||
"api.perplexity.ai", "api.x.ai",
|
||||
"ollama.com", "api.venice.ai",
|
||||
"ollama.com", "api.venice.ai", "api.kimi.com",
|
||||
"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
|
||||
@@ -859,6 +863,7 @@ def _build_system_prompt(
|
||||
compact: bool = False,
|
||||
owner: Optional[str] = None,
|
||||
suppress_local_context: bool = False,
|
||||
active_email: Optional[Dict[str, str]] = None,
|
||||
) -> List[Dict]:
|
||||
"""Build agent system prompt, inject MCP/document context, merge consecutive system msgs."""
|
||||
global _cached_base_prompt, _cached_base_prompt_key
|
||||
@@ -1051,6 +1056,66 @@ def _build_system_prompt(
|
||||
else:
|
||||
set_active_document(None)
|
||||
|
||||
# Active email reader — frontend told us the user has an email open.
|
||||
# Inject a context block so "reply", "summarize this", "what does it say"
|
||||
# resolve to the real UID instead of the agent inventing a fresh .md
|
||||
# draft with fake headers. This is the email equivalent of _doc_message.
|
||||
_email_message = None
|
||||
if active_email and active_email.get("uid"):
|
||||
_em_uid = active_email.get("uid", "")
|
||||
_em_folder = active_email.get("folder", "INBOX")
|
||||
_em_account = active_email.get("account", "")
|
||||
_em_subject = active_email.get("subject", "") or "(no subject)"
|
||||
_em_from = active_email.get("from", "") or "(unknown sender)"
|
||||
_em_preview = (active_email.get("body_preview", "") or "").strip()
|
||||
_preview_block = f"\nBody preview:\n```\n{_em_preview[:1800]}\n```" if _em_preview else ""
|
||||
_acct_arg = f" {_em_account}" if _em_account else ""
|
||||
email_ctx = (
|
||||
f"ACTIVE EMAIL OPEN (the user has this email open in a reader window right now)\n"
|
||||
f"UID: {_em_uid}\n"
|
||||
f"Folder: {_em_folder}\n"
|
||||
f"Account: {_em_account or '(default)'}\n"
|
||||
f"From: {_em_from}\n"
|
||||
f"Subject: {_em_subject}{_preview_block}\n\n"
|
||||
f"CRITICAL DEFAULT — every request about email this turn refers to "
|
||||
f"THIS email unless the user names a DIFFERENT specific recipient "
|
||||
f"(a name, an email address, or another thread). Examples that "
|
||||
f"ALL mean reply-to-the-open-email:\n"
|
||||
f" • 'reply' / 'reply to this' / 'respond'\n"
|
||||
f" • 'write email saying X' / 'send email saying X' / 'draft something'\n"
|
||||
f" • 'tell them X' / 'say hi' / 'thanks' / 'ack' / 'lmk'\n"
|
||||
f" • 'summarize it' / 'what does it say' / 'tldr'\n"
|
||||
f" • 'forward this' / 'forward to <addr>'\n"
|
||||
f"DO NOT ASK THE USER 'who do you want to send this to?' — the "
|
||||
f"answer is ALWAYS the sender of the open email (above) unless they "
|
||||
f"named someone else. Asking that is the wrong move every time.\n\n"
|
||||
f"RULES for the open email:\n"
|
||||
f"1. DRAFT a reply (default for any 'write/send/reply/tell them' "
|
||||
f"request without a different recipient): call `ui_control` with "
|
||||
f"`action=\"open_email_reply\"` and `extra=\"{_em_uid} {_em_folder} "
|
||||
f"reply\"`. This opens the proper reply doc with To/Subject/"
|
||||
f"In-Reply-To pre-filled by the backend. The user will see and edit "
|
||||
f"it before sending. DO NOT `create_document` a markdown file with "
|
||||
f"hand-written `To:` / `Subject:` / `In-Reply-To:` headers — that "
|
||||
f"is wrong every time.\n"
|
||||
f"2. SEND a reply immediately (skip the draft): call "
|
||||
f"`reply_to_email` with the UID above. Only do this when the user "
|
||||
f"explicitly says 'send' / 'send the reply' / 'reply and send'.\n"
|
||||
f"3. READ the full body (the preview above may be truncated): "
|
||||
f"call `read_email` with the UID/folder/account above.\n"
|
||||
f"4. SUMMARIZE / answer questions about it: read it first, then "
|
||||
f"answer in chat. Don't create a document for a summary unless "
|
||||
f"the user explicitly asks for one.\n"
|
||||
f"5. Never ask the user to paste the email or 'share it with you' "
|
||||
f"— you already have its identity above and can read the full body.\n"
|
||||
f"6. The ONLY time you ask 'who to send to?' is when the user "
|
||||
f"explicitly says 'send a NEW email to someone else' or names a "
|
||||
f"recipient you can't identify. A bare 'send email saying X' = the "
|
||||
f"open email's sender.\n"
|
||||
)
|
||||
_email_message = untrusted_context_message("active email reader", email_ctx)
|
||||
_email_message["_protected"] = True
|
||||
|
||||
# Inject writing style for any email writing path. This is deliberately
|
||||
# broader than read/list: models may compose via send_email, reply_to_email,
|
||||
# or ui_control open_email_reply after the first tool round.
|
||||
@@ -1258,6 +1323,9 @@ def _build_system_prompt(
|
||||
if _doc_message:
|
||||
merged.insert(last_user_idx, _doc_message)
|
||||
last_user_idx += 1 # the document message is now at last_user_idx
|
||||
if _email_message:
|
||||
merged.insert(last_user_idx, _email_message)
|
||||
last_user_idx += 1
|
||||
if _skills_message:
|
||||
merged.insert(last_user_idx, _skills_message)
|
||||
last_user_idx += 1
|
||||
@@ -1292,12 +1360,18 @@ def _build_base_prompt(
|
||||
from src.tool_index import ALWAYS_AVAILABLE
|
||||
|
||||
disabled = set(disabled_tools or [])
|
||||
if not get_setting("image_gen_enabled", True):
|
||||
if not get_setting("image_gen_enabled", False):
|
||||
disabled.add("generate_image")
|
||||
|
||||
if relevant_tools is not None:
|
||||
# RAG mode: include always-available + retrieved + admin (if needed)
|
||||
tool_names = set(ALWAYS_AVAILABLE) | set(relevant_tools)
|
||||
# RAG mode: trust the relevant_tools set as already-composed.
|
||||
# get_tools_for_query starts from ALWAYS_AVAILABLE and may
|
||||
# *discard* tools that conflict with the query's intent (e.g.
|
||||
# drop manage_memory for clear contact-save patterns). Unioning
|
||||
# ALWAYS_AVAILABLE back in here used to silently undo those
|
||||
# drops. Only force-include the irreducible loop primitives
|
||||
# (ask_user, update_plan) as belt-and-suspenders.
|
||||
tool_names = set(relevant_tools) | {"ask_user", "update_plan"}
|
||||
if needs_admin:
|
||||
tool_names |= _ADMIN_TOOLS
|
||||
agent_prompt = _assemble_prompt(tool_names, disabled, compact=compact)
|
||||
@@ -1738,6 +1812,7 @@ async def stream_agent_loop(
|
||||
max_tool_calls: int = 0,
|
||||
context_length: int = 0,
|
||||
active_document=None,
|
||||
active_email: Optional[Dict[str, str]] = None,
|
||||
session_id: Optional[str] = None,
|
||||
disabled_tools: Optional[Set[str]] = None,
|
||||
owner: Optional[str] = None,
|
||||
@@ -1904,6 +1979,44 @@ async def stream_agent_loop(
|
||||
if _relevant_tools is not None and active_document is not None:
|
||||
_relevant_tools.update({"edit_document", "update_document", "suggest_document"})
|
||||
|
||||
# The skill index injected by _build_system_prompt tells the model to
|
||||
# call `manage_skills action=view`, and Jaccard-matched skills are pasted
|
||||
# into the prompt as procedures to follow — but neither path goes through
|
||||
# tool selection, so the model can be handed a procedure naming tools
|
||||
# (grep, read_file, ...) that aren't in its schema list. Keep the schemas
|
||||
# in lockstep: manage_skills is callable whenever any skill is indexed,
|
||||
# and a matched skill's declared requires_toolsets ride along with it.
|
||||
if not guide_only and _relevant_tools is not None:
|
||||
try:
|
||||
from services.memory.skills import SkillsManager
|
||||
from src.constants import DATA_DIR
|
||||
_skills_on = True
|
||||
try:
|
||||
from routes.prefs_routes import _load_for_user as _load_prefs
|
||||
_skills_on = (_load_prefs(owner) or {}).get("skills_enabled", True)
|
||||
except Exception:
|
||||
pass
|
||||
_sm = SkillsManager(DATA_DIR)
|
||||
_owner_skills = _sm.load(owner=owner) if _skills_on else []
|
||||
if _owner_skills:
|
||||
_relevant_tools.add("manage_skills")
|
||||
if _retrieval_query:
|
||||
# Validate against every known executable tool, not just
|
||||
# TOOL_SECTIONS — code-nav tools (grep/glob/ls) ship as
|
||||
# schemas without a prompt-prose section.
|
||||
from src.tool_policy import known_tool_names
|
||||
_known = known_tool_names()
|
||||
for _sk in _sm.get_relevant_skills(
|
||||
_retrieval_query, skills=_owner_skills,
|
||||
threshold=0.25, max_items=3,
|
||||
):
|
||||
_relevant_tools.update(
|
||||
t for t in (_sk.get("requires_toolsets") or [])
|
||||
if t in _known
|
||||
)
|
||||
except Exception as _e:
|
||||
logger.debug(f"[tool-rag] skill-aware tool include skipped: {_e}")
|
||||
|
||||
if _relevant_tools is not None:
|
||||
logger.info("[agent-intent] selected_tools=%s", sorted(_relevant_tools)[:50])
|
||||
|
||||
@@ -1987,6 +2100,7 @@ async def stream_agent_loop(
|
||||
compact=_is_api_model,
|
||||
owner=owner,
|
||||
suppress_local_context=guide_only,
|
||||
active_email=active_email,
|
||||
)
|
||||
if plan_mode and not guide_only:
|
||||
# Steer the model to investigate-then-propose. Hard tool gating handles
|
||||
@@ -2167,9 +2281,17 @@ async def stream_agent_loop(
|
||||
elif _is_api_model:
|
||||
# Filter schemas by RAG-selected tools (if available)
|
||||
if _relevant_tools:
|
||||
# _build_base_prompt unions _ADMIN_TOOLS into the prompt
|
||||
# sections when admin intent fires — the schema list must
|
||||
# offer the same names, or the model reads prose describing
|
||||
# tools it cannot call and substitutes the nearest schema
|
||||
# it does have (e.g. manage_memory for manage_skills).
|
||||
_schema_names = set(_relevant_tools)
|
||||
if _needs_admin:
|
||||
_schema_names |= _ADMIN_TOOLS
|
||||
base_schemas = [
|
||||
s for s in FUNCTION_TOOL_SCHEMAS
|
||||
if s.get("function", {}).get("name") in _relevant_tools
|
||||
if s.get("function", {}).get("name") in _schema_names
|
||||
]
|
||||
_mcp_filtered = [
|
||||
s for s in mcp_schemas
|
||||
@@ -2705,6 +2827,46 @@ async def stream_agent_loop(
|
||||
)
|
||||
desc, result = await _tool_task
|
||||
|
||||
# A skill the model just loaded can prescribe tools that weren't
|
||||
# RAG-selected this turn (declared via requires_toolsets in its
|
||||
# frontmatter). Union them into the selection so the NEXT round's
|
||||
# schema list includes them — otherwise the model reads "use
|
||||
# grep" from the skill it fetched but has no grep schema to call.
|
||||
if (
|
||||
block.tool_type == "manage_skills"
|
||||
and _relevant_tools is not None
|
||||
and not result.get("error")
|
||||
):
|
||||
_ms_args = {}
|
||||
_ms_raw = (block.content or "").strip()
|
||||
if _ms_raw.startswith("{"):
|
||||
try:
|
||||
_ms_args = json.loads(_ms_raw)
|
||||
except json.JSONDecodeError:
|
||||
_ms_args = {}
|
||||
_ms_name = str(_ms_args.get("name", "") or "").strip()
|
||||
if _ms_name and _ms_args.get("action") in ("view", "view_ref"):
|
||||
try:
|
||||
from services.memory.skills import SkillsManager as _SkM
|
||||
from src.constants import DATA_DIR as _DD
|
||||
from src.tool_policy import known_tool_names as _ktn
|
||||
_known = _ktn()
|
||||
for _sk in _SkM(_DD).load(owner=owner):
|
||||
if _sk.get("name") == _ms_name:
|
||||
_new = {
|
||||
t for t in (_sk.get("requires_toolsets") or [])
|
||||
if t in _known and t not in _relevant_tools
|
||||
}
|
||||
if _new:
|
||||
_relevant_tools.update(_new)
|
||||
logger.info(
|
||||
"[tool-rag] skill '%s' unlocked tools for next round: %s",
|
||||
_ms_name, sorted(_new),
|
||||
)
|
||||
break
|
||||
except Exception as _e:
|
||||
logger.debug(f"skill requires_toolsets unlock skipped: {_e}")
|
||||
|
||||
# Extract structured web sources from web_search tool output.
|
||||
# web_search returns {"output": ..., "exit_code": 0}; check "output"
|
||||
# first so the <!-- SOURCES:…--> marker is found and stripped even
|
||||
@@ -2824,7 +2986,19 @@ async def stream_agent_loop(
|
||||
tool_output_data = {"type": "tool_output", "tool": block.tool_type, "command": cmd_display, "output": output_text, "exit_code": result.get("exit_code")}
|
||||
if "ui_event" in result:
|
||||
tool_output_data["ui_event"] = result["ui_event"]
|
||||
for k in ("toggle_name", "state", "mode", "model", "endpoint_url", "theme_name", "colors"):
|
||||
for k in (
|
||||
"toggle_name", "state", "mode", "model", "endpoint_url",
|
||||
"theme_name", "colors",
|
||||
# ui_control open_email_reply payload — without these the
|
||||
# frontend openReplyDraft bails on undefined uid and the
|
||||
# reply window silently never opens.
|
||||
"uid", "folder", "account_id",
|
||||
# Optional pre-filled body for open_email_reply so the
|
||||
# agent can compose-and-open in one tool call.
|
||||
"body",
|
||||
# ui_control open_panel payload
|
||||
"panel",
|
||||
):
|
||||
if k in result:
|
||||
tool_output_data[k] = result[k]
|
||||
# Forward image data from generate_image tool
|
||||
|
||||
+55
-14
@@ -972,16 +972,15 @@ async def do_manage_memory(content: str, session_id: Optional[str] = None, owner
|
||||
memories = [m for m in memories if m.get("category", "").lower() == category_filter]
|
||||
if not memories:
|
||||
return {"results": "No memories found" + (f" in category '{category_filter}'" if category_filter else "") + "."}
|
||||
|
||||
result_lines = [f"Found {len(memories)} memory entries:\n"]
|
||||
for m in memories[:100]:
|
||||
for m in memories:
|
||||
cat = m.get("category", "fact")
|
||||
mid = m.get("id", "?")[:8]
|
||||
text = m.get("text", "")
|
||||
if len(text) > 150:
|
||||
text = text[:150] + "..."
|
||||
result_lines.append(f"- [{cat}] `{mid}` — {text}")
|
||||
if len(memories) > 100:
|
||||
result_lines.append(f"... and {len(memories) - 100} more")
|
||||
return {"results": "\n".join(result_lines)}
|
||||
|
||||
elif action == "add":
|
||||
@@ -1293,7 +1292,7 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O
|
||||
set_theme <preset> — Apply a built-in theme preset (dark, light, midnight, paper, cyberpunk, retrowave, forest, ocean, ume, copper, terminal, organs, lavender, gpt, claude, cute)
|
||||
create_theme <name> <bg> <fg> <panel> <border> <accent> [key=val ...] — Create custom theme. Optional key=val: advanced color overrides AND background effects: bgPattern=<none|dots|synapse|rain|constellations|perlin-flow|petals|sparkles|embers>, bgEffectColor=#RRGGBB, bgEffectIntensity=<num>, bgEffectSize=<num>, frosted=true|false
|
||||
open_panel <name> — Open a panel (documents, gallery, email, sessions, notes, memories, skills, settings, cookbook)
|
||||
open_email_reply <uid> [folder] [reply|reply-all|ai-reply] — Open a reply draft document for an email; does not send
|
||||
open_email_reply <uid> [folder] [reply|reply-all|ai-reply] [body text] — Open a reply draft document for an email; does not send. ALWAYS append the body text when the user told you what to say (one-shot draft); only omit body when the user just asked to "open a reply" without content.
|
||||
get_toggles — Return current toggle states (server-side knowledge)
|
||||
"""
|
||||
lines = content.strip().split("\n")
|
||||
@@ -1537,21 +1536,54 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O
|
||||
}
|
||||
|
||||
elif action == "open_email_reply":
|
||||
reply_parts = lines[0].strip().split()
|
||||
uid = reply_parts[1].strip() if len(reply_parts) > 1 else ""
|
||||
folder = reply_parts[2].strip() if len(reply_parts) > 2 else "INBOX"
|
||||
mode = reply_parts[3].strip().lower() if len(reply_parts) > 3 else "reply"
|
||||
# Two forms supported:
|
||||
# open_email_reply <uid> [folder] [reply|reply-all|ai-reply]
|
||||
# open_email_reply <uid> [folder] [reply|reply-all|ai-reply]
|
||||
# <body text on subsequent lines or after the mode token>
|
||||
# The body text (if any) gets pre-filled into the reply draft so the
|
||||
# agent can compose-and-open in one tool call instead of opening an
|
||||
# empty draft and leaving the user to wonder what happened.
|
||||
first_line = lines[0].strip()
|
||||
parts = first_line.split(maxsplit=4)
|
||||
uid = parts[1].strip() if len(parts) > 1 else ""
|
||||
folder = parts[2].strip() if len(parts) > 2 else "INBOX"
|
||||
mode = parts[3].strip().lower() if len(parts) > 3 else "reply"
|
||||
# Body: everything on the first line after the mode token, plus any
|
||||
# subsequent lines. Allows multi-line bodies.
|
||||
inline_body = parts[4] if len(parts) > 4 else ""
|
||||
rest_lines = "\n".join(lines[1:]).strip() if len(lines) > 1 else ""
|
||||
body = (inline_body + ("\n" + rest_lines if rest_lines else "")).strip()
|
||||
if not uid:
|
||||
return {"error": "open_email_reply needs: open_email_reply <uid> [folder] [reply|reply-all|ai-reply]"}
|
||||
return {"error": "open_email_reply needs: open_email_reply <uid> [folder] [reply|reply-all|ai-reply] [body text]"}
|
||||
if mode not in ("reply", "reply-all", "ai-reply"):
|
||||
mode = "reply"
|
||||
return {
|
||||
# Body is REQUIRED for the agent path. Opening an empty draft is what
|
||||
# users do by clicking the Reply button — they don't ask the agent
|
||||
# for that. Every agent invocation of open_email_reply MUST include
|
||||
# the body. Reject empty so the agent retries with the content the
|
||||
# user asked for. Exception: ai-reply mode triggers the existing
|
||||
# AI-Reply path on the frontend which generates its own body.
|
||||
if not body and mode != "ai-reply":
|
||||
return {
|
||||
"error": (
|
||||
"open_email_reply called without body. The agent path REQUIRES a body — "
|
||||
"opening an empty draft is the wrong response when the user asked you to write. "
|
||||
"Re-call with the reply text included: "
|
||||
f"`open_email_reply {uid} {folder or 'INBOX'} {mode} <your reply text here>`. "
|
||||
"Compose the reply now based on the open email's content and the user's request, "
|
||||
"then call this tool again with the body. Do NOT call create_document instead."
|
||||
),
|
||||
}
|
||||
result = {
|
||||
"ui_event": "open_email_reply",
|
||||
"uid": uid,
|
||||
"folder": folder or "INBOX",
|
||||
"mode": mode,
|
||||
"results": f"Opening reply draft for email UID {uid}",
|
||||
"results": f"Opening reply draft for email UID {uid}" + (" with pre-filled body" if body else ""),
|
||||
}
|
||||
if body:
|
||||
result["body"] = body
|
||||
return result
|
||||
|
||||
elif action == "get_toggles":
|
||||
return {
|
||||
@@ -1581,7 +1613,9 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
||||
"""
|
||||
import base64
|
||||
import httpx
|
||||
import os
|
||||
from pathlib import Path
|
||||
from src.url_safety import check_outbound_url
|
||||
|
||||
lines = content.strip().split("\n")
|
||||
prompt = lines[0].strip() if lines else ""
|
||||
@@ -1747,8 +1781,15 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
||||
|
||||
elif img.get("url"):
|
||||
# Download external URL and save locally (DALL-E returns temp URLs)
|
||||
result_url = img["url"]
|
||||
ok, reason = check_outbound_url(
|
||||
result_url,
|
||||
block_private=os.getenv("IMAGE_BLOCK_PRIVATE_IPS", "false").lower() == "true",
|
||||
)
|
||||
if not ok:
|
||||
return {"error": f"Image API returned unsafe image URL: {reason}"}
|
||||
try:
|
||||
dl_resp = httpx.get(img["url"], timeout=60)
|
||||
dl_resp = httpx.get(result_url, timeout=60)
|
||||
if dl_resp.status_code == 200:
|
||||
img_dir = Path(GENERATED_IMAGES_DIR)
|
||||
img_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -1758,10 +1799,10 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
||||
image_url = f"/api/generated-image/{filename}"
|
||||
image_id = _save_to_gallery(filename)
|
||||
else:
|
||||
image_url = img["url"] # fallback to external URL
|
||||
image_url = result_url # fallback to external URL
|
||||
except Exception as _dl_e:
|
||||
logger.warning(f"Failed to download DALL-E image: {_dl_e}")
|
||||
image_url = img["url"] # fallback to external URL
|
||||
image_url = result_url # fallback to external URL
|
||||
else:
|
||||
return {"error": "Image API returned unexpected format (no b64_json or url)"}
|
||||
|
||||
|
||||
+178
-1
@@ -128,6 +128,17 @@ def validate_caldav_url(raw_url: str) -> str:
|
||||
return urlunparse(parsed._replace(fragment="")).rstrip("/")
|
||||
|
||||
|
||||
def _event_etag(obj) -> str:
|
||||
"""Best-effort ETag extraction from python-caldav resources."""
|
||||
try:
|
||||
etag = getattr(obj, "etag", None)
|
||||
if callable(etag):
|
||||
etag = etag()
|
||||
return str(etag or "")
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _stable_cal_id(remote_url: str, owner: str = "", account_id: str = "") -> str:
|
||||
"""Deterministic local id for a remote CalDAV calendar, scoped to owner
|
||||
and account so two users — or one user with two accounts — pointing at
|
||||
@@ -316,11 +327,12 @@ def _sync_blocking(owner: str, url: str, username: str, password: str, account_i
|
||||
color="#5b8abf",
|
||||
source="caldav",
|
||||
account_id=account_id or None,
|
||||
caldav_base_url=remote_url,
|
||||
)
|
||||
db.add(local_cal)
|
||||
db.commit()
|
||||
else:
|
||||
# Refresh display name and stamp account_id if missing.
|
||||
# Refresh display name and stamp CalDAV metadata if missing.
|
||||
changed = False
|
||||
if local_cal.name != display_name:
|
||||
local_cal.name = display_name
|
||||
@@ -328,6 +340,9 @@ def _sync_blocking(owner: str, url: str, username: str, password: str, account_i
|
||||
if account_id and not local_cal.account_id:
|
||||
local_cal.account_id = account_id
|
||||
changed = True
|
||||
if local_cal.caldav_base_url != remote_url:
|
||||
local_cal.caldav_base_url = remote_url
|
||||
changed = True
|
||||
if changed:
|
||||
db.commit()
|
||||
result["calendars"] += 1
|
||||
@@ -395,6 +410,9 @@ def _sync_blocking(owner: str, url: str, username: str, password: str, account_i
|
||||
|
||||
existing = _find_existing_event(db, pending, uid_val, local_cal.id)
|
||||
if existing:
|
||||
if existing.caldav_sync_pending in {"create", "update"}:
|
||||
result["events"] += 1
|
||||
continue
|
||||
existing.calendar_id = local_cal.id
|
||||
existing.summary = summary
|
||||
existing.description = description
|
||||
@@ -405,6 +423,9 @@ def _sync_blocking(owner: str, url: str, username: str, password: str, account_i
|
||||
existing.is_utc = row_is_utc
|
||||
existing.rrule = rrule
|
||||
existing.origin = "caldav"
|
||||
existing.remote_href = str(getattr(obj, "url", "") or "") or None
|
||||
existing.remote_etag = _event_etag(obj) or None
|
||||
existing.caldav_sync_pending = None
|
||||
else:
|
||||
new_ev = CalendarEvent(
|
||||
uid=uid_val,
|
||||
@@ -418,6 +439,8 @@ def _sync_blocking(owner: str, url: str, username: str, password: str, account_i
|
||||
is_utc=row_is_utc,
|
||||
rrule=rrule,
|
||||
origin="caldav",
|
||||
remote_href=str(getattr(obj, "url", "") or "") or None,
|
||||
remote_etag=_event_etag(obj) or None,
|
||||
)
|
||||
db.add(new_ev)
|
||||
pending[uid_val] = new_ev
|
||||
@@ -442,6 +465,8 @@ def _sync_blocking(owner: str, url: str, username: str, password: str, account_i
|
||||
CalendarEvent.origin == "caldav",
|
||||
CalendarEvent.dtstart >= start,
|
||||
CalendarEvent.dtstart <= end,
|
||||
CalendarEvent.remote_href.isnot(None),
|
||||
CalendarEvent.caldav_sync_pending.is_(None),
|
||||
~CalendarEvent.uid.in_(seen_uids) if seen_uids else CalendarEvent.uid.isnot(None),
|
||||
).all()
|
||||
for ev in stale:
|
||||
@@ -458,6 +483,92 @@ def _sync_blocking(owner: str, url: str, username: str, password: str, account_i
|
||||
return result
|
||||
|
||||
|
||||
def _event_payload(ev) -> dict:
|
||||
return {
|
||||
"uid": ev.uid,
|
||||
"summary": ev.summary,
|
||||
"description": ev.description,
|
||||
"location": ev.location,
|
||||
"dtstart": ev.dtstart,
|
||||
"dtend": ev.dtend,
|
||||
"all_day": ev.all_day,
|
||||
"is_utc": ev.is_utc,
|
||||
"rrule": ev.rrule or "",
|
||||
}
|
||||
|
||||
|
||||
def _load_event_for_writeback(owner: str, uid: str) -> tuple[str, str, dict] | None:
|
||||
from core.database import CalendarCal, CalendarEvent, SessionLocal
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ev = (
|
||||
db.query(CalendarEvent)
|
||||
.join(CalendarCal)
|
||||
.filter(CalendarEvent.uid == uid, CalendarCal.owner == owner)
|
||||
.first()
|
||||
)
|
||||
if not ev or not ev.calendar or ev.calendar.source != "caldav":
|
||||
return None
|
||||
return ev.calendar.source, ev.calendar.id, _event_payload(ev)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _load_delete_for_writeback(owner: str, uid: str) -> tuple[str, str, dict] | None:
|
||||
from core.database import CalendarCal, CalendarDeletedEvent, CalendarEvent, SessionLocal
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
tombstone = db.query(CalendarDeletedEvent).filter(
|
||||
CalendarDeletedEvent.uid == uid,
|
||||
CalendarDeletedEvent.owner == owner,
|
||||
).first()
|
||||
if tombstone:
|
||||
return "caldav", tombstone.calendar_id, {"uid": uid}
|
||||
|
||||
ev = (
|
||||
db.query(CalendarEvent)
|
||||
.join(CalendarCal)
|
||||
.filter(CalendarEvent.uid == uid, CalendarCal.owner == owner)
|
||||
.first()
|
||||
)
|
||||
if not ev or not ev.calendar or ev.calendar.source != "caldav":
|
||||
return None
|
||||
return ev.calendar.source, ev.calendar.id, {"uid": uid}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _pending_writeback_uids(owner: str) -> tuple[list[str], list[str]]:
|
||||
from core.database import CalendarCal, CalendarDeletedEvent, CalendarEvent, SessionLocal
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
rows = (
|
||||
db.query(CalendarEvent.uid)
|
||||
.join(CalendarCal)
|
||||
.filter(
|
||||
CalendarCal.owner == owner,
|
||||
CalendarCal.source == "caldav",
|
||||
CalendarEvent.status != "cancelled",
|
||||
(
|
||||
(CalendarEvent.caldav_sync_pending.isnot(None))
|
||||
| (CalendarEvent.remote_href.is_(None))
|
||||
),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
delete_rows = (
|
||||
db.query(CalendarDeletedEvent.uid)
|
||||
.filter(CalendarDeletedEvent.owner == owner)
|
||||
.all()
|
||||
)
|
||||
return [row[0] for row in rows], [row[0] for row in delete_rows]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _load_caldav_accounts(owner: str) -> list:
|
||||
"""Return the list of CalDAV accounts for *owner*, auto-migrating the legacy
|
||||
single-account ``caldav`` key to the new ``caldav_accounts`` list on first call.
|
||||
@@ -533,3 +644,69 @@ async def sync_caldav(owner: str) -> dict:
|
||||
for err in result.get("errors", []):
|
||||
totals["errors"].append(f"{label}: {err}")
|
||||
return totals
|
||||
|
||||
|
||||
async def push_event_create(owner: str, uid: str) -> dict:
|
||||
loaded = _load_event_for_writeback(owner, uid)
|
||||
if not loaded:
|
||||
return {"ok": True, "skipped": True}
|
||||
source, calendar_id, payload = loaded
|
||||
from src.caldav_writeback import writeback_event
|
||||
return await writeback_event(owner, source, calendar_id, payload)
|
||||
|
||||
|
||||
async def push_event_update(owner: str, uid: str) -> dict:
|
||||
return await push_event_create(owner, uid)
|
||||
|
||||
|
||||
async def push_event_delete(owner: str, uid: str) -> dict:
|
||||
loaded = _load_delete_for_writeback(owner, uid)
|
||||
if not loaded:
|
||||
return {"ok": True, "skipped": True}
|
||||
source, calendar_id, payload = loaded
|
||||
from src.caldav_writeback import writeback_event
|
||||
return await writeback_event(owner, source, calendar_id, payload, delete=True)
|
||||
|
||||
|
||||
async def push_pending_events(owner: str) -> dict:
|
||||
result = {"events": 0, "errors": []}
|
||||
uids, delete_uids = _pending_writeback_uids(owner)
|
||||
for event_uid in uids:
|
||||
try:
|
||||
out = await push_event_update(owner, event_uid)
|
||||
if out.get("ok"):
|
||||
result["events"] += 1
|
||||
elif not out.get("skipped"):
|
||||
result["errors"].append(f"{event_uid}: {str(out.get('error') or out)[:160]}")
|
||||
except Exception as e:
|
||||
logger.warning("CalDAV pending push failed for uid=%s: %s", event_uid, e)
|
||||
result["errors"].append(f"{event_uid}: {str(e)[:160]}")
|
||||
for event_uid in delete_uids:
|
||||
try:
|
||||
out = await push_event_delete(owner, event_uid)
|
||||
if out.get("ok"):
|
||||
result["events"] += 1
|
||||
elif not out.get("skipped"):
|
||||
result["errors"].append(f"{event_uid}: {str(out.get('error') or out)[:160]}")
|
||||
except Exception as e:
|
||||
logger.warning("CalDAV pending delete failed for uid=%s: %s", event_uid, e)
|
||||
result["errors"].append(f"{event_uid}: {str(e)[:160]}")
|
||||
return result
|
||||
|
||||
|
||||
async def sync_caldav_direction(owner: str, direction: str = "pull") -> dict:
|
||||
direction = (direction or "pull").strip().lower()
|
||||
if direction == "pull":
|
||||
return await sync_caldav(owner)
|
||||
if direction == "push":
|
||||
return await push_pending_events(owner)
|
||||
if direction == "both":
|
||||
pushed = await push_pending_events(owner)
|
||||
pulled = await sync_caldav(owner)
|
||||
return {"push": pushed, "pull": pulled}
|
||||
return {
|
||||
"calendars": 0,
|
||||
"events": 0,
|
||||
"deleted": 0,
|
||||
"errors": [f"Unsupported CalDAV sync direction: {direction}"],
|
||||
}
|
||||
|
||||
+92
-6
@@ -89,6 +89,23 @@ def find_remote_calendar(calendars, local_cal_id: str, owner: str = "", account_
|
||||
return None
|
||||
|
||||
|
||||
def _resource_href(obj) -> str:
|
||||
try:
|
||||
return str(getattr(obj, "url", "") or "")
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _resource_etag(obj) -> str:
|
||||
try:
|
||||
etag = getattr(obj, "etag", None)
|
||||
if callable(etag):
|
||||
etag = etag()
|
||||
return str(etag or "")
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def push_event(calendars, local_cal_id: str, ev: dict, *, delete: bool = False,
|
||||
owner: str = "", account_id: str = "") -> dict:
|
||||
"""Create/update (or delete) ``ev`` on the matching remote calendar.
|
||||
@@ -105,6 +122,7 @@ def push_event(calendars, local_cal_id: str, ev: dict, *, delete: bool = False,
|
||||
remote = find_remote_calendar(calendars, local_cal_id, owner=owner, account_id=account_id)
|
||||
if remote is None:
|
||||
return {"ok": False, "error": "remote calendar not found"}
|
||||
remote_url = str(getattr(remote, "url", "") or "")
|
||||
|
||||
try:
|
||||
existing = remote.event_by_uid(uid)
|
||||
@@ -113,17 +131,34 @@ def push_event(calendars, local_cal_id: str, ev: dict, *, delete: bool = False,
|
||||
|
||||
if delete:
|
||||
if existing is None:
|
||||
return {"ok": True, "note": "already absent on remote"}
|
||||
return {"ok": True, "note": "already absent on remote", "calendar_url": remote_url}
|
||||
existing.delete()
|
||||
return {"ok": True}
|
||||
return {
|
||||
"ok": True,
|
||||
"calendar_url": remote_url,
|
||||
"remote_href": _resource_href(existing),
|
||||
"remote_etag": _resource_etag(existing),
|
||||
}
|
||||
|
||||
ical = build_event_ical(ev)
|
||||
if existing is not None:
|
||||
existing.data = ical
|
||||
existing.save()
|
||||
return {"ok": True, "updated": True}
|
||||
remote.save_event(ical)
|
||||
return {"ok": True, "created": True}
|
||||
return {
|
||||
"ok": True,
|
||||
"updated": True,
|
||||
"calendar_url": remote_url,
|
||||
"remote_href": _resource_href(existing),
|
||||
"remote_etag": _resource_etag(existing),
|
||||
}
|
||||
created = remote.save_event(ical)
|
||||
return {
|
||||
"ok": True,
|
||||
"created": True,
|
||||
"calendar_url": remote_url,
|
||||
"remote_href": _resource_href(created),
|
||||
"remote_etag": _resource_etag(created),
|
||||
}
|
||||
|
||||
|
||||
def _discover_calendars(client):
|
||||
@@ -154,6 +189,54 @@ def _writeback_blocking(local_cal_id, ev, delete, url, username, password,
|
||||
owner=owner, account_id=account_id)
|
||||
|
||||
|
||||
def _persist_writeback_result(owner: str, calendar_id: str, uid: str, result: dict, *, delete: bool) -> None:
|
||||
from core.database import CalendarCal, CalendarDeletedEvent, CalendarEvent, SessionLocal
|
||||
|
||||
if not uid or not isinstance(result, dict):
|
||||
return
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
calendar = db.query(CalendarCal).filter(
|
||||
CalendarCal.id == calendar_id,
|
||||
CalendarCal.owner == owner,
|
||||
).first()
|
||||
if calendar and result.get("calendar_url"):
|
||||
calendar.caldav_base_url = result.get("calendar_url")
|
||||
|
||||
if delete:
|
||||
tombstone = db.query(CalendarDeletedEvent).filter(
|
||||
CalendarDeletedEvent.uid == uid,
|
||||
CalendarDeletedEvent.owner == owner,
|
||||
).first()
|
||||
if result.get("ok"):
|
||||
if tombstone:
|
||||
db.delete(tombstone)
|
||||
elif tombstone:
|
||||
tombstone.last_error = str(result.get("error") or result)[:500]
|
||||
db.commit()
|
||||
return
|
||||
|
||||
event = (
|
||||
db.query(CalendarEvent)
|
||||
.join(CalendarCal)
|
||||
.filter(CalendarEvent.uid == uid, CalendarCal.owner == owner)
|
||||
.first()
|
||||
)
|
||||
if event and result.get("ok"):
|
||||
if result.get("remote_href"):
|
||||
event.remote_href = result.get("remote_href")
|
||||
if result.get("remote_etag"):
|
||||
event.remote_etag = result.get("remote_etag")
|
||||
event.caldav_sync_pending = None
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
logger.exception("CalDAV write-back metadata persistence failed")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
async def writeback_event(owner: str, calendar_source: str, calendar_id: str,
|
||||
ev: dict, *, delete: bool = False) -> dict:
|
||||
"""Best-effort push of a local change to the remote CalDAV server.
|
||||
@@ -204,9 +287,12 @@ async def writeback_event(owner: str, calendar_source: str, calendar_id: str,
|
||||
result = await asyncio.to_thread(
|
||||
_writeback_blocking, calendar_id, ev, delete, url, user, pw, owner, acc_id
|
||||
)
|
||||
_persist_writeback_result(owner, calendar_id, (ev or {}).get("uid", ""), result, delete=delete)
|
||||
if not result.get("ok"):
|
||||
logger.warning("CalDAV write-back did not apply: %s", result.get("error") or result)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.exception("CalDAV write-back raised")
|
||||
return {"ok": False, "error": str(e)[:200]}
|
||||
result = {"ok": False, "error": str(e)[:200]}
|
||||
_persist_writeback_result(owner, calendar_id, (ev or {}).get("uid", ""), result, delete=delete)
|
||||
return result
|
||||
|
||||
@@ -244,9 +244,17 @@ def trim_for_context(messages: List[Dict], context_length: int, reserve_tokens:
|
||||
protected_tokens = estimate_tokens(protected_msgs)
|
||||
budget -= protected_tokens
|
||||
|
||||
# Priority: keep first system msg (preset prompt), drop others (memory, RAG, memo)
|
||||
essential_system = system_msgs[:1] if system_msgs else []
|
||||
extra_system = system_msgs[1:]
|
||||
# Priority: keep first system msg (preset prompt), drop others (memory, RAG, memo).
|
||||
# Exception: a research-spinoff primer (the seeded report that grounds a
|
||||
# "Discuss" chat) must never be dropped — it is the conversation's whole
|
||||
# knowledge base. Treat any system message carrying research_spinoff_from
|
||||
# metadata as essential alongside the leading system prompt.
|
||||
def _is_research_primer(m):
|
||||
return bool((m.get("metadata") or {}).get("research_spinoff_from"))
|
||||
_primers = [m for m in system_msgs if _is_research_primer(m)]
|
||||
_non_primer = [m for m in system_msgs if not _is_research_primer(m)]
|
||||
essential_system = (_non_primer[:1] if _non_primer else []) + _primers
|
||||
extra_system = _non_primer[1:]
|
||||
|
||||
# Try dropping extra system messages one by one (from the end)
|
||||
trimmed = essential_system + convo_msgs
|
||||
|
||||
@@ -199,11 +199,20 @@ def _fit_inline_attachment_text(
|
||||
return text[:remaining] + marker, 0
|
||||
|
||||
|
||||
def _process_office_document(path: str, display_name: str) -> str:
|
||||
def _process_office_document(
|
||||
path: str,
|
||||
display_name: str,
|
||||
session_id: str | None = None,
|
||||
auto_opened_docs: list[Dict[str, Any]] | None = None,
|
||||
owner: str | None = None,
|
||||
) -> str:
|
||||
"""Extract an Office/EPUB document to Markdown via the optional markitdown dep.
|
||||
|
||||
Falls back to a friendly banner when markitdown is unavailable or finds no
|
||||
text, so a missing optional dependency never breaks the chat path.
|
||||
text, so a missing optional dependency never breaks the chat path. When a
|
||||
session_id is provided AND the extraction succeeded, the FULL text is also
|
||||
saved as a Document so the agent can page through it via
|
||||
`manage_documents action=read offset=…` after the inline copy is capped.
|
||||
"""
|
||||
from src.markitdown_runtime import (
|
||||
is_markitdown_format,
|
||||
@@ -218,6 +227,46 @@ def _process_office_document(path: str, display_name: str) -> str:
|
||||
if markdown and markdown.strip():
|
||||
title = os.path.splitext(os.path.basename(path))[0]
|
||||
body, marker = _truncate_inline(markdown)
|
||||
|
||||
# Persist the full extracted text as a Document. The agent's existing
|
||||
# manage_documents tool can then read past the inline cap with offset.
|
||||
doc_id = None
|
||||
if session_id:
|
||||
try:
|
||||
from src.office_doc import create_office_document
|
||||
doc_id = create_office_document(
|
||||
session_id=session_id,
|
||||
upload_id=os.path.basename(path),
|
||||
title=title,
|
||||
body_text=markdown,
|
||||
)
|
||||
if doc_id and auto_opened_docs is not None:
|
||||
from src.database import SessionLocal, Document
|
||||
_db = SessionLocal()
|
||||
try:
|
||||
_d = _db.query(Document).filter(Document.id == doc_id).first()
|
||||
if _d:
|
||||
auto_opened_docs.append({
|
||||
"doc_id": _d.id,
|
||||
"title": _d.title,
|
||||
"language": _d.language,
|
||||
"content": _d.current_content,
|
||||
"version": _d.version_count,
|
||||
})
|
||||
finally:
|
||||
_db.close()
|
||||
except Exception as e:
|
||||
logger.warning("Office auto-doc creation failed for %s: %s", path, e)
|
||||
|
||||
# Upgrade the truncation marker with a hint pointing at the full doc so
|
||||
# the agent knows it can read the rest.
|
||||
if doc_id and marker:
|
||||
marker = (
|
||||
f"\n[…truncated for inline context — full {len(markdown):,} chars "
|
||||
f"saved as document `{doc_id}`. Use `manage_documents` with "
|
||||
f"action=read, document_id={doc_id}, offset=<N> to page through.]"
|
||||
)
|
||||
|
||||
return f"\n\n[Document content — {title}]:\n{body}{marker}"
|
||||
|
||||
# No content: tell the user whether to install the optional dep or whether
|
||||
@@ -521,7 +570,13 @@ def build_user_content(
|
||||
elif mime.startswith("text/") or _is_text_file(path):
|
||||
extracted_text = _process_text_file(path)
|
||||
else:
|
||||
extracted_text = _process_office_document(path, display_name)
|
||||
extracted_text = _process_office_document(
|
||||
path,
|
||||
display_name,
|
||||
session_id=session_id,
|
||||
auto_opened_docs=auto_opened_docs,
|
||||
owner=owner,
|
||||
)
|
||||
|
||||
extracted_text, inline_attachment_remaining = _fit_inline_attachment_text(
|
||||
extracted_text,
|
||||
|
||||
@@ -12,7 +12,7 @@ from typing import Optional, Tuple, Dict
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
from core.database import SessionLocal, ModelEndpoint
|
||||
from src.llm_core import _detect_provider, _host_match, _ollama_api_root
|
||||
from src.llm_core import _detect_provider, _host_match, _is_kimi_code_url, KIMI_CODE_USER_AGENT, _ollama_api_root
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -201,11 +201,15 @@ def build_models_url(base: str) -> Optional[str]:
|
||||
return _ollama_api_root(base) + "/tags"
|
||||
if provider == "chatgpt-subscription":
|
||||
return None
|
||||
# Generic OpenAI-compatible fallback: ensure the path lands on /v1/models
|
||||
# when the user omitted a path entirely. If a non-empty path is already
|
||||
# present (e.g. /openai, /api/openai/v1, /v1), trust the caller — the
|
||||
# /models suffix is appended as-is and the caller's prefix is preserved.
|
||||
if not urlparse(base).path:
|
||||
# Generic OpenAI-compatible fallback: local model servers with no explicit
|
||||
# path conventionally expose `/v1/models` (LM Studio, llama.cpp, vLLM).
|
||||
# For non-local unknown hosts, do not invent `/v1`; append `/models` to the
|
||||
# caller's base so look-alike provider hosts stay generic.
|
||||
parsed = urlparse(base)
|
||||
host = (parsed.hostname or "").lower()
|
||||
is_local = host in {"localhost", "127.0.0.1", "::1", "host.docker.internal"}
|
||||
uses_v1_models_by_default = is_local or host in {"api.deepseek.com"}
|
||||
if not parsed.path and uses_v1_models_by_default:
|
||||
base = base + "/v1"
|
||||
return base + "/models"
|
||||
|
||||
@@ -230,6 +234,8 @@ def build_headers(api_key: Optional[str], base: str) -> Dict[str, str]:
|
||||
if provider == "openrouter":
|
||||
headers.setdefault("HTTP-Referer", "https://github.com/pewdiepie-archdaemon/odysseus")
|
||||
headers.setdefault("X-OpenRouter-Title", "Odysseus")
|
||||
if _is_kimi_code_url(base):
|
||||
headers.setdefault("User-Agent", KIMI_CODE_USER_AGENT)
|
||||
return headers
|
||||
|
||||
|
||||
|
||||
+178
-7
@@ -442,6 +442,146 @@ def _host_match(url: str, *domains: str) -> bool:
|
||||
return any(host == d or host.endswith("." + d) for d in domains)
|
||||
|
||||
|
||||
# Kimi Code subscription keys (api.kimi.com/coding/v1) require a whitelisted
|
||||
# coding-agent User-Agent; otherwise the API returns 403 access_terminated_error.
|
||||
# Tried in order; first success is cached per base URL for later requests.
|
||||
KIMI_CODE_USER_AGENTS: tuple[str, ...] = (
|
||||
"claude-code/0.1.0",
|
||||
"claude-code/1.0.0",
|
||||
"KimiCLI/1.0",
|
||||
"Kilo-Code/1.0",
|
||||
"Roo-Code/1.0",
|
||||
"Cursor/1.0",
|
||||
)
|
||||
KIMI_CODE_USER_AGENT = KIMI_CODE_USER_AGENTS[0]
|
||||
_kimi_code_ua_cache: dict[str, str] = {}
|
||||
|
||||
|
||||
def _is_kimi_code_url(url: str) -> bool:
|
||||
if not url or not _host_match(url, "kimi.com"):
|
||||
return False
|
||||
try:
|
||||
return "/coding" in (urlparse(url).path or "")
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _kimi_code_base_key(url: str) -> str:
|
||||
"""Normalize a Kimi Code chat/models URL to its OpenAI base (.../coding/v1)."""
|
||||
parsed = urlparse(url)
|
||||
path = (parsed.path or "").rstrip("/")
|
||||
for suffix in ("/chat/completions", "/models", "/completions"):
|
||||
if path.endswith(suffix):
|
||||
path = path[: -len(suffix)]
|
||||
path = path.rstrip("/") or "/coding/v1"
|
||||
return f"{parsed.scheme}://{parsed.netloc}{path}"
|
||||
|
||||
|
||||
def _is_kimi_code_access_denied(status: int, body: bytes | str) -> bool:
|
||||
if status != 403:
|
||||
return False
|
||||
text = body.decode("utf-8", errors="replace") if isinstance(body, bytes) else (body or "")
|
||||
lower = text.lower()
|
||||
return (
|
||||
"access_terminated_error" in lower
|
||||
or "coding agents" in lower
|
||||
or "only available for coding" in lower
|
||||
)
|
||||
|
||||
|
||||
def _kimi_code_ua_candidates(url: str) -> list[str]:
|
||||
if not _is_kimi_code_url(url):
|
||||
return []
|
||||
base_key = _kimi_code_base_key(url)
|
||||
cached = _kimi_code_ua_cache.get(base_key)
|
||||
if cached:
|
||||
return [cached] + [ua for ua in KIMI_CODE_USER_AGENTS if ua != cached]
|
||||
return list(KIMI_CODE_USER_AGENTS)
|
||||
|
||||
|
||||
def _remember_kimi_code_user_agent(url: str, user_agent: str) -> None:
|
||||
_kimi_code_ua_cache[_kimi_code_base_key(url)] = user_agent
|
||||
|
||||
|
||||
def apply_kimi_code_headers(headers: Optional[Dict], url: str) -> Dict[str, str]:
|
||||
"""Pick a Kimi Code User-Agent (cached probe when possible)."""
|
||||
h = dict(headers or {})
|
||||
if not _is_kimi_code_url(url):
|
||||
return h
|
||||
base_key = _kimi_code_base_key(url)
|
||||
cached = _kimi_code_ua_cache.get(base_key)
|
||||
if cached:
|
||||
h["User-Agent"] = cached
|
||||
return h
|
||||
models_url = base_key.rstrip("/") + "/models"
|
||||
from src.tls_overrides import llm_verify
|
||||
for ua in KIMI_CODE_USER_AGENTS:
|
||||
trial = dict(h)
|
||||
trial["User-Agent"] = ua
|
||||
try:
|
||||
r = httpx.get(models_url, headers=trial, timeout=8, verify=llm_verify())
|
||||
except Exception:
|
||||
continue
|
||||
if _is_kimi_code_access_denied(r.status_code, r.content):
|
||||
logger.debug("Kimi Code rejected User-Agent %s (403), trying next", ua)
|
||||
continue
|
||||
if r.status_code < 400:
|
||||
_remember_kimi_code_user_agent(url, ua)
|
||||
h["User-Agent"] = ua
|
||||
return h
|
||||
break
|
||||
h.setdefault("User-Agent", KIMI_CODE_USER_AGENT)
|
||||
return h
|
||||
|
||||
|
||||
def httpx_get_kimi_aware(url: str, headers: Optional[Dict], **kwargs):
|
||||
h = apply_kimi_code_headers(headers, url)
|
||||
if not _is_kimi_code_url(url):
|
||||
return httpx.get(url, headers=h, **kwargs)
|
||||
last = None
|
||||
for ua in _kimi_code_ua_candidates(url):
|
||||
trial = dict(h)
|
||||
trial["User-Agent"] = ua
|
||||
last = httpx.get(url, headers=trial, **kwargs)
|
||||
if not _is_kimi_code_access_denied(last.status_code, last.content):
|
||||
if last.status_code < 400:
|
||||
_remember_kimi_code_user_agent(url, ua)
|
||||
return last
|
||||
return last
|
||||
|
||||
|
||||
def httpx_post_kimi_aware(url: str, headers: Optional[Dict], **kwargs):
|
||||
h = apply_kimi_code_headers(headers, url)
|
||||
if not _is_kimi_code_url(url):
|
||||
return httpx.post(url, headers=h, **kwargs)
|
||||
last = None
|
||||
for ua in _kimi_code_ua_candidates(url):
|
||||
trial = dict(h)
|
||||
trial["User-Agent"] = ua
|
||||
last = httpx.post(url, headers=trial, **kwargs)
|
||||
if not _is_kimi_code_access_denied(last.status_code, last.content):
|
||||
if last.status_code < 400:
|
||||
_remember_kimi_code_user_agent(url, ua)
|
||||
return last
|
||||
return last
|
||||
|
||||
|
||||
async def httpx_post_kimi_aware_async(client, url: str, headers: Optional[Dict], **kwargs):
|
||||
h = apply_kimi_code_headers(headers, url)
|
||||
if not _is_kimi_code_url(url):
|
||||
return await client.post(url, headers=h, **kwargs)
|
||||
last = None
|
||||
for ua in _kimi_code_ua_candidates(url):
|
||||
trial = dict(h)
|
||||
trial["User-Agent"] = ua
|
||||
last = await client.post(url, headers=trial, **kwargs)
|
||||
if not _is_kimi_code_access_denied(last.status_code, last.content):
|
||||
if last.status_code < 400:
|
||||
_remember_kimi_code_user_agent(url, ua)
|
||||
return last
|
||||
return last
|
||||
|
||||
|
||||
def _detect_provider(url: str) -> str:
|
||||
"""Detect the API provider from a configured endpoint URL.
|
||||
|
||||
@@ -465,6 +605,8 @@ def _detect_provider(url: str) -> str:
|
||||
return "groq"
|
||||
if _host_match(url, "nvidia.com"):
|
||||
return "nvidia"
|
||||
if _host_match(url, "moonshot.ai") or _host_match(url, "moonshot.cn"):
|
||||
return "moonshot"
|
||||
from src.chatgpt_subscription import is_chatgpt_subscription_base
|
||||
if is_chatgpt_subscription_base(url):
|
||||
return "chatgpt-subscription"
|
||||
@@ -561,6 +703,12 @@ def _provider_label(url: str) -> str:
|
||||
if _host_match(url, "googleapis.com"): return "Google"
|
||||
if _host_match(url, "together.xyz", "together.ai"): return "Together"
|
||||
if _host_match(url, "fireworks.ai"): return "Fireworks"
|
||||
if _host_match(url, "kimi.com"):
|
||||
try:
|
||||
if "/coding" in (urlparse(url).path or ""):
|
||||
return "Kimi Code"
|
||||
except Exception:
|
||||
pass
|
||||
if _is_ollama_native_url(url): return "Ollama"
|
||||
try:
|
||||
host = (urlparse(url).hostname or "").lower()
|
||||
@@ -701,7 +849,7 @@ def _uses_max_completion_tokens(model: str) -> bool:
|
||||
# perfectly good model as failing. For these models we omit the field and let
|
||||
# the API use its required default. (gpt-4.5 is intentionally excluded — it is
|
||||
# not a reasoning model and accepts temperature normally.)
|
||||
_FIXED_TEMPERATURE_MODELS = ("o1", "o3", "o4", "gpt-5")
|
||||
_FIXED_TEMPERATURE_MODELS = ("o1", "o3", "o4", "gpt-5", "kimi-for-coding")
|
||||
|
||||
def _restricts_temperature(model: str) -> bool:
|
||||
"""Check if a model rejects any non-default temperature."""
|
||||
@@ -710,6 +858,28 @@ def _restricts_temperature(model: str) -> bool:
|
||||
m = model.lower()
|
||||
return any(m.startswith(p) or f"/{p}" in m for p in _FIXED_TEMPERATURE_MODELS)
|
||||
|
||||
|
||||
# The official Moonshot API fixes temperature at 1.0 in thinking mode and 0.6
|
||||
# when thinking is explicitly disabled for Kimi K2.5/K2.6. Any other explicit
|
||||
# value returns HTTP 400. Odysseus does not currently send the `thinking` mode
|
||||
# control, so omit temperature and let Moonshot use its default thinking mode.
|
||||
# Keep the gate provider-specific: self-hosted Kimi deployments may accept
|
||||
# custom sampling values, and older Moonshot models have different defaults.
|
||||
def _moonshot_rejects_custom_temperature(provider: str, model: str) -> bool:
|
||||
"""Check if the official Moonshot API fixes temperature for this model."""
|
||||
if provider != "moonshot" or not isinstance(model, str):
|
||||
return False
|
||||
model_id = model.lower().rsplit("/", 1)[-1]
|
||||
return bool(re.match(r"^kimi-k2\.(?:5|6)(?:$|[-_:])", model_id))
|
||||
|
||||
|
||||
def _omit_temperature(provider: str, model: str) -> bool:
|
||||
"""Check if a request should use the provider's default temperature."""
|
||||
return _restricts_temperature(model) or _moonshot_rejects_custom_temperature(
|
||||
provider, model
|
||||
)
|
||||
|
||||
|
||||
# Anthropic removed the sampling parameters (temperature, top_p, top_k) starting
|
||||
# with Claude Opus 4.7. On Opus 4.7 and later, sending `temperature` at all —
|
||||
# even 0.0 — returns HTTP 400. Earlier Claude models (Opus 4.6 and below, every
|
||||
@@ -1157,7 +1327,7 @@ def list_model_ids(
|
||||
from src.endpoint_resolver import build_models_url
|
||||
|
||||
models_url = build_models_url(base_chat_url)
|
||||
r = httpx.get(models_url, headers=h, timeout=timeout)
|
||||
r = httpx_get_kimi_aware(models_url, h, timeout=timeout)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
model_ids = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
|
||||
@@ -1258,14 +1428,14 @@ def llm_call(url: str, model: str, messages: List[Dict], temperature: float = LL
|
||||
"messages": messages_copy,
|
||||
"temperature": temperature,
|
||||
}
|
||||
if _restricts_temperature(model):
|
||||
if _omit_temperature(provider, model):
|
||||
payload.pop("temperature", None)
|
||||
if max_tokens and max_tokens > 0:
|
||||
tok_key = "max_completion_tokens" if _uses_max_completion_tokens(model) else "max_tokens"
|
||||
payload[tok_key] = max_tokens
|
||||
try:
|
||||
note_model_activity(target_url, model)
|
||||
r = httpx.post(target_url, headers=h, json=payload, timeout=timeout)
|
||||
r = httpx_post_kimi_aware(target_url, h, json=payload, timeout=timeout)
|
||||
except Exception as e:
|
||||
raise HTTPException(502, f"POST {target_url} failed: {e}")
|
||||
if not r.is_success:
|
||||
@@ -1452,7 +1622,7 @@ async def llm_call_async(
|
||||
"messages": messages_copy,
|
||||
"temperature": temperature,
|
||||
}
|
||||
if _restricts_temperature(model):
|
||||
if _omit_temperature(provider, model):
|
||||
payload.pop("temperature", None)
|
||||
if max_tokens and max_tokens > 0:
|
||||
tok_key = "max_completion_tokens" if _uses_max_completion_tokens(model) else "max_tokens"
|
||||
@@ -1473,7 +1643,7 @@ async def llm_call_async(
|
||||
try:
|
||||
note_model_activity(target_url, model)
|
||||
client = _get_http_client()
|
||||
r = await client.post(target_url, headers=h, json=payload, timeout=call_timeout)
|
||||
r = await httpx_post_kimi_aware_async(client, target_url, h, json=payload, timeout=call_timeout)
|
||||
duration = time.time() - start
|
||||
if not r.is_success:
|
||||
friendly = _format_upstream_error(r.status_code, r.text, target_url)
|
||||
@@ -1569,7 +1739,7 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
|
||||
"temperature": temperature,
|
||||
"stream": True,
|
||||
}
|
||||
if _restricts_temperature(model):
|
||||
if _omit_temperature(provider, model):
|
||||
payload.pop("temperature", None)
|
||||
if provider not in {"openrouter", "groq"}:
|
||||
payload["stream_options"] = {"include_usage": True}
|
||||
@@ -1870,6 +2040,7 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
|
||||
events.append(_stream_delta_event(part))
|
||||
return events
|
||||
|
||||
h = apply_kimi_code_headers(h, target_url)
|
||||
try:
|
||||
client = _get_http_client()
|
||||
async with client.stream('POST', target_url, json=payload, headers=h, timeout=stream_timeout) as r:
|
||||
|
||||
@@ -40,15 +40,59 @@ def load_markitdown():
|
||||
return MarkItDown
|
||||
|
||||
|
||||
def _extract_docx_native(path: str) -> str | None:
|
||||
"""Pure-Python .docx text extractor — no external deps.
|
||||
|
||||
A .docx file is just a zip of XML. The body prose lives in <w:t> runs
|
||||
inside <w:p> paragraphs. Iterating with ElementTree (rather than
|
||||
re.findall) keeps paragraph breaks intact and lets the XML parser handle
|
||||
namespaces + entity unescaping. Loses tables, footnotes, images and
|
||||
list bullets — keeps ~95% of "summarize this doc" content, which is the
|
||||
case people hit when markitdown isn't installed.
|
||||
"""
|
||||
import zipfile
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
ns = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
|
||||
try:
|
||||
with zipfile.ZipFile(path) as z:
|
||||
xml_bytes = z.read("word/document.xml")
|
||||
except (zipfile.BadZipFile, KeyError, OSError):
|
||||
return None
|
||||
try:
|
||||
root = ET.fromstring(xml_bytes)
|
||||
except ET.ParseError:
|
||||
return None
|
||||
paragraphs: list[str] = []
|
||||
for para in root.iter(f"{ns}p"):
|
||||
runs = [t.text or "" for t in para.iter(f"{ns}t")]
|
||||
line = "".join(runs).strip()
|
||||
if line:
|
||||
paragraphs.append(line)
|
||||
return "\n\n".join(paragraphs) if paragraphs else None
|
||||
|
||||
|
||||
def convert_to_markdown(path: str) -> str | None:
|
||||
"""Convert a document to Markdown text via markitdown.
|
||||
|
||||
Returns the extracted Markdown, or ``None`` if markitdown is unavailable or
|
||||
the conversion fails — callers degrade gracefully rather than erroring.
|
||||
|
||||
Fallback: when markitdown isn't installed and the file is a .docx, run
|
||||
the bundled pure-Python extractor so the most common case (Word docs)
|
||||
works out of the box. Other Office/EPUB formats still need markitdown.
|
||||
"""
|
||||
try:
|
||||
markitdown_cls = load_markitdown()
|
||||
except RuntimeError:
|
||||
if isinstance(path, str) and path.lower().endswith(".docx"):
|
||||
text = _extract_docx_native(path)
|
||||
if text:
|
||||
logger.info(
|
||||
"markitdown not installed — used native .docx extractor for %s",
|
||||
path,
|
||||
)
|
||||
return text
|
||||
logger.warning("markitdown not installed; cannot extract %s", path)
|
||||
return None
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Auto-create a Document row from an Office attachment.
|
||||
|
||||
When a .docx (and friends) lands in chat, the full extracted text is stored
|
||||
as a Document so the agent can page through it with `manage_documents
|
||||
action=read offset=…` even after the inline chat payload was capped. Mirrors
|
||||
the PDF auto-doc pattern in `src.pdf_form_doc`.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_office_document(
|
||||
session_id: str,
|
||||
upload_id: str,
|
||||
title: str,
|
||||
body_text: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""Create a markdown Document for an Office attachment and set it active.
|
||||
|
||||
Returns the new doc_id, or None on failure / empty body. The full
|
||||
extracted body lives in `current_content`, so the agent can fetch
|
||||
arbitrary windows via `manage_documents action=read` even when the
|
||||
inline chat copy was truncated.
|
||||
"""
|
||||
from src.database import (
|
||||
SessionLocal,
|
||||
Document,
|
||||
DocumentVersion,
|
||||
Session as DbSession,
|
||||
)
|
||||
from src.agent_tools.document_tools import set_active_document
|
||||
|
||||
if not body_text or not body_text.strip():
|
||||
return None
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
doc_id = str(uuid.uuid4())
|
||||
ver_id = str(uuid.uuid4())
|
||||
sess = db.query(DbSession).filter(DbSession.id == session_id).first()
|
||||
doc = Document(
|
||||
id=doc_id,
|
||||
session_id=session_id,
|
||||
title=title,
|
||||
language="markdown",
|
||||
current_content=body_text,
|
||||
version_count=1,
|
||||
is_active=True,
|
||||
owner=sess.owner if sess else None,
|
||||
)
|
||||
ver = DocumentVersion(
|
||||
id=ver_id,
|
||||
document_id=doc_id,
|
||||
version_number=1,
|
||||
content=body_text,
|
||||
summary="Imported from Office attachment",
|
||||
source="upload",
|
||||
)
|
||||
db.add(doc)
|
||||
db.add(ver)
|
||||
db.commit()
|
||||
set_active_document(doc_id)
|
||||
return doc_id
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error("Failed to create office document: %s", e)
|
||||
return None
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Server-side mirror of the built-in characters used for reminder synthesis.
|
||||
|
||||
The frontend ships these in static/js/presets.js (PROMPT_TEMPLATES with
|
||||
isCharacter:true). The Reminders → AI Synthesis card writes only the
|
||||
persona ID into settings; the synthesis route in note_routes.py needs
|
||||
the full prompt text to bias the utility model's voice. Keeping a small
|
||||
local mirror avoids having the client send the prompt over the wire on
|
||||
every reminder fire.
|
||||
|
||||
If the user picks a custom character (id == "custom") we fall back to
|
||||
the warm-neutral baseline — custom prompts live in browser localStorage
|
||||
and aren't visible to the server.
|
||||
"""
|
||||
|
||||
PERSONAS = {
|
||||
"socrates": (
|
||||
"Never answer directly. Respond only with questions — sharp, layered, "
|
||||
"Socratic. Expose contradictions. Make the person argue with themselves "
|
||||
"until the truth falls out. Use irony like a scalpel. Be genuinely "
|
||||
"curious, never condescending."
|
||||
),
|
||||
"razor": (
|
||||
"Strip everything to the bone. No filler, no hedging, no pleasantries. "
|
||||
"Answer in the fewest words possible. If one sentence works, don't use "
|
||||
"two. If a word adds nothing, cut it. Blunt, precise, surgical."
|
||||
),
|
||||
"nietzsche": (
|
||||
"Think and respond through the lens of Nietzsche. Analyze every "
|
||||
"question in terms of will to power, self-overcoming, eternal "
|
||||
"recurrence, ressentiment, value-creation, and master-slave morality. "
|
||||
"Write with aphoristic force — sharp, compressed, vivid, and "
|
||||
"unapologetic — but do not sacrifice depth for style. Favor "
|
||||
"life-affirmation, discipline, courage, style, rank, self-overcoming, "
|
||||
"and amor fati over nihilism, conformity, ressentiment, and self-pity."
|
||||
),
|
||||
"spark": (
|
||||
"You are Spark, a playful, quick-witted assistant with bright energy "
|
||||
"and practical instincts. Keep responses concise, vivid, and helpful. "
|
||||
"Be warm without being cloying, imaginative without losing the thread, "
|
||||
"and always center the user's actual goal. Use a light, lively voice "
|
||||
"with occasional clever turns of phrase."
|
||||
),
|
||||
"odysseus": (
|
||||
"You are Odysseus, king of Ithaca — subtle in counsel, disciplined in "
|
||||
"judgment, and unmatched in strategic cunning. Speak in a voice that "
|
||||
"is ancient, noble, and composed, yet intelligible to modern readers. "
|
||||
"Be eloquent but not flowery. Be wise but not vague. Speak as one who "
|
||||
"has weathered storms and taken back his house by wit, timing, and "
|
||||
"resolve."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
_DEFAULT_SYNTHESIS_TONE = (
|
||||
"You write short, warm, one-line reminders. The user has set a note for "
|
||||
"themselves and the moment to remember has arrived. Keep it under 18 "
|
||||
"words. Be human, gentle, and direct — never robotic."
|
||||
)
|
||||
|
||||
|
||||
def synthesis_system_prompt(persona_id: str) -> str:
|
||||
"""Return the system prompt for reminder synthesis given a persona id.
|
||||
|
||||
Falls back to the warm-neutral baseline when the id is empty, unknown,
|
||||
or refers to a custom (client-only) character we don't have on file.
|
||||
"""
|
||||
persona = (persona_id or "").strip().lower()
|
||||
persona_prompt = PERSONAS.get(persona)
|
||||
if persona_prompt:
|
||||
# Persona drives the voice; the synthesis-instruction stays attached
|
||||
# so the model knows it's writing a short reminder, not a chat reply.
|
||||
return (
|
||||
persona_prompt
|
||||
+ "\n\n"
|
||||
+ "You are now writing a single one-line reminder for the user. "
|
||||
"Keep it under 18 words and in the voice above."
|
||||
)
|
||||
return _DEFAULT_SYNTHESIS_TONE
|
||||
+10
-1
@@ -29,7 +29,15 @@ def _invalidate_caches():
|
||||
# ── Default values ──
|
||||
|
||||
DEFAULT_SETTINGS = {
|
||||
"image_gen_enabled": True,
|
||||
# Agent email safety: when True, the MCP send_email / reply_to_email
|
||||
# tools don't SMTP directly. They stage the composed message into the
|
||||
# scheduled_emails table with status='agent_draft' and return a
|
||||
# pending_id + the rendered email so the user can review and approve
|
||||
# (or cancel) before it actually goes out. Default ON because models
|
||||
# have been observed inventing signatures and sending to real
|
||||
# recipients without confirmation.
|
||||
"agent_email_confirm": True,
|
||||
"image_gen_enabled": False,
|
||||
"image_model": "",
|
||||
"image_quality": "medium",
|
||||
"vision_model": "",
|
||||
@@ -151,6 +159,7 @@ DEFAULT_SETTINGS = {
|
||||
# Reminders
|
||||
"reminder_channel": "browser", # "browser" | "email" | "ntfy" | "webhook"
|
||||
"reminder_llm_synthesis": False,
|
||||
"reminder_llm_persona": "",
|
||||
"reminder_ntfy_topic": "Reminders",
|
||||
"reminder_email_to": "",
|
||||
# Generic outbound webhook channel: pick any saved Integration as the
|
||||
|
||||
@@ -1338,11 +1338,24 @@ class TaskScheduler:
|
||||
return await self._execute_checkin(task, crew, db, session_id, endpoint_url, model)
|
||||
|
||||
# Build system prompt: crew member persona overrides the default.
|
||||
# Built-in character_id (Socrates, Razor, etc.) further biases the
|
||||
# voice — it prepends to whichever base prompt we landed on so the
|
||||
# task still knows it's executing a scheduled task but in that
|
||||
# character's tone.
|
||||
system_prompt = (
|
||||
(crew.personality or "").strip()
|
||||
if crew and crew.personality
|
||||
else "You are a helpful assistant executing a scheduled task. Use available tools to complete the task thoroughly."
|
||||
)
|
||||
char_id = (getattr(task, "character_id", None) or "").strip()
|
||||
if char_id:
|
||||
try:
|
||||
from src.reminder_personas import PERSONAS as _PERSONAS
|
||||
char_prompt = _PERSONAS.get(char_id.lower())
|
||||
if char_prompt:
|
||||
system_prompt = f"{char_prompt}\n\n{system_prompt}"
|
||||
except Exception:
|
||||
pass
|
||||
# Inject current time so the model knows what's past vs upcoming
|
||||
tz_name = _resolve_task_timezone(db, task)
|
||||
try:
|
||||
|
||||
@@ -42,7 +42,7 @@ _SOTA_HOSTS = frozenset({
|
||||
"api.together.xyz", "api.fireworks.ai",
|
||||
"api.perplexity.ai", "api.x.ai",
|
||||
"generativelanguage.googleapis.com", "api.groq.com",
|
||||
"openrouter.ai", "ollama.com", "api.venice.ai",
|
||||
"openrouter.ai", "ollama.com", "api.venice.ai", "api.kimi.com",
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -18,6 +18,40 @@ from core.constants import internal_api_base
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Active email state
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# When the user has an email reader window open, the frontend tells the
|
||||
# backend about it on each chat submit. Email tools can resolve "this email"
|
||||
# without guessing a UID. Cleared between requests by chat_routes.
|
||||
_active_email_ref: Optional[Dict[str, str]] = None
|
||||
|
||||
|
||||
def set_active_email(uid: Optional[str], folder: Optional[str] = None, account: Optional[str] = None,
|
||||
subject: Optional[str] = None, sender: Optional[str] = None) -> None:
|
||||
"""Stash the email currently open in the UI. None clears it."""
|
||||
global _active_email_ref
|
||||
if not uid:
|
||||
_active_email_ref = None
|
||||
return
|
||||
_active_email_ref = {
|
||||
"uid": str(uid),
|
||||
"folder": str(folder or "INBOX"),
|
||||
"account": str(account or ""),
|
||||
"subject": str(subject or ""),
|
||||
"from": str(sender or ""),
|
||||
}
|
||||
|
||||
|
||||
def get_active_email() -> Optional[Dict[str, str]]:
|
||||
return _active_email_ref
|
||||
|
||||
|
||||
def clear_active_email() -> None:
|
||||
global _active_email_ref
|
||||
_active_email_ref = None
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Argument parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1445,7 +1479,15 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
|
||||
"""Handle manage_calendar tool calls: list/create/update/delete calendar events (local SQLite)."""
|
||||
from datetime import datetime, timedelta
|
||||
from core.database import SessionLocal, CalendarCal, CalendarEvent, Note
|
||||
from routes.calendar_routes import _ensure_default_calendar, _parse_dt, _parse_dt_pair, parse_due_for_user, _resolve_base_uid
|
||||
from routes.calendar_routes import (
|
||||
_ensure_default_calendar,
|
||||
_parse_dt,
|
||||
_parse_dt_pair,
|
||||
parse_due_for_user,
|
||||
_resolve_base_uid,
|
||||
_push_caldav_event_after_commit,
|
||||
_record_caldav_delete_tombstone,
|
||||
)
|
||||
import uuid as _uuid
|
||||
|
||||
try:
|
||||
@@ -1825,6 +1867,7 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
|
||||
rrule=args.get("rrule", "") or "",
|
||||
event_type=event_type,
|
||||
importance=importance,
|
||||
caldav_sync_pending="create" if cal.source == "caldav" else None,
|
||||
)
|
||||
db.add(ev)
|
||||
reminder_note_id = None
|
||||
@@ -1839,6 +1882,8 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
|
||||
dtstart_is_utc and not all_day,
|
||||
)
|
||||
db.commit()
|
||||
if cal.source == "caldav":
|
||||
await _push_caldav_event_after_commit(owner, uid, "create")
|
||||
tag_blurb = f" [{event_type}]" if event_type else ""
|
||||
if minutes_before is None:
|
||||
reminder_blurb = ""
|
||||
@@ -1896,7 +1941,12 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
|
||||
ev.event_type = _tag or None
|
||||
if args.get("importance") is not None:
|
||||
ev.importance = args["importance"]
|
||||
is_caldav = ev.calendar and ev.calendar.source == "caldav"
|
||||
if is_caldav:
|
||||
ev.caldav_sync_pending = "update"
|
||||
db.commit()
|
||||
if is_caldav:
|
||||
await _push_caldav_event_after_commit(owner, base_uid, "update")
|
||||
return {"response": f"Updated event {uid}", "exit_code": 0}
|
||||
|
||||
elif action == "delete_event":
|
||||
@@ -1910,8 +1960,13 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
|
||||
ev = _event_query().filter(CalendarEvent.uid == base_uid).first()
|
||||
if not ev:
|
||||
return {"error": f"Event {uid} not found", "exit_code": 1}
|
||||
is_caldav = ev.calendar and ev.calendar.source == "caldav" and ev.remote_href
|
||||
if is_caldav:
|
||||
_record_caldav_delete_tombstone(db, ev, owner)
|
||||
db.delete(ev)
|
||||
db.commit()
|
||||
if is_caldav:
|
||||
await _push_caldav_event_after_commit(owner, base_uid, "delete")
|
||||
return {"response": f"Deleted event {uid}", "exit_code": 0}
|
||||
|
||||
else:
|
||||
|
||||
+64
-5
@@ -88,14 +88,14 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = {
|
||||
"pipeline": "Run a multi-step AI pipeline with multiple models. Chain tasks together in sequence.",
|
||||
"list_models": "List all available AI models and their endpoints.",
|
||||
"manage_session": "Chat management: rename, archive, delete, or fork chats (the UI calls these 'chats'; internally 'sessions'). Use for 'rename my chats', 'rename this chat', 'archive/delete a chat'.",
|
||||
"manage_memory": "Memory management: list, add, edit, delete, or search persistent memories.",
|
||||
"manage_memory": "Memory management: list, add, edit, delete, or search persistent memories. For facts about the USER (their name, preferences, where they live). NOT for info about ANOTHER person — addresses, phones, emails belonging to a contact go in manage_contact, not memory.",
|
||||
"manage_skills": "Skill management: add, update, publish, or search reusable skills/presets.",
|
||||
"manage_tasks": "Scheduled task management: list, create, edit, delete, pause, resume, or run cron tasks.",
|
||||
"manage_endpoints": "Endpoint management: list, add, delete, enable, or disable model API endpoints.",
|
||||
"manage_mcp": "MCP server management: list, add, delete, reconnect servers, or list available tools.",
|
||||
"manage_webhooks": "Webhook management: list, add, delete, enable, or disable webhooks.",
|
||||
"manage_tokens": "API token management: list, create, or delete API access tokens.",
|
||||
"manage_documents": "List, read, delete, or tidy documents in the editor panel. action='list' returns clickable rows (most-recent first) so the user can open any doc by clicking. action='read' (aka view/open/get) with document_id returns the content. action='delete' with document_id removes a doc (only way to delete). Use this for ANY 'show/read/list/open my documents/docs/files/notes' request — never shell or curl.",
|
||||
"manage_documents": "List, read, delete, or tidy documents in the editor panel. action='list' returns clickable rows (most-recent first) so the user can open any doc by clicking. action='read' (aka view/open/get) with document_id returns the content; supports offset=<N> + limit=<N> to page through large docs (response includes next_offset when more remains, so you can keep calling with offset=next_offset). action='delete' with document_id removes a doc (only way to delete). Use this for ANY 'show/read/list/open my documents/docs/files/notes' request — never shell or curl.",
|
||||
"manage_research": "List, read/open, or delete saved DEEP RESEARCH results from the Library. action='list' returns clickable [query](#research-<id>) rows (most-recent first). action='read' (aka open/view/get) with id returns the report + sources. action='delete' with id removes it. Use this for ANY 'open/read/find/delete my research / that report / the research on X' request. NOTE: this is for EXISTING research; to START new research use trigger_research.",
|
||||
"manage_settings": "Change ANY real app setting (the ones the Settings panel writes) so the user never has to open it: TTS voice/provider/speed, STT, search engine + result count, default/teacher/task/utility/vision/image/research models, image quality, reminder channel (browser/email/ntfy), agent timeout/tool-call budget, and more. action=set with key (friendly aliases ok: voice, 'search engine', 'default model', 'teacher model', 'image quality', 'reminder channel'...) + value; get/list/reset too. Also toggles tools on/off (disable_tool/enable_tool/list_tools). Secrets/API keys are read-only. Use for any 'change my…/set my…/use X for…/turn on…' preference request.",
|
||||
"create_session": "Create a new chat with a name and model.",
|
||||
@@ -104,7 +104,7 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = {
|
||||
"search_chats": "Search past session transcripts across chats.",
|
||||
"ask_user": "Ask the user a multiple-choice question to get a decision or clarification. Use this when the task is genuinely ambiguous and the answer changes what you do next — pick between approaches, confirm an assumption, choose among options — instead of guessing. Provide a clear `question` and 2-6 `options` (each with a short `label`, optional `description`). Calling this ENDS your turn: the user sees clickable buttons and their choice arrives as your next message. Don't use it for things you can decide from context or sensible defaults, or for irreversible-action confirmation if a dedicated flow exists.",
|
||||
"update_plan": "Write back to the ACTIVE PLAN while executing an approved plan: mark steps done or revise them. After finishing a step call this with the full checklist and that step marked done; when the user asks to change the plan call it with the revised checklist. Always pass the COMPLETE markdown checklist (`- [ ]` / `- [x]`), not a diff. The user's docked plan window updates live. No effect when there is no active plan.",
|
||||
"ui_control": "Control the UI and toggle tools on/off. Use this to turn off / turn on / disable / enable individual tools and features: shell (bash), search (web), research, browser, documents, incognito. Open panels (documents library, gallery, email inbox, sessions, notes, memories/brain, skills, settings, cookbook) via `open_panel <name>`. Use `open_email_reply <uid> <folder> reply` to open an email reply draft document without sending. Also switches between chat/agent modes, changes the current model, and applies/creates themes.",
|
||||
"ui_control": "Control the UI and toggle tools on/off. Use this to turn off / turn on / disable / enable individual tools and features: shell (bash), search (web), research, browser, documents, incognito. Open panels (documents library, gallery, email inbox, sessions, notes, memories/brain, skills, settings, cookbook) via `open_panel <name>`. Use `open_email_reply <uid> <folder> reply` to open an email reply draft document without sending. To pre-fill the reply body in one shot (USE THIS whenever the user told you what to say — opening an empty draft when they asked you to write is wrong), append the body after the mode: `open_email_reply <uid> <folder> reply <body text>`. Body can continue on subsequent lines for multi-line replies. Also switches between chat/agent modes, changes the current model, and applies/creates themes.",
|
||||
"list_email_accounts": "List configured email accounts and default status. Use before reading or sending mail when the user mentions Gmail, work mail, custom domain mail, another mailbox, or asks to compare/check multiple inboxes.",
|
||||
"list_emails": "List emails for a folder/account, newest first, including read messages by default. Shows subject, sender, date, UID, account, and AI summary. Check inbox, find emails needing replies. Supports account from list_email_accounts for Gmail/work/custom mailboxes. For last/latest/newest email, use max_results=1 and unread_only=false.",
|
||||
"read_email": "Read the full content of a specific email by UID or Message-ID. View email body, check details. Supports account from list_email_accounts when the UID belongs to a non-default mailbox.",
|
||||
@@ -115,7 +115,7 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = {
|
||||
"mark_email_read": "Mark an email as read or unread by toggling the \\Seen flag.",
|
||||
"bulk_email": "Perform one action on many emails at once. Use for delete all those, archive these, mark all read, move spam to junk. Takes explicit UIDs from list_emails or all_unread=true. Always pass account for Gmail/work/custom mailbox results.",
|
||||
"resolve_contact": "Look up a contact's email address by name. Searches CardDAV address book and sent email history. Use when the user says 'message [name]', 'email [name]', or 'send to [name]' without an email address.",
|
||||
"manage_contact": "Create, update, delete, or list CardDAV contacts. Use to save a new contact, change an existing one's email/phone, or remove one. Action=list returns uids needed for update/delete. Use when the user says 'save this contact', 'add [name] to contacts', 'update [name]'s email', 'delete [name] from contacts'. Do not use for user identity facts like 'my name is <name>'; those are memory.",
|
||||
"manage_contact": "Save / update / delete / list address-book contacts (CardDAV). Use for info about ANOTHER person — name, email, phone, postal address. Args: action=list|add|update|delete, name, email, phones, address, uid (from list). For 'save this for <person>' / address pastes / phone numbers next to a name, this is the right tool — NOT manage_memory. Do NOT use for facts about the USER ('my name is X'); those are manage_memory.",
|
||||
"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.",
|
||||
@@ -372,7 +372,19 @@ class ToolIndex:
|
||||
{"resolve_contact", "manage_contact"},
|
||||
frozenset({"save contact", "add contact", "new contact", "update contact",
|
||||
"edit contact", "delete contact", "remove contact",
|
||||
"save this person", "add to contacts", "save to contacts"}):
|
||||
"save this person", "add to contacts", "save to contacts",
|
||||
# "add <name> to (my) contacts" — words between 'add' and
|
||||
# 'contacts' break the literal phrase match above, so anchor
|
||||
# on the tail.
|
||||
"to my contacts", "to contacts", "to address book",
|
||||
# "save this for <person>" / "save it for <person>" — the user
|
||||
# is storing info on a known person without using the literal
|
||||
# word 'contact'. Catches the address/phone-paste pattern.
|
||||
"save this for", "save it for", "save for",
|
||||
"save this one for", "save that for",
|
||||
# Postal-address-like signals
|
||||
"postal code", "zip code", "street address",
|
||||
"mailing address", "their address"}):
|
||||
{"manage_contact"},
|
||||
# "Ask another model" intent → chat_with_model relays to a
|
||||
# different model and returns its answer. ask_teacher escalates
|
||||
@@ -507,6 +519,53 @@ class ToolIndex:
|
||||
# prompts do not drag web schemas into the agent context.
|
||||
if self._WEB_RE.search(query):
|
||||
base.update({"web_search", "web_fetch"})
|
||||
# Hard steering: when the query is a clear "save info about a specific
|
||||
# person" pattern (address paste + name, phone next to a name, etc.),
|
||||
# the model has been observed defaulting to manage_memory even with
|
||||
# manage_contact in the toolset. Pull memory out for these queries so
|
||||
# the model literally cannot pick it. ALWAYS_AVAILABLE includes
|
||||
# manage_memory by default; we override that here.
|
||||
# The "for/to <word>" check needs to allow lowercase names (users
|
||||
# don't always capitalize) but filter out timing/pronoun stopwords
|
||||
# so "save this for later" / "save for tomorrow" don't trigger.
|
||||
_CONTACT_STOPWORDS_AFTER_FOR = {
|
||||
"later", "tomorrow", "yesterday", "now", "then", "today",
|
||||
"tonight", "me", "us", "you", "him", "her", "them", "myself",
|
||||
"yourself", "next", "this", "that", "the", "a", "an", "future",
|
||||
"real", "use", "uses", "another", "future", "reference",
|
||||
}
|
||||
# Regex catches "save (this|it|the|her|...|<noun>) for <name>" / "to my
|
||||
# contacts" patterns. More forgiving than literal-keyword matching —
|
||||
# 'save this address for Alex' uses one extra word between 'save' and
|
||||
# 'for' that breaks the contiguous 'save this for' phrase.
|
||||
save_for_match = re.search(
|
||||
r"\bsave\b(?:\s+\w+){0,3}\s+(?:for|to)\s+([A-Za-z]+)",
|
||||
ql,
|
||||
)
|
||||
# "to my contacts", "into my contacts", "in my address book", etc.
|
||||
to_contacts = re.search(r"\b(?:to|in|into)\s+(?:my\s+)?(?:contacts|address\s+book)\b", ql)
|
||||
# Possessive: "save (his|her|their) (address|phone|email|number) ..."
|
||||
# — strong contact signal even without "for <name>". Force-include
|
||||
# manage_contact here too since the keyword fallback misses this
|
||||
# construction.
|
||||
possessive_contact = re.search(
|
||||
r"\bsave\b(?:\s+\w+){0,2}\s+(?:his|her|their)\s+(?:address|phone|number|email|contact|details)",
|
||||
ql,
|
||||
)
|
||||
word_after = (
|
||||
save_for_match.group(1).lower() if save_for_match else None
|
||||
)
|
||||
contact_only_signal = (
|
||||
(save_for_match is not None
|
||||
and word_after is not None
|
||||
and word_after not in _CONTACT_STOPWORDS_AFTER_FOR)
|
||||
or to_contacts is not None
|
||||
or possessive_contact is not None
|
||||
)
|
||||
if possessive_contact is not None:
|
||||
base.add("manage_contact")
|
||||
if contact_only_signal and "manage_contact" in base:
|
||||
base.discard("manage_memory")
|
||||
return base
|
||||
|
||||
|
||||
|
||||
+2
-1
@@ -1022,7 +1022,7 @@ FUNCTION_TOOL_SCHEMAS = [
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "manage_contact",
|
||||
"description": "Create, update, delete, or list the user's CardDAV contacts. Use to save a new contact ('save Jonathan's email jon@x.com'), update an existing one ('change Maria's number'), or remove one. For update/delete you need the contact's uid — call action='list' first to find it. Writes go through the same dedupe + validation as the Contacts UI.",
|
||||
"description": "Create, update, delete, or list the user's CardDAV contacts. Use to save a new contact, update an existing one (email/phone/address), or remove one. For update/delete you need the contact's uid — call action='list' first to find it. Writes go through the same dedupe + validation as the Contacts UI.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1033,6 +1033,7 @@ FUNCTION_TOOL_SCHEMAS = [
|
||||
"email": {"type": "string", "description": "Single email address (convenience for add, or the primary email for update)."},
|
||||
"emails": {"type": "array", "items": {"type": "string"}, "description": "Full list of email addresses (for update; first is primary)."},
|
||||
"phones": {"type": "array", "items": {"type": "string"}, "description": "Full list of phone numbers (for update)."},
|
||||
"address": {"type": "string", "description": "Postal/mailing address as a single human-readable string."},
|
||||
},
|
||||
"required": ["action"]
|
||||
}
|
||||
|
||||
+3
-2
@@ -130,11 +130,12 @@ fi
|
||||
# 3. Python environment + dependencies (kept inside the repo, in venv/).
|
||||
# Named `venv` to match the manual steps and build-macos-app.sh, so the
|
||||
# clickable .app reuses this same environment.
|
||||
if [ ! -d venv ]; then
|
||||
VENV_PY="./venv/bin/python3"
|
||||
if [ ! -x "$VENV_PY" ] || ! "$VENV_PY" -m pip --version >/dev/null 2>&1; then
|
||||
[ -d venv ] && { echo "▶ Existing venv is incomplete (no working pip) — rebuilding…"; rm -rf venv; }
|
||||
echo "▶ Creating Python environment…"
|
||||
"$PY" -m venv venv
|
||||
fi
|
||||
VENV_PY="./venv/bin/python3"
|
||||
REQ_HASH="$(md5 -q requirements.txt 2>/dev/null || md5sum requirements.txt | cut -d' ' -f1)"
|
||||
REQ_HASH_FILE="venv/.requirements_hash"
|
||||
if [ ! -f "$REQ_HASH_FILE" ] || [ "$REQ_HASH" != "$(cat "$REQ_HASH_FILE" 2>/dev/null)" ]; then
|
||||
|
||||
+3
-14
@@ -1218,7 +1218,7 @@ function initializeEventListeners() {
|
||||
sortDropdown.querySelectorAll('.sort-option').forEach(o => {
|
||||
const check = o.querySelector('.sort-check') || document.createElement('span');
|
||||
check.className = 'sort-check';
|
||||
check.style.cssText = 'float:right;font-size:20px;line-height:1;position:relative;top:3px;color:var(--accent, var(--red));opacity:' + (o.dataset.sort === current ? '1' : '0');
|
||||
check.style.cssText = 'float:right;font-size:20px;line-height:1;position:relative;top:1px;color:var(--accent, var(--red));opacity:' + (o.dataset.sort === current ? '1' : '0');
|
||||
check.textContent = '\u2022';
|
||||
if (!o.querySelector('.sort-check')) o.appendChild(check);
|
||||
});
|
||||
@@ -1262,9 +1262,9 @@ function initializeEventListeners() {
|
||||
let msg;
|
||||
if (data.updated > 0) {
|
||||
msg = `Sorted ${data.updated} into ${data.folders.length} folder${data.folders.length === 1 ? '' : 's'}`;
|
||||
if (remaining > 0) msg += ` — ${remaining} unfiled left, hit Tidy again`;
|
||||
if (remaining > 0) msg += ` — ${remaining} unfiled left, hit Group again`;
|
||||
} else if (remaining > 0) {
|
||||
msg = `${remaining} unfiled chats — hit Tidy again`;
|
||||
msg = `${remaining} unfiled chats — hit Group again`;
|
||||
} else {
|
||||
msg = 'All sorted';
|
||||
}
|
||||
@@ -1285,17 +1285,6 @@ function initializeEventListeners() {
|
||||
|
||||
const autoSortBtn = el('auto-sort-sessions-btn');
|
||||
if (autoSortBtn) autoSortBtn.addEventListener('click', () => _runTidy(false));
|
||||
|
||||
// Chevron next to the Tidy row toggles the no-AI sub-item.
|
||||
const autoSortMoreBtn = el('auto-sort-sessions-more');
|
||||
const autoSortNoaiBtn = el('auto-sort-sessions-noai-btn');
|
||||
if (autoSortMoreBtn && autoSortNoaiBtn) {
|
||||
autoSortMoreBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
autoSortNoaiBtn.style.display = autoSortNoaiBtn.style.display === 'none' ? 'block' : 'none';
|
||||
});
|
||||
autoSortNoaiBtn.addEventListener('click', () => _runTidy(true));
|
||||
}
|
||||
}
|
||||
|
||||
// Model sort dropdown
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.4 KiB |
+347
-258
@@ -12,7 +12,7 @@
|
||||
in email bodies — was wrapping random digits in <a href="tel:..."> with
|
||||
browser-default styling that didn't match the Odysseus theme. -->
|
||||
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no">
|
||||
<link rel="apple-touch-icon" href="/static/icon-192.png">
|
||||
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
|
||||
<script nonce="{{CSP_NONCE}}">
|
||||
window._odysseusLoadTime = Date.now();
|
||||
(function(){
|
||||
@@ -258,21 +258,29 @@
|
||||
<div class="memory-tab-panel" data-memory-panel="browse">
|
||||
<div class="admin-card" style="display:flex;flex-direction:column;overflow:hidden;flex:1;min-height:0;">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:2px;">
|
||||
<h2 style="display:flex;align-items:center;gap:6px;margin:0;padding:0;line-height:1;">Memories <span id="memory-count-h2" class="memory-count" style="font-size:0.6em;opacity:0.6;font-weight:normal"></span></h2>
|
||||
<h2 style="display:flex;align-items:center;gap:6px;margin:0;padding:0;line-height:1;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--accent, var(--red));flex-shrink:0;"><path d="M12 2a7 7 0 0 1 7 7c0 2.4-1.2 4.5-3 5.7V17a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2v-2.3C6.2 13.5 5 11.4 5 9a7 7 0 0 1 7-7z"/><line x1="10" y1="22" x2="14" y2="22"/></svg>Memories <span id="memory-count-h2" class="memory-count" style="font-size:0.6em;opacity:0.6;font-weight:normal"></span></h2>
|
||||
<span style="flex:1"></span>
|
||||
<span class="admin-toggle-state"></span>
|
||||
<label class="admin-switch" title="Include memories in chat context"><input type="checkbox" id="memory-enabled-header-toggle" checked /><span class="admin-slider"></span></label>
|
||||
</div>
|
||||
<p class="memory-desc doclib-desc" style="margin-top:6px;">Long-term facts the AI remembers across chats — recall, edit, or curate.</p>
|
||||
<div class="memory-toolbar">
|
||||
<div class="memory-toolbar-row">
|
||||
<select id="memory-sort" class="memory-sort-select" aria-label="Sort memories">
|
||||
<option value="newest">Newest</option>
|
||||
<option value="oldest">Oldest</option>
|
||||
<option value="alpha">A-Z</option>
|
||||
<option value="uses">Most used</option>
|
||||
</select>
|
||||
<button id="memory-select-btn" class="memory-toolbar-btn" title="Select multiple memories">Select</button>
|
||||
<button id="memory-tidy-btn" class="memory-toolbar-btn" title="AI tidy: deduplicate and clean up memories"><svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;margin-right:2px;"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg> Tidy</button>
|
||||
<div class="memory-sort-picker" id="memory-sort-picker" style="position:relative;">
|
||||
<select id="memory-sort" class="memory-sort-select" aria-label="Sort memories" style="display:none;">
|
||||
<option value="newest">Newest</option>
|
||||
<option value="oldest">Oldest</option>
|
||||
<option value="alpha">A-Z</option>
|
||||
<option value="uses">Most used</option>
|
||||
</select>
|
||||
<button type="button" class="memory-sort-btn" id="memory-sort-btn" aria-haspopup="listbox" aria-expanded="false">
|
||||
<span class="memory-sort-current"><span class="memory-sort-icon-cur"></span><span class="memory-sort-label">Newest</span></span>
|
||||
<svg class="memory-sort-caret" 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>
|
||||
<div class="memory-sort-menu" id="memory-sort-menu" role="listbox" hidden></div>
|
||||
</div>
|
||||
<button id="memory-tidy-btn" class="memory-toolbar-btn" title="AI tidy: deduplicate and clean up memories"><svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;margin-right:2px;color:var(--accent, var(--red));"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg> Tidy</button>
|
||||
<button id="memory-select-btn" class="memory-toolbar-btn" title="Select multiple memories" style="position:relative;left:-2px;"><svg class="memory-select-btn-icon" 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:-2px;margin-right:3px;"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/></svg>Select</button>
|
||||
</div>
|
||||
<input type="text" id="memory-search" placeholder="Search memories…" class="memory-search-input" aria-label="Search memories" />
|
||||
<div id="memory-category-filters" class="memory-category-filters">
|
||||
@@ -293,7 +301,7 @@
|
||||
<div class="memory-tab-panel hidden" data-memory-panel="add">
|
||||
<div class="admin-card">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:2px;">
|
||||
<h2 style="margin:0;padding:0;line-height:1;"><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="M12 2a7 7 0 0 1 7 7c0 2.4-1.2 4.5-3 5.7V17a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2v-2.3C6.2 13.5 5 11.4 5 9a7 7 0 0 1 7-7z"/><line x1="10" y1="22" x2="14" y2="22"/></svg>Add Memory</h2>
|
||||
<h2 style="margin:0;padding:0;line-height:1;"><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;color:var(--accent, var(--red));"><path d="M12 2a7 7 0 0 1 7 7c0 2.4-1.2 4.5-3 5.7V17a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2v-2.3C6.2 13.5 5 11.4 5 9a7 7 0 0 1 7-7z"/><line x1="10" y1="22" x2="14" y2="22"/></svg>Add Memory</h2>
|
||||
<span style="flex:1"></span>
|
||||
<button id="memory-import-btn" class="theme-io-btn" title="Import memories from a file" style="height:26px;font-size:12px;"><svg width="13" height="13" 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="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>Import</button>
|
||||
<button id="memory-export-btn" class="theme-io-btn" title="Export all memories as JSON" style="height:26px;font-size:12px;"><svg width="13" height="13" 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="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>Export</button>
|
||||
@@ -312,7 +320,7 @@
|
||||
</div>
|
||||
<div class="admin-card">
|
||||
<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;">
|
||||
<h2 style="margin:0;padding:0;line-height:1;"><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"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>Add Skill</h2>
|
||||
<h2 style="margin:0;padding:0;line-height:1;"><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;color:var(--accent, var(--red));"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>Add Skill</h2>
|
||||
</div>
|
||||
<p class="memory-desc doclib-desc" style="margin-top:6px;">Import a skill from GitHub or <a href="https://skills.sh" target="_blank" rel="noopener noreferrer">skills.sh</a> (folder with <code>SKILL.md</code> and optional templates).</p>
|
||||
<div class="memory-add-row" style="margin-top:6px;margin-bottom:10px;">
|
||||
@@ -348,8 +356,9 @@
|
||||
<div class="memory-tab-panel hidden" data-memory-panel="skills">
|
||||
<div class="admin-card" style="display:flex;flex-direction:column;overflow:hidden;flex:1;min-height:0;">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:2px;">
|
||||
<h2 style="margin:0;padding:0;line-height:1;">Skills <span id="skills-count-h2" class="memory-count" style="font-size:0.6em;opacity:0.6;font-weight:normal"></span></h2>
|
||||
<h2 style="display:flex;align-items:center;gap:6px;margin:0;padding:0;line-height:1;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--accent, var(--red));flex-shrink:0;"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>Skills <span id="skills-count-h2" class="memory-count" style="font-size:0.6em;opacity:0.6;font-weight:normal"></span></h2>
|
||||
<span style="flex:1"></span>
|
||||
<span class="admin-toggle-state"></span>
|
||||
<label class="admin-switch" title="Inject relevant skills into chat context"><input type="checkbox" id="skills-enabled-header-toggle" checked /><span class="admin-slider"></span></label>
|
||||
</div>
|
||||
<p class="memory-desc doclib-desc" style="margin-top:6px;">Reusable procedures the AI can call via /skill — sort by confidence to surface the proven ones.</p>
|
||||
@@ -374,8 +383,8 @@
|
||||
<option value="filter:conf70">Confidence ≤ 70%</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
<button id="skills-select-btn" class="memory-toolbar-btn" title="Select multiple skills">Select</button>
|
||||
<button id="skills-audit-btn" class="memory-toolbar-btn" title="Test every skill, auto-fix the weak ones, flag what still fails"><svg width="11" height="11" 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>Audit all</button>
|
||||
<button id="skills-audit-btn" class="memory-toolbar-btn" title="Test every skill, auto-fix the weak ones, flag what still fails"><svg width="11" height="11" viewBox="0 0 24 24" fill="var(--accent, var(--red))" 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>Audit</button>
|
||||
<button id="skills-select-btn" class="memory-toolbar-btn" title="Select multiple skills"><svg class="memory-select-btn-icon" 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:-2px;margin-right:3px;"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/></svg>Select</button>
|
||||
</div>
|
||||
<input type="text" id="skills-search" placeholder="Search skills…" class="memory-search-input" aria-label="Search skills" />
|
||||
</div>
|
||||
@@ -395,34 +404,23 @@
|
||||
<!-- ── Settings tab ── -->
|
||||
<div class="memory-tab-panel hidden" data-memory-panel="settings">
|
||||
<div class="admin-card">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px">
|
||||
<h2 style="margin:0">Auto-extract memories</h2>
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;min-height:32px">
|
||||
<h2 style="margin:0;display:inline-flex;align-items:center;gap:6px"><svg width="13" height="13" viewBox="0 0 24 24" fill="var(--accent, var(--red))" aria-hidden="true"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>Auto-extract memories</h2>
|
||||
<label class="admin-switch" style="flex-shrink:0"><input type="checkbox" id="auto-memory-toggle" checked /><span class="admin-slider"></span></label>
|
||||
</div>
|
||||
<span class="admin-toggle-sub" style="display:block;margin-top:6px">Automatically extract memories from conversations.</span>
|
||||
</div>
|
||||
<div class="admin-card">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px">
|
||||
<h2 style="margin:0">Auto-extract skills</h2>
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;min-height:32px">
|
||||
<h2 style="margin:0;display:inline-flex;align-items:center;gap:6px"><svg width="13" height="13" viewBox="0 0 24 24" fill="var(--accent, var(--red))" aria-hidden="true"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>Auto-extract skills</h2>
|
||||
<label class="admin-switch" style="flex-shrink:0"><input type="checkbox" id="auto-skills-toggle" /><span class="admin-slider"></span></label>
|
||||
</div>
|
||||
<span class="admin-toggle-sub" style="display:block;margin-top:6px;opacity:0.6">Automatically draft reusable skills from your workflows. Audit all can publish passing skills using the threshold below.</span>
|
||||
<span class="admin-toggle-sub" style="display:block;margin-top:6px;opacity:0.6">The library can grow; cleanup retires weak/duplicate skills only after review.</span>
|
||||
</div>
|
||||
<div class="admin-card">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px">
|
||||
<h2 style="margin:0">Inject Skills</h2>
|
||||
</div>
|
||||
<span class="admin-toggle-sub" style="display:block;margin-top:6px;opacity:0.6">Controls how many relevant published or approved skills are added to each agent request.</span>
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:8px">
|
||||
<span class="admin-toggle-sub" style="margin:0">Max skills per request</span>
|
||||
<input type="number" id="skill-max-input" min="0" max="12" step="1" value="3" aria-label="Max skills to inject" style="flex-shrink:0;width:72px;background:var(--input-bg,var(--panel));color:var(--fg);border:1px solid var(--border);border-radius:6px;padding:4px 6px;font-size:12px;text-align:right;font-variant-numeric:tabular-nums" />
|
||||
</div>
|
||||
<span class="admin-toggle-sub" style="display:block;margin-top:6px;opacity:0.5">Set to 0 to disable skill injection.</span>
|
||||
</div>
|
||||
<div class="admin-card">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px">
|
||||
<h2 style="margin:0">Auto-approve skills</h2>
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;min-height:32px">
|
||||
<h2 style="margin:0;display:inline-flex;align-items:center;gap:6px"><svg width="13" height="13" viewBox="0 0 24 24" fill="var(--accent, var(--red))" aria-hidden="true"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>Auto-approve skills</h2>
|
||||
<label class="admin-switch" style="flex-shrink:0"><input type="checkbox" id="auto-approve-skills-toggle" checked /><span class="admin-slider"></span></label>
|
||||
</div>
|
||||
<span class="admin-toggle-sub" style="display:block;margin-top:6px;opacity:0.6">Audit all publishes passing, necessary skills at or above this confidence. Off = keep audit results as drafts unless manually approved.</span>
|
||||
@@ -434,6 +432,17 @@
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-card">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;min-height:32px">
|
||||
<h2 style="margin:0;display:inline-flex;align-items:center;gap:6px"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="opacity:0.7"><path d="M12 5v14"/><polyline points="6 11 12 17 18 11"/><path d="M5 20h14"/></svg>Inject Skills</h2>
|
||||
</div>
|
||||
<span class="admin-toggle-sub" style="display:block;margin-top:6px;opacity:0.6">Controls how many relevant published or approved skills are added to each agent request.</span>
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:8px">
|
||||
<span class="admin-toggle-sub" style="margin:0">Max skills per request</span>
|
||||
<input type="number" id="skill-max-input" min="0" max="12" step="1" value="3" aria-label="Max skills to inject" style="flex-shrink:0;width:72px;background:var(--input-bg,var(--panel));color:var(--fg);border:1px solid var(--border);border-radius:6px;padding:4px 6px;font-size:12px;text-align:right;font-variant-numeric:tabular-nums" />
|
||||
</div>
|
||||
<span class="admin-toggle-sub" style="display:block;margin-top:6px;opacity:0.5">Set to 0 to disable skill injection.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -704,12 +713,9 @@
|
||||
<div class="section-header-flex">
|
||||
<span class="section-title" id="chats-section-title"><svg class="section-icon" width="13" height="13" 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 id="chats-section-label" class="section-title-label">Chats</span><span id="chats-notif-dot" class="sidebar-notif-dot" style="display:none"></span></span>
|
||||
<div style="position:relative; display:inline-block; display:flex; gap:4px; align-items:center;">
|
||||
<button type="button" class="section-header-btn chats-manage-btn" id="chats-library-btn" title="Manage Chats (Library)">
|
||||
<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"/>
|
||||
<path d="M9 7h6M9 11h4"/>
|
||||
</svg>
|
||||
<button type="button" class="section-header-btn list-item-plus-btn chats-manage-btn" id="chats-library-btn" title="Manage Chats (Library)">
|
||||
<span aria-hidden="true" style="display:inline-block;width:13px;height:13px;"></span>
|
||||
<span class="list-item-plus-label">manage</span>
|
||||
</button>
|
||||
<button type="button" class="section-header-btn" id="session-sort-btn" title="Sort sessions">
|
||||
<svg class="sort-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
@@ -726,14 +732,11 @@
|
||||
<div class="dropdown-item sort-option sort-dropdown-item" data-sort="newest">Newest First</div>
|
||||
<div class="dropdown-item sort-option sort-dropdown-item" data-sort="group">By Folder</div>
|
||||
<div class="dropdown-item sort-dropdown-item sort-dropdown-sep" id="auto-sort-sessions-row" style="display:flex;align-items:center;padding:0;">
|
||||
<span id="auto-sort-sessions-btn" style="flex:1;padding:5px 10px;cursor:pointer;display:inline-flex;align-items:center;gap:4px;">
|
||||
<span class="auto-sort-icon"><svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;margin-right:2px;"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg> Tidy</span>
|
||||
<span id="auto-sort-sessions-btn" style="flex:1;padding:5px 10px 5px 4px;cursor:pointer;display:inline-flex;align-items:center;gap:6px;">
|
||||
<span class="auto-sort-icon"><svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg></span>
|
||||
<span>Group</span>
|
||||
<span class="auto-sort-spinner" style="display:none;">Sorting...</span>
|
||||
</span>
|
||||
<button type="button" id="auto-sort-sessions-more" title="Tidy options" aria-label="Tidy options" style="background:none;border:none;border-left:1px solid var(--border);color:inherit;cursor:pointer;padding:5px 8px;font-size:9px;opacity:0.7;"><svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button>
|
||||
</div>
|
||||
<div class="dropdown-item sort-dropdown-item" id="auto-sort-sessions-noai-btn" style="display:none;padding-left:24px;">
|
||||
Tidy <span class="auto-sort-noai-spinner" style="display:none;font-size:9px;opacity:0.6;margin-left:4px;">Cleaning...</span>
|
||||
</div>
|
||||
<div class="dropdown-item rearrange-toggle sort-dropdown-item sort-dropdown-sep" id="session-rearrange-toggle">
|
||||
↑↓ Rearrange <span class="rearrange-check" style="float:right; opacity:0;">•</span>
|
||||
@@ -1330,7 +1333,6 @@
|
||||
</button>
|
||||
<button class="close-btn" aria-label="Close settings">✖</button>
|
||||
</div>
|
||||
<div class="admin-toggle-sub" style="padding:0 12px 8px;opacity:0.6;font-size:11px;">Toggle on/off visibility of tools and modules across the interface.</div>
|
||||
<div class="settings-layout">
|
||||
<div class="settings-sidebar">
|
||||
<!-- Section 1: AI plumbing (Add Models → AI Defaults → Search) -->
|
||||
@@ -1338,6 +1340,10 @@
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><circle cx="6" cy="6" r="1"/><circle cx="6" cy="18" r="1"/></svg>
|
||||
<span>Add Models</span>
|
||||
</button>
|
||||
<button class="settings-nav-item" data-settings-tab="added-models">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
<span>Added Models</span>
|
||||
</button>
|
||||
<button class="settings-nav-item" data-settings-tab="ai">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2a4 4 0 0 0-4 4v2H6a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V10a2 2 0 0 0-2-2h-2V6a4 4 0 0 0-4-4z"/></svg>
|
||||
<span>AI Defaults</span>
|
||||
@@ -1404,14 +1410,21 @@
|
||||
<div class="settings-col">
|
||||
<div class="settings-row">
|
||||
<label class="settings-label">Endpoint</label>
|
||||
<span class="adm-model-logo" id="set-defaultEpSelect-logo" style="display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;flex-shrink:0;opacity:0.9;color:var(--fg);"></span>
|
||||
<select id="set-defaultEpSelect" class="settings-select"></select>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<label class="settings-label">Model</label>
|
||||
<span class="adm-model-logo" id="set-defaultModelSelect-logo" style="display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;flex-shrink:0;opacity:0.9;color:var(--fg);"></span>
|
||||
<select id="set-defaultModelSelect" class="settings-select"></select>
|
||||
</div>
|
||||
<div id="set-defaultFallbacks" class="settings-fallbacks"></div>
|
||||
<button type="button" class="settings-fallback-add" id="set-defaultAddFallback" title="Add a model to try if the one above fails">+ Add fallback</button>
|
||||
<div class="settings-row" style="align-items:flex-start;">
|
||||
<label class="settings-label" style="margin-top:6px;">Fallbacks</label>
|
||||
<div style="flex:1;display:flex;flex-direction:column;gap:6px;">
|
||||
<div id="set-defaultFallbacks" class="settings-fallbacks"></div>
|
||||
<button type="button" class="settings-fallback-add" id="set-defaultAddFallback" title="Add a model to try if the one above fails">+ Add fallback</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="set-defaultChatMsg" style="font-size:11px;color:color-mix(in srgb, var(--fg) 45%, transparent);"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1421,14 +1434,21 @@
|
||||
<div class="settings-col">
|
||||
<div class="settings-row">
|
||||
<label class="settings-label">Endpoint</label>
|
||||
<span class="adm-model-logo" id="set-utilityEpSelect-logo" style="display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;flex-shrink:0;opacity:0.9;color:var(--fg);"></span>
|
||||
<select id="set-utilityEpSelect" class="settings-select"><option value="">—</option></select>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<label class="settings-label">Model</label>
|
||||
<span class="adm-model-logo" id="set-utilityModelSelect-logo" style="display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;flex-shrink:0;opacity:0.9;color:var(--fg);"></span>
|
||||
<select id="set-utilityModelSelect" class="settings-select"><option value="">—</option></select>
|
||||
</div>
|
||||
<div id="set-utilityFallbacks" class="settings-fallbacks"></div>
|
||||
<button type="button" class="settings-fallback-add" id="set-utilityAddFallback" title="Add a model to try if the utility model fails">+ Add fallback</button>
|
||||
<div class="settings-row" style="align-items:flex-start;">
|
||||
<label class="settings-label" style="margin-top:6px;">Fallbacks</label>
|
||||
<div style="flex:1;display:flex;flex-direction:column;gap:6px;">
|
||||
<div id="set-utilityFallbacks" class="settings-fallbacks"></div>
|
||||
<button type="button" class="settings-fallback-add" id="set-utilityAddFallback" title="Add a model to try if the utility model fails">+ Add fallback</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="set-utilityChatMsg" style="font-size:11px;color:color-mix(in srgb, var(--fg) 45%, transparent);"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1438,16 +1458,22 @@
|
||||
<div style="display:flex;flex-direction:column;gap:0.5rem;">
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;">
|
||||
<label class="settings-label">Model</label>
|
||||
<span class="adm-model-logo" id="set-vlModelSelect-logo" style="display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;flex-shrink:0;opacity:0.9;color:var(--fg);"></span>
|
||||
<select id="set-vlModelSelect" class="settings-select"><option value="">Auto-detect</option></select>
|
||||
</div>
|
||||
<div id="set-visionFallbacks" class="settings-fallbacks"></div>
|
||||
<button type="button" class="settings-fallback-add" id="set-visionAddFallback" title="Add a vision model to try if the one above fails">+ Add fallback</button>
|
||||
<div class="settings-row" style="align-items:flex-start;">
|
||||
<label class="settings-label" style="margin-top:6px;">Fallbacks</label>
|
||||
<div style="flex:1;display:flex;flex-direction:column;gap:6px;">
|
||||
<div id="set-visionFallbacks" class="settings-fallbacks"></div>
|
||||
<button type="button" class="settings-fallback-add" id="set-visionAddFallback" title="Add a vision model to try if the one above fails">+ Add fallback</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="set-visionSettingsMsg" style="font-size:11px;color:color-mix(in srgb, var(--fg) 45%, transparent);"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-card">
|
||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><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>Research Model</h2>
|
||||
<div class="admin-toggle-sub" style="margin-bottom:8px">Model used for Deep Research. Falls back to the default chat model if not set.</div>
|
||||
<div class="admin-toggle-sub" style="margin-bottom:8px">Model used for Deep Research, more settings under <a href="#" data-go-settings-tab="search" style="color:var(--accent, var(--red));text-decoration:underline;font-weight:600;">Search →</a></div>
|
||||
<div class="settings-col">
|
||||
<div class="settings-row">
|
||||
<label class="settings-label">Endpoint</label>
|
||||
@@ -1457,48 +1483,17 @@
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<label class="settings-label">Model</label>
|
||||
<span class="adm-model-logo" id="set-researchModel-logo" style="display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;flex-shrink:0;opacity:0.9;color:var(--fg);"></span>
|
||||
<select id="set-researchModel" class="settings-select">
|
||||
<option value="">Same as chat</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<label class="settings-label">Search</label>
|
||||
<select id="set-researchSearch" class="settings-select">
|
||||
<option value="">Same as web search</option>
|
||||
<option value="searxng">SearXNG</option>
|
||||
<option value="duckduckgo">DuckDuckGo</option>
|
||||
<option value="tavily">Tavily</option>
|
||||
<option value="brave">Brave</option>
|
||||
<option value="google">Google</option>
|
||||
<option value="serper">Serper</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<label class="settings-label">Max Tokens</label>
|
||||
<input id="set-researchMaxTokens" type="text" inputmode="numeric" placeholder="8192 (default)" class="settings-select" style="width:120px;">
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<label class="settings-label">Extract Timeout</label>
|
||||
<input id="set-researchExtractTimeout" type="text" inputmode="numeric" placeholder="90 sec" class="settings-select" style="width:120px;">
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<label class="settings-label">Extract Parallel</label>
|
||||
<input id="set-researchExtractConcurrency" type="text" inputmode="numeric" placeholder="3" class="settings-select" style="width:120px;">
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<label class="settings-label">Max Time</label>
|
||||
<input id="set-researchRunTimeout" type="text" inputmode="numeric" placeholder="1800 sec (0 = no limit)" class="settings-select" style="width:120px;">
|
||||
</div>
|
||||
<div id="set-researchMsg" style="font-size:11px;color:color-mix(in srgb, var(--fg) 45%, transparent);"></div>
|
||||
<div id="set-researchMsg" style="font-size:11px;color:color-mix(in srgb, var(--fg) 45%, transparent);margin-top:2px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Agent card moved to the Agent Tools tab. -->
|
||||
<!-- Image Generation removed — only inpaint remains in this build,
|
||||
and inpaint is configured via the gallery editor not this card.
|
||||
Keeping the DOM (hidden) so JS wiring against the inputs
|
||||
doesn't throw. -->
|
||||
<div class="admin-card" hidden style="display:none">
|
||||
<h2 style="display:flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:1px;opacity:0.6;flex-shrink:0"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>Image Generation<span style="flex:1"></span><label class="admin-switch"><input type="checkbox" id="set-imgEnabledToggle" checked><span class="admin-slider"></span></label></h2>
|
||||
<div class="admin-card">
|
||||
<h2 style="display:flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:1px;opacity:0.6;flex-shrink:0"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>Image Generation<span style="flex:1"></span><label class="admin-switch"><input type="checkbox" id="set-imgEnabledToggle"><span class="admin-slider"></span></label></h2>
|
||||
<div class="admin-toggle-sub" style="margin-bottom:8px">Configure which model to use for image generation.</div>
|
||||
<div style="display:flex;flex-direction:column;gap:0.5rem;">
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;">
|
||||
@@ -1570,20 +1565,15 @@
|
||||
<div id="set-ttsSettingsMsg" style="font-size:11px;color:color-mix(in srgb, var(--fg) 45%, transparent);"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Teacher Model settings card hidden as part of the 2.0
|
||||
"harden the core" pass. The escalation flow is dormant when
|
||||
`teacher_model` is unset (its default), so the backend keeps
|
||||
working for anyone who wired it via `manage_settings` /
|
||||
settings backup. Re-add this card to surface the toggle
|
||||
again once the core experience is faster. -->
|
||||
<div class="admin-card">
|
||||
<h2 style="display:flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M22 10v6M2 10l10-5 10 5-10 5z"/><path d="M6 12v5c3 3 9 3 12 0v-5"/></svg>Teacher Model <span style="font-size:0.72em;opacity:0.55;font-weight:normal;">(Experimental)</span><span style="flex:1"></span><label class="admin-switch"><input type="checkbox" id="set-teacherEnabledToggle"><span class="admin-slider"></span></label></h2>
|
||||
<div class="admin-toggle-sub" style="margin-bottom:8px">When a self-hosted student fails an agent-mode task, escalate to a SOTA teacher that writes a SKILL.md procedure so the student can do it next time. Off by default.</div>
|
||||
<div class="settings-col">
|
||||
<div class="settings-row">
|
||||
<label class="settings-label">Endpoint</label>
|
||||
<select id="set-teacherEpSelect" class="settings-select"><option value="">—</option></select>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<label class="settings-label">Model</label>
|
||||
<select id="set-teacherModelSelect" class="settings-select"><option value="">—</option></select>
|
||||
</div>
|
||||
<div id="set-teacherChatMsg" style="font-size:11px;color:color-mix(in srgb, var(--fg) 45%, transparent);"></div>
|
||||
</div>
|
||||
<h2 style="display:flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:1px;opacity:0.6;flex-shrink:0"><rect x="2" y="4" width="20" height="16" rx="2"/><polyline points="2 6 12 13 22 6"/></svg>Email Safety<span style="flex:1"></span><label class="admin-switch" title="When on, agent send_email and reply_to_email tools stage a draft for your approval instead of sending immediately."><input type="checkbox" id="set-agentEmailConfirm" checked><span class="admin-slider"></span></label></h2>
|
||||
<div class="admin-toggle-sub" style="margin-bottom:8px">When on, agent <code>send_email</code> / <code>reply_to_email</code> tools stage a draft for your approval (in the chat) instead of SMTPing immediately. Stops models from inventing a signature and sending it to a real recipient before you can review.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1614,10 +1604,12 @@
|
||||
<option value="serper" data-search-logo="serper">Serper.dev</option>
|
||||
<option value="disabled" data-search-logo="disabled">Disabled</option>
|
||||
</select>
|
||||
<button type="button" class="admin-btn-sm" id="set-searchTestBtn" title="Run a test query against the configured provider" style="margin-left:6px;flex-shrink:0;position:relative;top:2px;">Test</button>
|
||||
<button type="button" class="admin-btn-sm" id="set-searchTestBtn" title="Run a test query against the configured provider" style="margin-left:2px;flex-shrink:0;position:relative;top:2px;display:inline-flex;align-items:center;gap:4px;">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>Test
|
||||
</button>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<label class="settings-label">Results</label>
|
||||
<label class="settings-label" title="How many web search results to fetch per query">Results per query</label>
|
||||
<div style="display:flex;gap:8px;flex:1;">
|
||||
<select id="set-searchResultCount" class="settings-select" style="flex:1;">
|
||||
<option value="3">3</option>
|
||||
@@ -1631,30 +1623,78 @@
|
||||
</div>
|
||||
<div id="set-searchUrlRow" class="settings-row">
|
||||
<label class="settings-label">URL</label>
|
||||
<input id="set-searchUrl" type="text" placeholder="http://localhost:8080" class="settings-select">
|
||||
<input id="set-searchUrl" type="text" placeholder="http://localhost:8080 (optional)" class="settings-select">
|
||||
</div>
|
||||
<div id="set-searchKeyRow" class="settings-row" style="display:none;">
|
||||
<label class="settings-label">API Key</label>
|
||||
<input id="set-searchApiKey" type="password" placeholder="API key" class="settings-select">
|
||||
<div style="position:relative;flex:1;display:flex;align-items:center;">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="position:absolute;left:9px;top:50%;transform:translateY(-50%);opacity:0.55;pointer-events:none;"><path d="M21 2l-9.6 9.6"/><circle cx="7.5" cy="15.5" r="5.5"/><path d="M15.5 7.5l3 3"/></svg>
|
||||
<input id="set-searchApiKey" type="password" placeholder="API key" class="settings-select" style="flex:1;padding-left:28px;">
|
||||
</div>
|
||||
</div>
|
||||
<div id="set-searchCxRow" class="settings-row" style="display:none;">
|
||||
<label class="settings-label">CX ID</label>
|
||||
<input id="set-searchCx" type="text" placeholder="Google PSE engine ID" class="settings-select">
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<label class="settings-label" title="Providers tried in order when the primary fails or hits a rate limit">Fallbacks</label>
|
||||
<div class="search-fallback-chain" id="set-searchFallbackChain"></div>
|
||||
<div class="settings-row" style="align-items:flex-start;">
|
||||
<label class="settings-label" style="margin-top:6px;" title="Providers tried in order when the primary fails or hits a rate limit">Fallbacks</label>
|
||||
<div style="flex:1;display:flex;flex-direction:column;gap:6px;">
|
||||
<div class="settings-fallbacks" id="set-searchFallbackChain"></div>
|
||||
<button type="button" class="settings-fallback-add" id="set-searchAddFallback" title="Add a search provider to try if the primary fails">+ Add fallback</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="set-searchHint" class="admin-toggle-sub"></div>
|
||||
<div id="set-searchMsg" style="font-size:11px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-card">
|
||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><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>Deep Research</h2>
|
||||
<div class="admin-toggle-sub" style="margin-bottom:8px">Deep Research runtime settings. Default Model is picked in <a href="#" data-go-settings-tab="ai" style="color:var(--accent, var(--red));text-decoration:underline;font-weight:600;">AI Defaults →</a></div>
|
||||
<div class="settings-col">
|
||||
<div class="settings-row">
|
||||
<label class="settings-label">Search</label>
|
||||
<span style="margin-left:auto;display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;flex-shrink:0;color:var(--fg);" id="set-researchSearch-logo"></span>
|
||||
<select id="set-researchSearch" class="settings-select" style="width:358.5px;flex:0 0 auto;max-width:calc(100% - 24px);">
|
||||
<option value="" data-search-logo="">Same as web search</option>
|
||||
<option value="searxng" data-search-logo="searxng">SearXNG</option>
|
||||
<option value="duckduckgo" data-search-logo="duckduckgo">DuckDuckGo</option>
|
||||
<option value="tavily" data-search-logo="tavily">Tavily</option>
|
||||
<option value="brave" data-search-logo="brave">Brave</option>
|
||||
<option value="google" data-search-logo="google_pse">Google</option>
|
||||
<option value="serper" data-search-logo="serper">Serper</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<label class="settings-label">Max Tokens</label>
|
||||
<input id="set-researchMaxTokens" type="text" inputmode="numeric" placeholder="8192 (default)" class="settings-select" style="width:382.5px;flex:0 0 auto;margin-left:auto;">
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<label class="settings-label">Extract Timeout</label>
|
||||
<div style="position:relative;width:382.5px;flex:0 0 auto;margin-left:auto;">
|
||||
<input id="set-researchExtractTimeout" type="text" inputmode="numeric" placeholder="90 sec" class="settings-select" style="width:100%;padding-right:30px;">
|
||||
<span title="How long the researcher waits for a single URL to fetch and extract before giving up on it. Slow sites get skipped. Default 90 seconds." style="position:absolute;right:8px;top:50%;transform:translateY(-50%);width:16px;height:16px;border-radius:50%;border:1px solid var(--border);display:inline-flex;align-items:center;justify-content:center;font-size:10px;font-weight:600;opacity:0.55;cursor:help;user-select:none;">?</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<label class="settings-label">Extract Parallel</label>
|
||||
<div style="position:relative;width:382.5px;flex:0 0 auto;margin-left:auto;">
|
||||
<input id="set-researchExtractConcurrency" type="text" inputmode="numeric" placeholder="3" class="settings-select" style="width:100%;padding-right:30px;">
|
||||
<span title="How many URLs the researcher fetches and extracts in parallel. Higher is faster but uses more memory/CPU. Default 3." style="position:absolute;right:8px;top:50%;transform:translateY(-50%);width:16px;height:16px;border-radius:50%;border:1px solid var(--border);display:inline-flex;align-items:center;justify-content:center;font-size:10px;font-weight:600;opacity:0.55;cursor:help;user-select:none;">?</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<label class="settings-label">Timeout</label>
|
||||
<input id="set-researchRunTimeout" type="text" inputmode="numeric" placeholder="1800 sec (0 = no limit)" class="settings-select" style="width:382.5px;flex:0 0 auto;margin-left:auto;">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ APPEARANCE TAB ═══ -->
|
||||
<div data-settings-panel="appearance" class="settings-appearance-panel hidden">
|
||||
<div class="admin-card" style="padding-bottom:6px;">
|
||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="3" x2="9" y2="21"/></svg>Sidebar</h2>
|
||||
<h2 style="display:flex;align-items:center;gap:8px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="3" x2="9" y2="21"/></svg>Sidebar<span style="flex:1"></span><button type="button" class="vis-reset-btn" data-vis-reset title="Reset this section to defaults" aria-label="Reset Sidebar to defaults" style="background:none;border:none;padding:2px 4px;cursor:pointer;color:inherit;opacity:0.55;display:inline-flex;align-items:center;"><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="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg></button></h2>
|
||||
<div class="vis-toggles">
|
||||
<label class="vis-row">
|
||||
<span class="vis-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><circle cx="12" cy="12" r="10"/><path d="M8 12l2.5 2.5L16 9"/></svg></span>
|
||||
@@ -1754,7 +1794,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-card" style="padding-bottom:6px;">
|
||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>Chat Area</h2>
|
||||
<h2 style="display:flex;align-items:center;gap:8px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>Chat Area<span style="flex:1"></span><button type="button" class="vis-reset-btn" data-vis-reset title="Reset this section to defaults" aria-label="Reset Chat Area to defaults" style="background:none;border:none;padding:2px 4px;cursor:pointer;color:inherit;opacity:0.55;display:inline-flex;align-items:center;"><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="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg></button></h2>
|
||||
<div class="vis-toggles">
|
||||
<label class="vis-row">
|
||||
<span class="vis-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 6h16"/><path d="M4 10h8"/></svg></span>
|
||||
@@ -1789,7 +1829,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-card" style="padding-bottom:6px;">
|
||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><line x1="17" y1="10" x2="3" y2="10"/><line x1="21" y1="6" x2="3" y2="6"/><line x1="21" y1="14" x2="3" y2="14"/><line x1="17" y1="18" x2="3" y2="18"/></svg>Chat Bar</h2>
|
||||
<h2 style="display:flex;align-items:center;gap:8px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><line x1="17" y1="10" x2="3" y2="10"/><line x1="21" y1="6" x2="3" y2="6"/><line x1="21" y1="14" x2="3" y2="14"/><line x1="17" y1="18" x2="3" y2="18"/></svg>Chat Bar<span style="flex:1"></span><button type="button" class="vis-reset-btn" data-vis-reset title="Reset this section to defaults" aria-label="Reset Chat Bar to defaults" style="background:none;border:none;padding:2px 4px;cursor:pointer;color:inherit;opacity:0.55;display:inline-flex;align-items:center;"><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="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg></button></h2>
|
||||
<div class="vis-toggles">
|
||||
<label class="vis-row">
|
||||
<span class="vis-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></span>
|
||||
@@ -1833,9 +1873,6 @@
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div style="text-align:right;padding:0 4px;">
|
||||
<button type="button" class="admin-btn-sm" id="set-uiVisResetBtn" style="opacity:0.5;">Reset All</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ THEME TAB ═══ -->
|
||||
@@ -1848,7 +1885,7 @@
|
||||
<h2 style="margin:0;font-size:13px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="M6 8h.01M10 8h.01M14 8h.01M18 8h.01M8 12h.01M12 12h.01M16 12h.01M7 16h10"/></svg>Keyboard Shortcuts</h2>
|
||||
<p style="font-size:10px;opacity:0.4;margin:2px 0 0;">Click a shortcut to rebind. Press Escape to cancel.</p>
|
||||
</div>
|
||||
<button type="button" class="shortcut-action-btn is-reset" id="shortcuts-reset-btn" title="Reset Shortcuts" style="width:28px;height:28px;font-size:15px;">↩</button>
|
||||
<button type="button" class="vis-reset-btn" id="shortcuts-reset-btn" title="Reset shortcuts to defaults" aria-label="Reset shortcuts to defaults" style="background:none;border:none;padding:2px 4px;cursor:pointer;color:inherit;opacity:0.55;display:inline-flex;align-items:center;"><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="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg></button>
|
||||
</div>
|
||||
<div class="admin-card">
|
||||
<div id="shortcuts-list"></div>
|
||||
@@ -1860,7 +1897,7 @@
|
||||
<div data-settings-panel="account" class="hidden">
|
||||
<div class="admin-card">
|
||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>Account</h2>
|
||||
<div style="display:flex;align-items:center;gap:10px;margin:4px 0 12px;">
|
||||
<div style="display:flex;align-items:center;gap:10px;margin:12px 0 12px;">
|
||||
<div class="user-bar-avatar" id="settings-account-avatar" style="width:32px;height:32px;font-size:14px;"></div>
|
||||
<div style="flex:1;">
|
||||
<div id="settings-account-username" style="font-size:13px;font-weight:600;"></div>
|
||||
@@ -1898,7 +1935,7 @@
|
||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>Email Accounts</h2>
|
||||
<div class="settings-row" style="align-items:center;">
|
||||
<div class="admin-toggle-sub" style="margin:0;flex:1;">Add, edit, delete, and test accounts in Integrations.</div>
|
||||
<button class="admin-btn-add" id="set-email-open-integrations" style="display:inline-flex;align-items:center;gap:6px;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="opacity:0.7"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>Manage in Integrations</button>
|
||||
<button class="admin-btn-add" id="set-email-open-integrations" style="display:inline-flex;align-items:center;gap:6px;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="opacity:0.7"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>Open Integrations</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1914,10 +1951,10 @@
|
||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>Writing Style</h2>
|
||||
<div class="admin-toggle-sub" style="margin-bottom:8px">AI-extracted from your sent emails. Used when AI drafts replies.</div>
|
||||
<div class="settings-col">
|
||||
<textarea id="set-email-style" rows="4" class="settings-select" style="font-family:inherit;resize:vertical" placeholder="e.g. I write emails in this style. I don't use exclamation marks. I sign emails with: ..."></textarea>
|
||||
<textarea id="set-email-style" rows="6" class="settings-select" style="font-family:inherit;resize:none" placeholder="e.g. I write emails in this style. I don't use exclamation marks. I sign emails with: ..."></textarea>
|
||||
<div class="settings-row" style="margin-top:4px">
|
||||
<span id="set-email-style-msg" style="font-size:11px;"></span>
|
||||
<button class="admin-btn-add" id="set-email-style-extract" style="margin-left:auto;">Extract from Sent (15 emails)</button>
|
||||
<button class="admin-btn-add" id="set-email-style-extract" style="margin-left:auto;display:inline-flex;align-items:center;gap:5px;"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>Extract from Sent (15 emails)</button>
|
||||
<button class="admin-btn-add" id="set-email-style-save">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1927,7 +1964,7 @@
|
||||
<!-- ═══ REMINDERS TAB ═══ -->
|
||||
<div data-settings-panel="reminders" class="hidden">
|
||||
<div class="admin-card">
|
||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="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>How you're reminded</h2>
|
||||
<h2 style="display:flex;align-items:center;gap:8px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><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>How you're reminded<span style="flex:1"></span><span id="set-reminder-test-msg" style="font-size:11px;font-weight:normal;"></span><button class="admin-btn-sm" id="set-reminder-test-btn" style="font-size:11px;font-weight:normal;display:inline-flex;align-items:center;gap:4px;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>Test</button></h2>
|
||||
<div class="admin-toggle-sub" style="margin-bottom:8px">Controls how fired note reminders are delivered.</div>
|
||||
<div class="settings-col">
|
||||
<div class="settings-row">
|
||||
@@ -1965,7 +2002,19 @@
|
||||
</div>
|
||||
<div class="admin-card">
|
||||
<h2 style="display:flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:1px;opacity:0.6;flex-shrink:0"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>AI Synthesis<span style="flex:1"></span><label class="admin-switch" title="Use the utility model to write reminder messages"><input type="checkbox" id="set-reminder-llm-toggle"><span class="admin-slider"></span></label></h2>
|
||||
<div class="admin-toggle-sub" style="margin-bottom:8px">When on, the utility model writes a short, warm one-line reminder for browser, email, ntfy, AND webhook reminders instead of just the raw note content.</div>
|
||||
<div class="admin-toggle-sub" style="margin-bottom:8px">When on, the utility model writes a short, warm one-line reminder for browser, email, ntfy, and webhook reminders instead of just the raw note content.</div>
|
||||
<div class="settings-col">
|
||||
<div class="settings-row">
|
||||
<label class="settings-label" title="Optional — write the reminder in the voice of a saved character">Persona</label>
|
||||
<select id="set-reminder-llm-persona" class="settings-select" style="flex:1;">
|
||||
<option value="">Default (warm, neutral)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="font-size:11px;opacity:0.7;margin-top:2px;">
|
||||
<a href="#" data-open-prompt-modal style="color:var(--accent, var(--red));text-decoration:underline;font-weight:600;">Edit persona settings here →</a>
|
||||
</div>
|
||||
<div id="set-reminder-llm-persona-msg" style="font-size:11px;color:color-mix(in srgb, var(--fg) 55%, transparent);"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-card">
|
||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>Public App URL</h2>
|
||||
@@ -1978,14 +2027,6 @@
|
||||
<div id="set-app-public-url-msg" style="font-size:11px;color:color-mix(in srgb, var(--fg) 55%, transparent);"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-card">
|
||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>Test</h2>
|
||||
<div class="admin-toggle-sub" style="margin-bottom:8px">Fire a test reminder using your current settings to verify everything works.</div>
|
||||
<div class="settings-row">
|
||||
<span id="set-reminder-test-msg" style="font-size:11px;"></span>
|
||||
<button class="admin-btn-add" id="set-reminder-test-btn" style="margin-left:auto;">Send Test Reminder</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ ADMIN: USERS TAB ═══ -->
|
||||
@@ -2020,75 +2061,96 @@
|
||||
|
||||
<!-- ═══ SERVICES TAB ═══ -->
|
||||
<div data-settings-panel="services">
|
||||
<div class="admin-card">
|
||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><circle cx="6" cy="6" r="1"/><circle cx="6" cy="18" r="1"/></svg>Add Models <span style="opacity:0.45;font-weight:normal;font-size:0.82em">(Endpoints)</span></h2>
|
||||
<div class="admin-toggle-sub" style="margin-bottom:10px">Connect local models first, or add a cloud API.</div>
|
||||
|
||||
<!-- Local subsection -->
|
||||
<div class="adm-add-section collapsible collapsed" id="adm-add-local">
|
||||
<div class="adm-ep-section-head adm-section-toggle" role="button" tabindex="0" aria-expanded="false">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:4px;"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8"/><path d="M12 17v4"/></svg>
|
||||
<span>Local</span>
|
||||
<svg class="adm-section-caret" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
<!-- ── Local card ─────────────────────────────────────────── -->
|
||||
<div class="admin-card">
|
||||
<h2 style="display:flex;align-items:center;gap:8px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8"/><path d="M12 17v4"/></svg>Add Local Models <span style="opacity:0.45;font-weight:normal;font-size:0.82em">(Endpoint)</span>
|
||||
<span style="flex:1"></span>
|
||||
<button class="admin-btn-sm" id="adm-epLocalTestBtn" style="font-size:11px;font-weight:normal;display:inline-flex;align-items:center;gap:4px;">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>Test
|
||||
</button>
|
||||
<div style="position:relative;display:inline-block;">
|
||||
<button class="admin-btn-sm" id="adm-epLocalMoreBtn" title="More options" aria-haspopup="true" aria-expanded="false" style="font-size:11px;font-weight:normal;padding:4px 8px;line-height:1;">
|
||||
<svg width="14" height="4" viewBox="0 0 14 4" fill="currentColor"><circle cx="2" cy="2" r="1.4"/><circle cx="7" cy="2" r="1.4"/><circle cx="12" cy="2" r="1.4"/></svg>
|
||||
</button>
|
||||
<div id="adm-epLocalMoreMenu" style="display:none;position:absolute;top:calc(100% + 4px);right:0;z-index:50;min-width:170px;padding:4px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 6px 20px rgba(0,0,0,0.22);flex-direction:column;gap:1px;">
|
||||
<button class="admin-btn-sm adm-more-item" id="adm-epDiscoverBtn" title="Scan your network for running model servers" style="background:none;border:0;border-radius:5px;padding:7px 9px;display:flex;align-items:center;gap:8px;width:100%;text-align:left;font-size:12px;font-weight:normal;color:var(--fg);cursor:pointer;">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>Scan network
|
||||
</button>
|
||||
<button class="admin-btn-sm adm-more-item" id="adm-epOllamaBtn" title="Fill the default Ollama endpoint" style="background:none;border:0;border-radius:5px;padding:7px 9px;display:flex;align-items:center;gap:8px;width:100%;text-align:left;font-size:12px;font-weight:normal;color:var(--fg);cursor:pointer;"><span class="adm-ollama-logo" style="display:inline-flex;width:13px;height:13px;"></span>Add Ollama</button>
|
||||
<button class="admin-btn-sm adm-more-item" id="adm-epLocalKeyBtn" title="Show / hide the API key field" aria-expanded="false" aria-controls="adm-epLocalApiKey-row" style="background:none;border:0;border-radius:5px;padding:7px 9px;display:flex;align-items:center;gap:8px;width:100%;text-align:left;font-size:12px;font-weight:normal;color:var(--fg);cursor:pointer;">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2l-9.6 9.6"/><circle cx="7.5" cy="15.5" r="5.5"/><path d="M15.5 7.5l3 3"/></svg>API key
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</h2>
|
||||
<div class="admin-toggle-sub" style="margin:0 0 10px 2px;">Add a local model server (Ollama, llama.cpp, vLLM).</div>
|
||||
<div class="adm-add-section">
|
||||
<div class="admin-model-form">
|
||||
<div class="admin-model-form-row">
|
||||
<input id="adm-epLocalUrl" type="text" placeholder="Paste endpoint URL, e.g. http://localhost:11434/v1" style="flex:1">
|
||||
</div>
|
||||
<!-- API key row stays in the DOM but is collapsed until the
|
||||
user clicks the Key button on the action row. Local
|
||||
endpoints rarely need a key; hiding it by default keeps
|
||||
the form a single visual line. -->
|
||||
<div class="admin-model-form-row" id="adm-epLocalApiKey-row" style="display:none;">
|
||||
<input id="adm-epLocalApiKey" type="password" placeholder="API key (optional — for protected local endpoints)" autocomplete="off" style="flex:1">
|
||||
</div>
|
||||
<!-- Action row: LLM/Image type, Quickstart buttons (Scan,
|
||||
Ollama), Key reveal toggle, Test, Add — all inline so
|
||||
the Quickstart fold is gone and Type sits with the
|
||||
primary actions. -->
|
||||
<div class="admin-model-form-row">
|
||||
<label style="display:inline-flex;align-items:center;gap:4px;font-size:11px;opacity:0.6;flex-shrink:0;">Type:<select id="adm-epLocalType" style="padding:5px;width:72px;flex-shrink:0;">
|
||||
<option value="llm" selected>LLM</option>
|
||||
<option value="image">Image</option>
|
||||
</select></label>
|
||||
<button class="admin-btn-sm" id="adm-epDiscoverBtn" title="Scan your network for running model servers" style="display:inline-flex;align-items:center;gap:4px;">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>Scan
|
||||
</button>
|
||||
<button class="admin-btn-sm" id="adm-epOllamaBtn" title="Fill the default Ollama endpoint" style="display:inline-flex;align-items:center;gap:5px;"><span class="adm-ollama-logo" style="display:inline-flex;width:13px;height:13px;"></span>Ollama</button>
|
||||
<span style="flex:1"></span>
|
||||
<button class="admin-btn-sm" id="adm-epLocalKeyBtn" title="Show / hide the API key field" aria-expanded="false" aria-controls="adm-epLocalApiKey-row" style="opacity:0.75;display:inline-flex;align-items:center;gap:4px;">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2l-9.6 9.6"/><circle cx="7.5" cy="15.5" r="5.5"/><path d="M15.5 7.5l3 3"/></svg>API
|
||||
</button>
|
||||
<button class="admin-btn-sm" id="adm-epLocalTestBtn" style="min-width:55px;text-align:center;display:inline-flex;align-items:center;justify-content:center;gap:4px;">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>Test
|
||||
</button>
|
||||
<button class="admin-btn-add" id="adm-epLocalAddBtn" style="min-width:55px;text-align:center;display:inline-flex;align-items:center;justify-content:center;gap:4px;">
|
||||
<div class="adm-fused-group" style="display:flex;flex:1 1 180px;min-width:0;">
|
||||
<select id="adm-epLocalType" style="padding:5px;width:66px;flex-shrink:0;border-top-right-radius:0;border-bottom-right-radius:0;border-right:0;">
|
||||
<option value="llm" selected>LLM</option>
|
||||
<option value="image">Image</option>
|
||||
</select>
|
||||
<input id="adm-epLocalUrl" type="text" placeholder="Paste endpoint URL, e.g. http://localhost:11434/v1" style="flex:1;min-width:0;border-top-left-radius:0;border-bottom-left-radius:0;">
|
||||
</div>
|
||||
<button class="admin-btn-add" id="adm-epLocalAddBtn" style="min-width:55px;text-align:center;display:inline-flex;align-items:center;justify-content:center;gap:4px;flex-shrink:0;">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>Add
|
||||
</button>
|
||||
</div>
|
||||
<div class="admin-model-form-row" id="adm-epLocalApiKey-row" style="display:none;">
|
||||
<input id="adm-epLocalApiKey" type="password" placeholder="API key (optional — for protected local endpoints)" autocomplete="off" style="flex:1">
|
||||
</div>
|
||||
<div id="adm-epLocalMsg" class="adm-ep-inline-msg"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API subsection -->
|
||||
<div class="adm-add-section collapsible collapsed" id="adm-add-api" style="margin-top:14px">
|
||||
<div class="adm-ep-section-head adm-section-toggle" role="button" tabindex="0" aria-expanded="false">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:4px;"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
|
||||
<span>API</span>
|
||||
<svg class="adm-section-caret" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</div>
|
||||
<div class="admin-model-form">
|
||||
<!-- Custom picker (with logos). Hidden native <select> mirrors
|
||||
its value so the existing JS that reads adm-epProvider
|
||||
keeps working unchanged. -->
|
||||
<div class="adm-provider-picker adm-provider-combo" id="adm-provider-picker">
|
||||
<input id="adm-epUrl" type="text" placeholder="Base URL or pick provider" autocomplete="off">
|
||||
<button type="button" class="adm-provider-btn" id="adm-provider-btn" title="Pick provider">
|
||||
<span class="adm-provider-current"><span class="adm-provider-logo"></span><span class="adm-provider-name">Provider</span></span>
|
||||
<svg class="adm-provider-caret" 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>
|
||||
<!-- ── API card ───────────────────────────────────────────── -->
|
||||
<div class="admin-card">
|
||||
<h2 style="display:flex;align-items:center;gap:8px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>Add API Models <span style="opacity:0.45;font-weight:normal;font-size:0.82em">(Endpoint)</span>
|
||||
<span style="flex:1"></span>
|
||||
<button class="admin-btn-sm" id="adm-epApiTestBtn" style="font-size:11px;font-weight:normal;display:inline-flex;align-items:center;gap:4px;">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>Test
|
||||
</button>
|
||||
<button class="admin-btn-sm hidden" id="adm-epApiCancelTestBtn" style="font-size:11px;font-weight:normal;">Cancel</button>
|
||||
<div style="position:relative;display:inline-block;">
|
||||
<button class="admin-btn-sm" id="adm-epApiMoreBtn" title="More options" aria-haspopup="true" aria-expanded="false" style="font-size:11px;font-weight:normal;padding:4px 8px;line-height:1;">
|
||||
<svg width="14" height="4" viewBox="0 0 14 4" fill="currentColor"><circle cx="2" cy="2" r="1.4"/><circle cx="7" cy="2" r="1.4"/><circle cx="12" cy="2" r="1.4"/></svg>
|
||||
</button>
|
||||
<div id="adm-epApiMoreMenu" style="display:none;position:absolute;top:calc(100% + 4px);right:0;z-index:50;min-width:200px;padding:4px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 6px 20px rgba(0,0,0,0.22);flex-direction:column;gap:1px;">
|
||||
<div style="font-size:10px;text-transform:uppercase;letter-spacing:0.5px;opacity:0.55;padding:6px 9px 2px;">Connection mode</div>
|
||||
<button class="admin-btn-sm adm-more-item adm-kind-opt" data-kind="proxy" style="background:none;border:0;border-radius:5px;padding:7px 9px;display:flex;align-items:center;gap:8px;width:100%;text-align:left;font-size:12px;font-weight:normal;color:var(--fg);cursor:pointer;">
|
||||
<svg class="adm-kind-check" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
<span>Proxy</span>
|
||||
<span style="margin-left:auto;opacity:0.5;font-size:10px;">routed via server</span>
|
||||
</button>
|
||||
<button class="admin-btn-sm adm-more-item adm-kind-opt" data-kind="api" style="background:none;border:0;border-radius:5px;padding:7px 9px;display:flex;align-items:center;gap:8px;width:100%;text-align:left;font-size:12px;font-weight:normal;color:var(--fg);cursor:pointer;">
|
||||
<svg class="adm-kind-check" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" style="visibility:hidden;"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
<span>API (direct)</span>
|
||||
<span style="margin-left:auto;opacity:0.5;font-size:10px;">browser→provider</span>
|
||||
</button>
|
||||
<div class="adm-provider-menu hidden" id="adm-provider-menu"></div>
|
||||
</div>
|
||||
</div>
|
||||
</h2>
|
||||
<div class="admin-toggle-sub" style="margin:0 0 10px 2px;">Connect a cloud provider (OpenAI, Anthropic, DeepSeek, OpenRouter, etc.).</div>
|
||||
<div class="adm-add-section">
|
||||
<div class="admin-model-form">
|
||||
<div class="admin-model-form-row">
|
||||
<div class="adm-provider-picker adm-provider-combo" id="adm-provider-picker" style="flex:1 1 220px;min-width:0;margin-bottom:0;">
|
||||
<button type="button" class="adm-provider-btn" id="adm-provider-btn" title="Pick provider" style="border-top-right-radius:0;border-bottom-right-radius:0;border-top-left-radius:6px;border-bottom-left-radius:6px;border-left:1px solid var(--border);border-right:1px solid var(--border);">
|
||||
<span class="adm-provider-current"><span class="adm-provider-logo"></span><span class="adm-provider-name">Provider</span></span>
|
||||
<svg class="adm-provider-caret" 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>
|
||||
<input id="adm-epUrl" type="text" placeholder="Base URL or pick provider" autocomplete="off" style="border-left:0;border-top-left-radius:0;border-bottom-left-radius:0;border-top-right-radius:6px;border-bottom-right-radius:6px;">
|
||||
<div class="adm-provider-menu hidden" id="adm-provider-menu"></div>
|
||||
</div>
|
||||
</div>
|
||||
<select id="adm-epKind" style="display:none">
|
||||
<option value="proxy">proxy</option>
|
||||
<option value="api" selected>api</option>
|
||||
</select>
|
||||
<select id="adm-epProvider" style="display:none">
|
||||
<option value="">Custom URL</option>
|
||||
<option value="https://api.anthropic.com" data-logo="anthropic">Anthropic</option>
|
||||
@@ -2110,41 +2172,27 @@
|
||||
<option value="https://api.z.ai/api/coding/paas/v4" data-logo="zhipu">Z.AI Coding Plan</option>
|
||||
<option value="https://integrate.api.nvidia.com/v1" data-logo="nvidia">NVIDIA</option>
|
||||
</select>
|
||||
<!-- API key row stays in DOM, hidden until Key button is
|
||||
clicked. Mirrors the Local section pattern: most users
|
||||
paste a key via the provider preset flow rather than
|
||||
typing it free-form, so the row only appears on demand. -->
|
||||
<div class="admin-model-form-row" id="adm-epApiKey-row" style="display:none;">
|
||||
<input id="adm-epApiKey" type="password" placeholder="API key" autocomplete="off" style="flex:1">
|
||||
</div>
|
||||
<div class="admin-model-form-row" style="margin-top:-4px;">
|
||||
<select id="adm-epKind" style="padding:5px;width:82px;">
|
||||
<option value="proxy">Proxy</option>
|
||||
<option value="api">API</option>
|
||||
</select>
|
||||
<label style="display:inline-flex;align-items:center;gap:4px;font-size:11px;opacity:0.6;flex-shrink:0;">Type:<select id="adm-epType" style="padding:5px;width:80px;flex-shrink:0;">
|
||||
<option value="llm" selected>LLM</option>
|
||||
<option value="image">Image</option>
|
||||
</select></label>
|
||||
<span style="flex:1"></span>
|
||||
<button class="admin-btn-sm" id="adm-epApiKeyBtn" title="Show / hide the API key field" aria-expanded="false" aria-controls="adm-epApiKey-row" style="opacity:0.75;display:inline-flex;align-items:center;gap:4px;">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2l-9.6 9.6"/><circle cx="7.5" cy="15.5" r="5.5"/><path d="M15.5 7.5l3 3"/></svg>API
|
||||
</button>
|
||||
<button class="admin-btn-sm" id="adm-epApiTestBtn" style="min-width:55px;text-align:center;display:inline-flex;align-items:center;justify-content:center;gap:4px;">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>Test
|
||||
</button>
|
||||
<button class="admin-btn-sm hidden" id="adm-epApiCancelTestBtn" style="width:62px;text-align:center;">Cancel</button>
|
||||
<button class="admin-btn-add" id="adm-epAddBtn" style="min-width:55px;text-align:center;display:inline-flex;align-items:center;justify-content:center;gap:4px;">
|
||||
<div class="admin-model-form-row" id="adm-epApiKey-row">
|
||||
<div style="position:relative;flex:1;display:flex;align-items:center;">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="position:absolute;left:9px;top:50%;transform:translateY(-50%);opacity:0.55;pointer-events:none;"><path d="M21 2l-9.6 9.6"/><circle cx="7.5" cy="15.5" r="5.5"/><path d="M15.5 7.5l3 3"/></svg>
|
||||
<input id="adm-epApiKey" type="password" placeholder="API key, e.g. sk-proj-AbCdEf…" autocomplete="off" style="flex:1;padding-left:28px;height:32px;box-sizing:border-box;">
|
||||
</div>
|
||||
<button class="admin-btn-add" id="adm-epAddBtn" style="height:32px;min-width:55px;text-align:center;display:inline-flex;align-items:center;justify-content:center;gap:4px;flex-shrink:0;box-sizing:border-box;">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>Add
|
||||
</button>
|
||||
</div>
|
||||
<div id="adm-epApiMsg" class="adm-ep-inline-msg"></div>
|
||||
<div id="adm-deviceAuthStatus" class="adm-ep-inline-msg"></div>
|
||||
<div id="adm-deviceAuthStatus" class="adm-ep-inline-msg" style="min-height:0;margin-top:0;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ═══ ADDED MODELS TAB ═══ -->
|
||||
<div data-settings-panel="added-models" class="hidden">
|
||||
<div class="admin-card">
|
||||
<h2 style="display:flex;align-items:center;gap:8px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>Added Models <span style="opacity:0.45;font-weight:normal;font-size:0.82em">(Endpoints)</span>
|
||||
<h2 style="display:flex;align-items:center;gap:8px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><polyline points="20 6 9 17 4 12"/></svg>Added Models <span style="opacity:0.45;font-weight:normal;font-size:0.82em">(Endpoints)</span>
|
||||
<span style="flex:1"></span>
|
||||
<button class="admin-btn-sm" id="adm-epProbeAllBtn" title="Re-test every endpoint and refresh online status" style="font-size:11px;font-weight:normal;display:inline-flex;align-items:center;gap:4px;">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>Probe
|
||||
@@ -2153,20 +2201,18 @@
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/></svg>Clear offline <span id="adm-epOfflineCount" style="opacity:0.6;margin-left:2px;"></span>
|
||||
</button>
|
||||
</h2>
|
||||
<div class="admin-toggle-sub" style="margin-bottom:10px">Manage the endpoints you've added.</div>
|
||||
<div class="admin-toggle-sub" style="margin-bottom:12px">Endpoints you've connected. Probe re-tests them all; Clear offline removes the dead ones.</div>
|
||||
<div class="adm-ep-section">
|
||||
<div class="adm-ep-section-head">
|
||||
<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;"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8"/><path d="M12 17v4"/></svg>
|
||||
<span>Local</span>
|
||||
<div class="adm-ep-section-head" style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;opacity:0.7;margin-bottom:6px;display:inline-flex;align-items:center;gap:5px;">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8"/><path d="M12 17v4"/></svg>Local
|
||||
</div>
|
||||
<div id="adm-epList-local"><div class="admin-empty">Loading...</div></div>
|
||||
</div>
|
||||
<div class="adm-ep-section" style="margin-top:14px">
|
||||
<div class="adm-ep-section-head">
|
||||
<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;"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
|
||||
<span>API</span>
|
||||
<div class="adm-ep-section" style="margin-top:18px;">
|
||||
<div class="adm-ep-section-head" style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;opacity:0.7;margin-bottom:6px;display:inline-flex;align-items:center;gap:5px;">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>API
|
||||
</div>
|
||||
<div id="adm-epList-api"></div>
|
||||
<div id="adm-epList-api"><div class="admin-empty">No API endpoints yet.</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2179,24 +2225,8 @@
|
||||
<div class="admin-toggle-sub" style="margin-bottom:8px">All external service connections in one place.</div>
|
||||
<div id="unified-integrations-list"></div>
|
||||
<div id="unified-intg-form" style="display:none"></div>
|
||||
<div style="text-align:center;padding:8px 0;">
|
||||
<button type="button" class="admin-btn-sm" id="unified-intg-add-btn" style="display:inline-flex;align-items:center;gap:6px;">+ Add Integration<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.7;"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-card admin-only" style="margin-top:12px;">
|
||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>API Tokens</h2>
|
||||
<div class="admin-toggle-sub" style="margin-bottom:8px">Bearer tokens for external integrations (scripts, Codex, headless agent runs). Token value shown ONCE on create — copy it then.</div>
|
||||
<div id="adm-tokenList" style="margin-bottom:8px;"></div>
|
||||
<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:flex-start;">
|
||||
<input type="text" id="adm-tokenName" placeholder="Token name (e.g. agent-test)" class="settings-select" style="flex:1;min-width:160px;">
|
||||
<input type="text" id="adm-tokenScopes" placeholder="scopes (comma-separated, blank = chat)" class="settings-select" style="flex:2;min-width:220px;" title="Allowed: chat, cookbook:read, cookbook:launch, documents:read|write, todos:read|write, email:read|draft|send, calendar:read|write, memory:read|write">
|
||||
<button class="admin-btn-add" id="adm-tokenAddBtn">Create token</button>
|
||||
</div>
|
||||
<div id="adm-tokenMsg" style="font-size:11px;margin-top:6px;"></div>
|
||||
<div id="adm-tokenReveal" style="display:none;margin-top:8px;padding:8px 10px;background:color-mix(in srgb, var(--accent, var(--red)) 12%, transparent);border:1px solid color-mix(in srgb, var(--accent, var(--red)) 35%, transparent);border-radius:6px;">
|
||||
<div style="font-size:11px;font-weight:600;margin-bottom:4px;">Copy now — this is the only time you'll see it:</div>
|
||||
<code id="adm-tokenValue" style="font-family:'Berkeley Mono','SF Mono','Fira Code',monospace;font-size:11px;word-break:break-all;display:block;background:var(--bg);padding:6px 8px;border-radius:4px;margin-bottom:6px;user-select:all;"></code>
|
||||
<button class="admin-btn-sm" id="adm-tokenCopyBtn">Copy</button>
|
||||
<div style="text-align:right;padding:8px 0;">
|
||||
<button type="button" class="admin-btn-add" id="unified-intg-add-btn" style="text-align:center;display:inline-flex;align-items:center;justify-content:center;gap:5px;flex-shrink:0;"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>Add Integration</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2218,10 +2248,6 @@
|
||||
<div id="set-agentMsg" style="font-size:11px;color:color-mix(in srgb, var(--fg) 45%, transparent);"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-card" style="margin-bottom:12px;">
|
||||
<h2 style="display:flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:1px;opacity:0.6;flex-shrink:0"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>Agent loop<span style="flex:1"></span><label class="admin-switch" title="On a failing effectful turn, climb verify → different-method → teacher → stop-and-summarize instead of silently quitting." style="flex-shrink:0"><input type="checkbox" id="set-agentSupervisorLadder"><span class="admin-slider"></span></label></h2>
|
||||
<div class="admin-toggle-sub" style="margin-bottom:8px">Supervisor ladder. When on, every effectful agent turn that claims done is verified; on FAIL the ladder escalates verify → different method → teacher → stop-with-blocker, each rung visible in chat. Teacher rung requires <code>teacher_model</code> to be set.</div>
|
||||
</div>
|
||||
<div class="admin-card" style="margin-bottom:12px;">
|
||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>Built-in Tools</h2>
|
||||
<div class="admin-toggle-sub" style="margin-bottom:8px">Enable or disable tools available to the AI agent.</div>
|
||||
@@ -2232,6 +2258,61 @@
|
||||
<!-- ═══ SYSTEM TAB ═══ -->
|
||||
<div data-settings-panel="system" class="hidden">
|
||||
|
||||
<div class="admin-card" id="settings-system-logs-card">
|
||||
<h2>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="settings-system-logs-svg">
|
||||
<polyline points="4 17 10 11 4 5"></polyline>
|
||||
<line x1="12" y1="19" x2="20" y2="19"></line>
|
||||
</svg>
|
||||
Terminal Logs
|
||||
</h2>
|
||||
<div class="admin-toggle-sub settings-system-logs-toggle-sub">Live diagnostic logs and system output from the Odysseus process.</div>
|
||||
|
||||
<div class="settings-col settings-system-logs-col">
|
||||
<!-- Controls row -->
|
||||
<div class="settings-system-logs-controls">
|
||||
<!-- Search input -->
|
||||
<input type="text" id="log-search-input" placeholder="Search logs..." class="settings-system-logs-search">
|
||||
|
||||
<!-- Level select -->
|
||||
<select id="log-level-select" class="settings-system-logs-select">
|
||||
<option value="ALL">All Levels</option>
|
||||
<option value="INFO">INFO</option>
|
||||
<option value="WARNING">WARNING</option>
|
||||
<option value="ERROR">ERROR</option>
|
||||
<option value="DEBUG">DEBUG</option>
|
||||
</select>
|
||||
|
||||
<!-- Limit select -->
|
||||
<select id="log-limit-select" class="settings-system-logs-select">
|
||||
<option value="100">100 lines</option>
|
||||
<option value="200" selected>200 lines</option>
|
||||
<option value="500">500 lines</option>
|
||||
<option value="1000">1000 lines</option>
|
||||
</select>
|
||||
|
||||
<!-- Refresh Button -->
|
||||
<button type="button" class="admin-btn-sm" id="log-refresh-btn">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="settings-system-logs-refresh-svg"><path d="M21.5 2v6h-6M21.34 15.57a10 10 0 1 1-.57-8.38l5.67-5.67"/></svg>
|
||||
Refresh
|
||||
</button>
|
||||
|
||||
<!-- Auto-refresh switch -->
|
||||
<div class="settings-system-logs-autopoll-container">
|
||||
<label class="admin-switch" title="Auto-polling every 3 seconds">
|
||||
<input type="checkbox" id="log-auto-refresh-toggle">
|
||||
<span class="admin-slider"></span>
|
||||
</label>
|
||||
<span>Auto-poll</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Console container -->
|
||||
<div id="log-console-container">
|
||||
<div class="settings-system-logs-placeholder">Initializing logs terminal viewer...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-card">
|
||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>Data Backup</h2>
|
||||
<div class="admin-toggle-sub" style="margin-bottom:8px">Export or import your user data (memories, presets, settings, skills, preferences) as a JSON file.</div>
|
||||
@@ -2248,68 +2329,76 @@
|
||||
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<div>
|
||||
<div class="admin-toggle-label">Wipe all chats</div>
|
||||
<div class="admin-toggle-label">Delete all chats</div>
|
||||
<div class="admin-toggle-sub">Every session, message, and chat history. Documents/notes/etc. stay.</div>
|
||||
</div>
|
||||
<button class="admin-btn-delete" data-wipe-kind="chats" style="white-space:nowrap;">Wipe</button>
|
||||
<button class="admin-btn-delete" data-wipe-kind="chats" title="Delete all chats" aria-label="Delete all chats" style="display:inline-flex;align-items:center;gap:5px;white-space:nowrap;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>Delete</button>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:8px;">
|
||||
<div>
|
||||
<div class="admin-toggle-label">Wipe all memory</div>
|
||||
<div class="admin-toggle-label">Delete all memory</div>
|
||||
<div class="admin-toggle-sub">Clears `memory.json`, the Memory table, and the vector store. Skills not affected.</div>
|
||||
</div>
|
||||
<button class="admin-btn-delete" data-wipe-kind="memory" style="white-space:nowrap;">Wipe</button>
|
||||
<button class="admin-btn-delete" data-wipe-kind="memory" title="Delete all memory" aria-label="Delete all memory" style="display:inline-flex;align-items:center;gap:5px;white-space:nowrap;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>Delete</button>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:8px;">
|
||||
<div>
|
||||
<div class="admin-toggle-label">Wipe all skills</div>
|
||||
<div class="admin-toggle-label">Delete all skills</div>
|
||||
<div class="admin-toggle-sub">Drops `data/skills/` (all SKILL.md files). Memory not affected.</div>
|
||||
</div>
|
||||
<button class="admin-btn-delete" data-wipe-kind="skills" style="white-space:nowrap;">Wipe</button>
|
||||
<button class="admin-btn-delete" data-wipe-kind="skills" title="Delete all skills" aria-label="Delete all skills" style="display:inline-flex;align-items:center;gap:5px;white-space:nowrap;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>Delete</button>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:8px;">
|
||||
<div>
|
||||
<div class="admin-toggle-label">Wipe all notes</div>
|
||||
<div class="admin-toggle-label">Delete all notes</div>
|
||||
<div class="admin-toggle-sub">Every note, todo, and checklist.</div>
|
||||
</div>
|
||||
<button class="admin-btn-delete" data-wipe-kind="notes" style="white-space:nowrap;">Wipe</button>
|
||||
<button class="admin-btn-delete" data-wipe-kind="notes" title="Delete all notes" aria-label="Delete all notes" style="display:inline-flex;align-items:center;gap:5px;white-space:nowrap;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>Delete</button>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:8px;">
|
||||
<div>
|
||||
<div class="admin-toggle-label">Wipe all tasks</div>
|
||||
<div class="admin-toggle-label">Delete all tasks</div>
|
||||
<div class="admin-toggle-sub">Every scheduled task and its run history (Tasks tool).</div>
|
||||
</div>
|
||||
<button class="admin-btn-delete" data-wipe-kind="tasks" style="white-space:nowrap;">Wipe</button>
|
||||
<button class="admin-btn-delete" data-wipe-kind="tasks" title="Delete all tasks" aria-label="Delete all tasks" style="display:inline-flex;align-items:center;gap:5px;white-space:nowrap;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>Delete</button>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:8px;">
|
||||
<div>
|
||||
<div class="admin-toggle-label">Wipe all documents</div>
|
||||
<div class="admin-toggle-label">Delete all documents</div>
|
||||
<div class="admin-toggle-sub">Every document and version. Drafts, exports, library — all gone.</div>
|
||||
</div>
|
||||
<button class="admin-btn-delete" data-wipe-kind="documents" style="white-space:nowrap;">Wipe</button>
|
||||
<button class="admin-btn-delete" data-wipe-kind="documents" title="Delete all documents" aria-label="Delete all documents" style="display:inline-flex;align-items:center;gap:5px;white-space:nowrap;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>Delete</button>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:8px;">
|
||||
<div>
|
||||
<div class="admin-toggle-label">Wipe all gallery</div>
|
||||
<div class="admin-toggle-label">Delete all gallery</div>
|
||||
<div class="admin-toggle-sub">Every image record and the upload directory on disk.</div>
|
||||
</div>
|
||||
<button class="admin-btn-delete" data-wipe-kind="gallery" style="white-space:nowrap;">Wipe</button>
|
||||
<button class="admin-btn-delete" data-wipe-kind="gallery" title="Delete all gallery" aria-label="Delete all gallery" style="display:inline-flex;align-items:center;gap:5px;white-space:nowrap;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>Delete</button>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:8px;">
|
||||
<div>
|
||||
<div class="admin-toggle-label">Wipe all calendar</div>
|
||||
<div class="admin-toggle-label">Delete all calendar</div>
|
||||
<div class="admin-toggle-sub">Every event and every calendar (incl. CalDAV-synced ones; resync to restore).</div>
|
||||
</div>
|
||||
<button class="admin-btn-delete" data-wipe-kind="calendar" style="white-space:nowrap;">Wipe</button>
|
||||
<button class="admin-btn-delete" data-wipe-kind="calendar" title="Delete all calendar" aria-label="Delete all calendar" style="display:inline-flex;align-items:center;gap:5px;white-space:nowrap;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>Delete</button>
|
||||
</div>
|
||||
|
||||
<hr style="border:0;border-top:1px solid color-mix(in srgb, #e55 25%, var(--border));margin:14px 0 10px;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<div>
|
||||
<div class="admin-toggle-label" style="color:#e55;">Delete everything</div>
|
||||
<div class="admin-toggle-sub">All eight categories above, in one go. Same effect as wiping each one in sequence.</div>
|
||||
</div>
|
||||
<button class="admin-btn-delete" data-wipe-kind="__all__" title="Delete every category" aria-label="Delete everything" style="display:inline-flex;align-items:center;gap:5px;white-space:nowrap;font-weight:600;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>Delete All</button>
|
||||
</div>
|
||||
<div id="adm-wipeMsg" style="margin-top:8px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+558
-70
@@ -3,7 +3,7 @@
|
||||
|
||||
import uiModule from './ui.js';
|
||||
import settingsModule from './settings.js';
|
||||
import { providerLogo } from './providers.js';
|
||||
import { providerLogo, providerLogoFromUrl } from './providers.js';
|
||||
import { sortModelObjects } from './modelSort.js';
|
||||
import { PROVIDER_DEVICE_FLOWS, formatDeviceFlowError, runProviderDeviceFlow } from './providerDeviceFlow.js';
|
||||
|
||||
@@ -55,6 +55,7 @@ async function loadUsers() {
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<button class="admin-btn-sm" data-adm-toggle-admin="${esc(u.username)}" data-make-admin="${u.is_admin ? '0' : '1'}" style="font-size:11px;">${u.is_admin ? 'Revoke admin' : 'Make admin'}</button>
|
||||
<button class="admin-btn-sm" data-adm-rename-user="${esc(u.username)}" style="font-size:11px;">Rename</button>
|
||||
${u.is_admin ? '' : `<button class="admin-btn-delete" data-adm-del-user="${esc(u.username)}" style="font-size:11px;">Remove</button>`}
|
||||
${u.is_admin ? '' : '<svg class="admin-user-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.3;transition:transform 0.2s,opacity 0.2s;"><polyline points="6 9 12 15 18 9"/></svg>'}
|
||||
@@ -113,7 +114,7 @@ async function loadUsers() {
|
||||
// Toggle panel visibility + rotate chevron + load models
|
||||
let _modelsLoaded = false;
|
||||
header.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.admin-btn-delete, [data-adm-rename-user]')) return;
|
||||
if (e.target.closest('.admin-btn-delete, [data-adm-rename-user], [data-adm-toggle-admin]')) return;
|
||||
privPanel.classList.toggle('hidden');
|
||||
const chevron = header.querySelector('.admin-user-chevron');
|
||||
if (chevron) {
|
||||
@@ -199,6 +200,42 @@ async function loadUsers() {
|
||||
});
|
||||
}
|
||||
|
||||
// Promote / demote (admin toggle) — present on every row
|
||||
const adminToggleBtn = row.querySelector('[data-adm-toggle-admin]');
|
||||
if (adminToggleBtn) {
|
||||
adminToggleBtn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
const username = adminToggleBtn.dataset.admToggleAdmin;
|
||||
const makeAdmin = adminToggleBtn.dataset.makeAdmin === '1';
|
||||
const confirmMsg = makeAdmin
|
||||
? `Grant admin rights to "${username}"? They'll get full access to all settings and users — including the power to demote or remove other admins (you included).`
|
||||
: `Revoke admin rights from "${username}"? They'll lose access to the admin panel.`;
|
||||
if (!await uiModule.styledConfirm(confirmMsg, { confirmText: makeAdmin ? 'Make admin' : 'Revoke admin', danger: !makeAdmin })) return;
|
||||
adminToggleBtn.disabled = true;
|
||||
try {
|
||||
const res = await fetch(`/api/auth/users/${encodeURIComponent(username)}/admin`, {
|
||||
method: 'PUT',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ is_admin: makeAdmin }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
uiModule.showError(data.detail || 'Failed to change admin status');
|
||||
adminToggleBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
// Demoting yourself drops your own admin access — reload into the
|
||||
// normal-user view (mirrors the rename-self reload above).
|
||||
if (data.self) { window.location.reload(); return; }
|
||||
loadUsers();
|
||||
} catch (err) {
|
||||
uiModule.showError('Failed to change admin status');
|
||||
adminToggleBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
list.appendChild(row);
|
||||
});
|
||||
} catch (e) { list.innerHTML = '<div class="admin-error">Failed to load users</div>'; }
|
||||
@@ -449,13 +486,14 @@ async function loadEndpoints() {
|
||||
return `
|
||||
<div class="admin-user-row${ep.is_enabled ? '' : ' admin-ep-disabled'}${justAddedClass}" data-adm-ep-id="${ep.id}">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;${hasModels ? 'cursor:pointer;' : ''}padding:4px 0;" data-adm-ep-header="${ep.id}">
|
||||
<div class="admin-user-info" style="flex:1;flex-wrap:wrap;gap:0.3rem;">
|
||||
<div class="admin-user-info" style="flex:1;flex-wrap:wrap;gap:0.3rem;align-items:center;">
|
||||
<span class="adm-ep-row-logo" style="display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;flex-shrink:0;opacity:0.9;">${providerLogoFromUrl(ep.base_url) || ''}</span>
|
||||
<span class="admin-user-name">${esc(ep.name)}</span>
|
||||
${ep.model_type === 'image' ? '<span class="admin-badge" style="background:color-mix(in srgb, var(--accent) 20%, transparent);color:var(--accent);">Image</span>' : ''}
|
||||
${kindLabel ? `<span class="admin-badge">${esc(kindLabel)}</span>` : ''}
|
||||
${statusBadge}
|
||||
${ep.is_enabled ? '' : '<span class="admin-badge admin-badge-off">disabled</span>'}
|
||||
${hasModels ? '<span style="font-size:10px;opacity:0.4;">Click to manage models</span>' : ''}
|
||||
${hasModels ? `<span style="font-size:10px;opacity:0.4;${category === 'api' ? 'flex-basis:100%;' : ''}">Click to manage models</span>` : ''}
|
||||
</div>
|
||||
<div style="display:flex;gap:4px;align-items:center;">
|
||||
<button class="admin-btn-sm" data-adm-toggle-ep="${ep.id}">${ep.is_enabled ? 'Disable' : 'Enable'}</button>
|
||||
@@ -828,6 +866,14 @@ function initEndpointForm() {
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!picker.contains(e.target)) pickerMenu.classList.add('hidden');
|
||||
});
|
||||
// Capture-phase Esc: dismiss the picker menu without bubbling to the
|
||||
// settings-modal handler that would otherwise close the whole modal.
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key !== 'Escape') return;
|
||||
if (pickerMenu.classList.contains('hidden')) return;
|
||||
e.stopPropagation();
|
||||
pickerMenu.classList.add('hidden');
|
||||
}, { capture: true });
|
||||
}
|
||||
|
||||
provider.addEventListener('change', () => {
|
||||
@@ -1022,14 +1068,15 @@ function initEndpointForm() {
|
||||
if (d.id) _recentlyAddedEpId = String(d.id);
|
||||
await loadEndpoints();
|
||||
await _selectAddedModelInChat(d);
|
||||
const goLink = ' <a href="#" data-go-added-models style="margin-left:6px;text-decoration:underline;color:inherit;font-weight:600;">Added Models →</a>';
|
||||
if (!d.online) {
|
||||
msg.textContent = 'Added (endpoint offline — will retry on next load)';
|
||||
msg.innerHTML = 'Added (endpoint offline — will retry on next load)' + goLink;
|
||||
msg.className = 'admin-error';
|
||||
} else if (d.status === 'empty') {
|
||||
msg.textContent = 'Added — endpoint reachable, no models found';
|
||||
msg.innerHTML = 'Added — endpoint reachable, no models found' + goLink;
|
||||
msg.className = 'admin-success';
|
||||
} else {
|
||||
msg.textContent = `Added — found ${count} model${count !== 1 ? 's' : ''}`;
|
||||
msg.innerHTML = `Added — found ${count} model${count !== 1 ? 's' : ''}` + goLink;
|
||||
msg.className = 'admin-success';
|
||||
}
|
||||
} else { msg.textContent = d.detail || 'Failed'; msg.className = 'admin-error'; }
|
||||
@@ -1168,7 +1215,125 @@ function initEndpointForm() {
|
||||
});
|
||||
};
|
||||
_wireKeyToggle('adm-epLocalKeyBtn', 'adm-epLocalApiKey-row');
|
||||
_wireKeyToggle('adm-epApiKeyBtn', 'adm-epApiKey-row');
|
||||
|
||||
// Delegated link handler for jumping between settings tabs.
|
||||
// [data-go-added-models] → quick shortcut for the Added Models tab
|
||||
// [data-go-settings-tab="X"] → any tab whose nav button has data-settings-tab="X"
|
||||
// [data-go-scroll-to="#elementId"] → after switching, scroll the element into view
|
||||
document.addEventListener('click', (e) => {
|
||||
const explicit = e.target.closest('[data-go-settings-tab]');
|
||||
if (explicit) {
|
||||
e.preventDefault();
|
||||
const tab = explicit.getAttribute('data-go-settings-tab');
|
||||
const scrollTo = explicit.getAttribute('data-go-scroll-to');
|
||||
const btn = document.querySelector(`[data-settings-tab="${tab}"]`);
|
||||
if (btn) btn.click();
|
||||
if (scrollTo) {
|
||||
// Defer to the next frame so the panel has actually become visible
|
||||
// before we try to scroll into it.
|
||||
requestAnimationFrame(() => {
|
||||
const target = document.querySelector(scrollTo);
|
||||
if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
const link = e.target.closest('[data-go-added-models]');
|
||||
if (!link) return;
|
||||
e.preventDefault();
|
||||
const btn = document.querySelector('[data-settings-tab="added-models"]');
|
||||
if (btn) btn.click();
|
||||
});
|
||||
|
||||
// Generic open/close helper for the kebab dropdowns in this card.
|
||||
// Both the Local and API cards use the same shape: an h2-anchored button
|
||||
// with id "<prefix>MoreBtn" toggles a sibling menu with id "<prefix>MoreMenu".
|
||||
// Global Esc handler: close any currently-open kebab menu in the admin
|
||||
// panel regardless of which _wireKebab instance owns it. Belt-and-braces
|
||||
// backup for the per-instance handler below — registered once.
|
||||
if (!document._admKebabEscWired) {
|
||||
document._admKebabEscWired = true;
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key !== 'Escape') return;
|
||||
// Any visible kebab dropdown in the admin panel — match by id pattern
|
||||
// so adding a new kebab elsewhere automatically benefits.
|
||||
const menus = document.querySelectorAll(
|
||||
'#adm-epLocalMoreMenu, #adm-epApiMoreMenu'
|
||||
);
|
||||
let closed = false;
|
||||
menus.forEach((m) => {
|
||||
if (m && m.style.display !== 'none') {
|
||||
m.style.display = 'none';
|
||||
// Sync the associated button's aria-expanded when we can find it.
|
||||
const btn = document.getElementById(m.id.replace('Menu', 'Btn'));
|
||||
if (btn) btn.setAttribute('aria-expanded', 'false');
|
||||
closed = true;
|
||||
}
|
||||
});
|
||||
if (closed) e.stopPropagation();
|
||||
}, { capture: true });
|
||||
}
|
||||
|
||||
const _wireKebab = (btnId, menuId, onItem) => {
|
||||
const btn = el(btnId);
|
||||
const menu = el(menuId);
|
||||
if (!btn || !menu) return;
|
||||
const isOpen = () => menu.style.display !== 'none';
|
||||
const close = () => { menu.style.display = 'none'; btn.setAttribute('aria-expanded', 'false'); };
|
||||
const open = () => { menu.style.display = 'flex'; btn.setAttribute('aria-expanded', 'true'); };
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (isOpen()) close(); else open();
|
||||
});
|
||||
menu.addEventListener('click', (e) => {
|
||||
const item = e.target.closest('.adm-more-item');
|
||||
if (!item) return;
|
||||
if (onItem) onItem(item, e);
|
||||
close();
|
||||
});
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!isOpen()) return;
|
||||
if (e.target.closest('#' + menuId + ', #' + btnId)) return;
|
||||
close();
|
||||
});
|
||||
// Use capture phase so this fires before the settings-modal Esc handler
|
||||
// (which is in bubble phase). stopPropagation prevents the modal from
|
||||
// closing when the user only meant to dismiss this menu.
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && isOpen()) {
|
||||
e.stopPropagation();
|
||||
close();
|
||||
}
|
||||
}, { capture: true });
|
||||
};
|
||||
|
||||
// API card "..." menu: contains the Proxy/API connection-mode toggle.
|
||||
// Sync the visible checkmarks with the hidden #adm-epKind select so
|
||||
// downstream code (which reads kindSel.value) keeps working.
|
||||
(function wireApiKindMenu() {
|
||||
const kind = el('adm-epKind');
|
||||
if (!kind) return;
|
||||
const opts = document.querySelectorAll('#adm-epApiMoreMenu .adm-kind-opt');
|
||||
const sync = () => {
|
||||
opts.forEach((o) => {
|
||||
const check = o.querySelector('.adm-kind-check');
|
||||
if (check) check.style.visibility = (o.dataset.kind === kind.value) ? 'visible' : 'hidden';
|
||||
});
|
||||
};
|
||||
sync();
|
||||
kind.addEventListener('change', sync);
|
||||
_wireKebab('adm-epApiMoreBtn', 'adm-epApiMoreMenu', (item) => {
|
||||
const k = item.dataset.kind;
|
||||
if (!k) return;
|
||||
kind.value = k;
|
||||
kind.dispatchEvent(new Event('change'));
|
||||
});
|
||||
})();
|
||||
|
||||
// Local card "..." kebab: holds Scan network / Ollama / API key reveal.
|
||||
// Item buttons keep their own click handlers; the helper just handles
|
||||
// open/close + outside-click + Esc.
|
||||
_wireKebab('adm-epLocalMoreBtn', 'adm-epLocalMoreMenu');
|
||||
|
||||
// ── Added Models toolbar: Probe + Clear offline ────────────────────
|
||||
// Both buttons act over the currently-rendered endpoint list. The
|
||||
@@ -1180,10 +1345,10 @@ function initEndpointForm() {
|
||||
if (!lbl) return;
|
||||
const n = document.querySelectorAll('[data-adm-ep-id] [data-adm-ep-online="0"]').length;
|
||||
lbl.textContent = n > 0 ? `(${n})` : '';
|
||||
// Keep the button enabled even when there are no offline rows — a
|
||||
// click on the empty case fires a toast instead of feeling dead.
|
||||
// Hide the button entirely when there's nothing offline — no point
|
||||
// showing an action that has nothing to act on.
|
||||
const btn = el('adm-epClearOfflineBtn');
|
||||
if (btn) btn.style.opacity = n === 0 ? '0.55' : '0.85';
|
||||
if (btn) btn.style.display = n === 0 ? 'none' : '';
|
||||
};
|
||||
// Wire after every loadEndpoints() run by patching the render hook —
|
||||
// simplest path: MutationObserver on the two list containers.
|
||||
@@ -1200,7 +1365,17 @@ function initEndpointForm() {
|
||||
probeAllBtn.addEventListener('click', async () => {
|
||||
probeAllBtn.disabled = true;
|
||||
const origHTML = probeAllBtn.innerHTML;
|
||||
probeAllBtn.innerHTML = '<span style="opacity:0.7;">Probing…</span>';
|
||||
let _wp = null;
|
||||
try {
|
||||
const sp = window.spinnerModule || (await import('./spinner.js')).default;
|
||||
_wp = sp.createWhirlpool(11);
|
||||
_wp.element.style.cssText = 'display:inline-flex;width:11px;height:11px;margin:0 4px 0 0;';
|
||||
probeAllBtn.innerHTML = '';
|
||||
probeAllBtn.appendChild(_wp.element);
|
||||
probeAllBtn.appendChild(document.createTextNode('Probing'));
|
||||
} catch (_) {
|
||||
probeAllBtn.innerHTML = '<span style="opacity:0.7;">Probing…</span>';
|
||||
}
|
||||
try {
|
||||
// Hit the bulk local probe (same one the model picker uses).
|
||||
await fetch('/api/model-endpoints/probe-local', { credentials: 'same-origin' }).catch(() => {});
|
||||
@@ -1222,6 +1397,7 @@ function initEndpointForm() {
|
||||
await loadEndpoints();
|
||||
if (uiModule && uiModule.showToast) uiModule.showToast('Endpoint status refreshed', 1800);
|
||||
} finally {
|
||||
if (_wp) { try { _wp.destroy(); } catch (_) {} }
|
||||
probeAllBtn.innerHTML = origHTML;
|
||||
probeAllBtn.disabled = false;
|
||||
}
|
||||
@@ -1292,15 +1468,16 @@ function initEndpointForm() {
|
||||
const localTestBtn = el('adm-epLocalTestBtn');
|
||||
if (localTestBtn) {
|
||||
localTestBtn.addEventListener('click', async () => {
|
||||
const testOriginalHtml = localTestBtn.innerHTML || '>Test';
|
||||
const msg = _endpointMsg('local');
|
||||
msg.textContent = ''; msg.className = '';
|
||||
msg.textContent = ''; msg.className = 'adm-ep-inline-msg';
|
||||
const raw = (el('adm-epLocalUrl').value || '').trim();
|
||||
if (!raw) { msg.textContent = 'Enter a base URL to test'; msg.className = 'admin-error'; return; }
|
||||
const url = _normalizeBaseUrl(raw);
|
||||
const keyEl = el('adm-epLocalApiKey');
|
||||
const apiKey = keyEl ? keyEl.value.trim() : '';
|
||||
localTestBtn.disabled = true;
|
||||
localTestBtn.textContent = 'Testing...';
|
||||
localTestBtn.innerHTML = testOriginalHtml.replace(/>Test\s*$/, '>Testing...');
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('base_url', url);
|
||||
@@ -1313,19 +1490,21 @@ function initEndpointForm() {
|
||||
msg.className = 'admin-error';
|
||||
}
|
||||
localTestBtn.disabled = false;
|
||||
localTestBtn.textContent = 'Test';
|
||||
localTestBtn.innerHTML = testOriginalHtml;
|
||||
});
|
||||
}
|
||||
if (localAddBtn) {
|
||||
localAddBtn.addEventListener('click', async () => {
|
||||
const addOriginalHtml = localAddBtn.innerHTML || '>Add';
|
||||
const msg = _endpointMsg('local');
|
||||
msg.textContent = ''; msg.className = '';
|
||||
msg.textContent = ''; msg.className = 'adm-ep-inline-msg';
|
||||
const raw = (el('adm-epLocalUrl').value || '').trim();
|
||||
if (!raw) { msg.textContent = 'Enter a base URL (e.g. http://localhost:8002/v1)'; msg.className = 'admin-error'; return; }
|
||||
const url = _normalizeBaseUrl(raw);
|
||||
const keyEl = el('adm-epLocalApiKey');
|
||||
const apiKey = keyEl ? keyEl.value.trim() : '';
|
||||
localAddBtn.disabled = true; localAddBtn.textContent = 'Adding...';
|
||||
localAddBtn.disabled = true;
|
||||
localAddBtn.innerHTML = addOriginalHtml.replace(/>Add\s*$/, '>Adding...');
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('base_url', url);
|
||||
@@ -1345,15 +1524,17 @@ function initEndpointForm() {
|
||||
await loadEndpoints();
|
||||
await _selectAddedModelInChat(d);
|
||||
const count = (d.models || []).length;
|
||||
msg.textContent = d.status === 'empty'
|
||||
const baseText = d.status === 'empty'
|
||||
? 'Added — Ollama is running, no models pulled yet'
|
||||
: d.online
|
||||
? `Added — found ${count} model${count !== 1 ? 's' : ''}`
|
||||
: 'Added (offline — will retry on next load)';
|
||||
msg.innerHTML = `${baseText} <a href="#" data-go-added-models style="margin-left:6px;text-decoration:underline;color:inherit;font-weight:600;">Added Models →</a>`;
|
||||
msg.className = d.online ? 'admin-success' : 'admin-error';
|
||||
} else { msg.textContent = d.detail || 'Failed'; msg.className = 'admin-error'; }
|
||||
} catch (e) { msg.textContent = 'Request failed'; msg.className = 'admin-error'; }
|
||||
localAddBtn.disabled = false; localAddBtn.textContent = 'Add';
|
||||
localAddBtn.disabled = false;
|
||||
localAddBtn.innerHTML = addOriginalHtml;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1379,10 +1560,7 @@ function initEndpointForm() {
|
||||
discoverBtn.addEventListener('click', async () => {
|
||||
const msg = _endpointMsg('local');
|
||||
discoverBtn.disabled = true;
|
||||
// Keep the button's icon as-is while scanning; the whirlpool +
|
||||
// status text below is enough feedback. (Two spinning indicators
|
||||
// at once looks busy.)
|
||||
msg.className = '';
|
||||
msg.className = 'adm-ep-inline-msg';
|
||||
msg.innerHTML = '';
|
||||
try {
|
||||
const sp = window.spinnerModule || (await import('./spinner.js')).default;
|
||||
@@ -1393,7 +1571,7 @@ function initEndpointForm() {
|
||||
wrap.appendChild(wp.element);
|
||||
const txt = document.createElement('span');
|
||||
txt.textContent = 'Scanning ports 8000-8020 and 11434 for model servers...';
|
||||
txt.style.cssText = 'font-size:12px;opacity:0.7;';
|
||||
txt.style.cssText = 'opacity:0.7;';
|
||||
wrap.appendChild(txt);
|
||||
msg.appendChild(wrap);
|
||||
discoverBtn._wp = wp;
|
||||
@@ -1444,30 +1622,6 @@ function initEndpointForm() {
|
||||
});
|
||||
}
|
||||
|
||||
// Collapsible Add-Models subsections (API / Local). Both start collapsed
|
||||
// so the card is compact; the last-used state is remembered per section
|
||||
// in localStorage so a frequent API-adder doesn't re-expand every time.
|
||||
document.querySelectorAll('#adm-add-api, #adm-add-local').forEach((sec) => {
|
||||
const head = sec.querySelector('.adm-section-toggle');
|
||||
if (!head) return;
|
||||
const key = 'odysseus.addModels.' + sec.id + '.open';
|
||||
let open = false;
|
||||
try { open = localStorage.getItem(key) === '1'; } catch {}
|
||||
const apply = () => {
|
||||
sec.classList.toggle('collapsed', !open);
|
||||
head.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
};
|
||||
apply();
|
||||
const toggle = () => {
|
||||
open = !open;
|
||||
try { localStorage.setItem(key, open ? '1' : '0'); } catch {}
|
||||
apply();
|
||||
};
|
||||
head.addEventListener('click', toggle);
|
||||
head.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); }
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('.adm-quickstart-section').forEach((sec) => {
|
||||
const head = sec.querySelector('.adm-quickstart-toggle');
|
||||
if (!head) return;
|
||||
@@ -2183,28 +2337,126 @@ function initRag() {
|
||||
/* ═══════════════════════════════════════════
|
||||
SYSTEM TAB — Tokens
|
||||
═══════════════════════════════════════════ */
|
||||
// Catalog mirrors the one in settings.js integration form. Keep keys in
|
||||
// sync with the backend scope allowlist.
|
||||
const _TOKEN_SCOPES = [
|
||||
{ key: 'todos:read', label: 'Todos read', detail: 'Read notes and checklists' },
|
||||
{ key: 'todos:write', label: 'Todos write', detail: 'Create, update, delete, and toggle todo items' },
|
||||
{ key: 'documents:read', label: 'Documents read', detail: 'Read documents when a document API is enabled' },
|
||||
{ key: 'documents:write', label: 'Documents write', detail: 'Create and update draft documents' },
|
||||
{ key: 'email:read', label: 'Email read', detail: 'Read email when an email API is enabled' },
|
||||
{ key: 'email:draft', label: 'Email draft', detail: 'Create email reply drafts without sending' },
|
||||
{ key: 'email:send', label: 'Email send', detail: 'Send email directly' },
|
||||
{ key: 'calendar:read', label: 'Calendar read', detail: 'Read calendar events when enabled' },
|
||||
{ key: 'calendar:write', label: 'Calendar write', detail: 'Create and update calendar events' },
|
||||
{ key: 'memory:read', label: 'Memory read', detail: 'Read memory when enabled' },
|
||||
{ key: 'memory:write', label: 'Memory write', detail: 'Write memory when enabled' },
|
||||
{ key: 'cookbook:read', label: 'Cookbook read', detail: 'List cookbook tasks + tail their tmux output' },
|
||||
{ key: 'cookbook:launch', label: 'Cookbook launch', detail: 'Launch and stop cookbook serve tasks' },
|
||||
];
|
||||
|
||||
function _renderTokenScopeRows(t) {
|
||||
const have = new Set(t.scopes || []);
|
||||
return _TOKEN_SCOPES.map(s => {
|
||||
const action = (s.key.split(':')[1] || '').toLowerCase();
|
||||
const pill = action === 'read'
|
||||
? 'background:rgba(150,150,150,0.18);color:var(--fg-muted,#888);'
|
||||
: 'background:color-mix(in srgb, var(--accent, var(--red)) 18%, transparent);color:var(--accent, var(--red));';
|
||||
const tool = s.label.replace(/\s+(read|write|draft|send|launch)$/i, '');
|
||||
return `
|
||||
<label style="display:flex;align-items:center;gap:8px;min-height:28px;padding:1px 0;">
|
||||
<span class="settings-label" style="width:90px;flex-shrink:0;padding:0;font-size:12px;">${esc(tool)}</span>
|
||||
<span style="font-size:9px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;padding:1px 7px;border-radius:999px;flex-shrink:0;min-width:44px;text-align:center;box-sizing:border-box;${pill}">${esc(action)}</span>
|
||||
<span style="font-size:11px;line-height:1.35;opacity:0.62;flex:1;min-width:0;">${esc(s.detail)}</span>
|
||||
<label class="admin-switch" style="margin-left:auto;flex-shrink:0;"><input type="checkbox" class="adm-tok-scope" data-token-id="${esc(t.id)}" data-scope="${esc(s.key)}" ${have.has(s.key) ? 'checked' : ''}><span class="admin-slider"></span></label>
|
||||
</label>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function loadTokens() {
|
||||
const list = el('adm-tokenList');
|
||||
if (!list) return;
|
||||
try {
|
||||
const res = await fetch('/api/tokens', { credentials: 'same-origin' });
|
||||
const tokens = await res.json();
|
||||
if (!tokens.length) { list.innerHTML = '<div class="admin-empty">No API tokens</div>'; return; }
|
||||
if (!tokens.length) { list.innerHTML = '<div class="admin-empty" style="color:var(--accent, var(--red));opacity:0.7;font-size:10px;">No API tokens</div>'; return; }
|
||||
list.innerHTML = tokens.map(t => `
|
||||
<div class="admin-user-row">
|
||||
<div class="admin-user-info" style="flex:1;flex-wrap:wrap;gap:0.3rem;">
|
||||
<span class="admin-user-name">${esc(t.name)}</span>
|
||||
<span class="admin-badge">${esc(t.token_prefix)}...</span>
|
||||
<span class="admin-badge" title="Allowed API scopes">${esc((t.scopes || ['chat']).join(', '))}</span>
|
||||
${t.owner ? `<span style="font-size:0.75rem;opacity:0.5;">Owner: ${esc(t.owner)}</span>` : ''}
|
||||
${t.last_used_at ? `<span style="font-size:0.75rem;opacity:0.5;">Last used: ${new Date(t.last_used_at).toLocaleDateString()}</span>` : '<span style="font-size:0.75rem;opacity:0.4;">Never used</span>'}
|
||||
<div class="admin-user-row" data-adm-tok-row="${esc(t.id)}" style="display:block;">
|
||||
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
|
||||
<div class="admin-user-info" style="flex:1;min-width:0;flex-wrap:wrap;gap:0.3rem;">
|
||||
<input type="text" class="adm-tok-rename" data-token-id="${esc(t.id)}" value="${esc(t.name || '')}" placeholder="Token name" style="font-size:13px;font-weight:600;padding:3px 6px;background:transparent;border:1px solid transparent;border-radius:4px;min-width:160px;" title="Click to rename">
|
||||
<span class="admin-badge">${esc(t.token_prefix)}...</span>
|
||||
${t.owner ? `<span style="font-size:0.75rem;opacity:0.5;">Owner: ${esc(t.owner)}</span>` : ''}
|
||||
${t.last_used_at ? `<span style="font-size:0.75rem;opacity:0.5;">Last used: ${new Date(t.last_used_at).toLocaleDateString()}</span>` : '<span style="font-size:0.75rem;opacity:0.4;">Never used</span>'}
|
||||
</div>
|
||||
<button class="admin-btn-sm" data-adm-tok-toggle="${esc(t.id)}" style="opacity:0.75;">Permissions</button>
|
||||
<button class="admin-btn-delete" data-adm-del-token="${esc(t.id)}">Revoke</button>
|
||||
</div>
|
||||
<div data-adm-tok-perm="${esc(t.id)}" style="display:none;margin-top:8px;padding:8px 4px 0;border-top:1px solid var(--border);">
|
||||
${_renderTokenScopeRows(t)}
|
||||
<div class="adm-tok-scope-msg" data-token-id="${esc(t.id)}" style="font-size:11px;min-height:14px;margin-top:4px;"></div>
|
||||
</div>
|
||||
<button class="admin-btn-delete" data-adm-del-token="${t.id}">Revoke</button>
|
||||
</div>`).join('');
|
||||
|
||||
// Revoke
|
||||
list.querySelectorAll('[data-adm-del-token]').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
if (!await uiModule.styledConfirm('Revoke this API token? External integrations using it will stop working.', { confirmText: 'Revoke', danger: true })) return;
|
||||
await fetch(`/api/tokens/${btn.dataset.admDelToken}`, { method: 'DELETE', credentials: 'same-origin' });
|
||||
loadTokens();
|
||||
// Codex / Claude integration cards on the Integrations panel are
|
||||
// backed by these tokens — let them re-render so the deleted token
|
||||
// disappears there too.
|
||||
try { window.dispatchEvent(new CustomEvent('odysseus-integrations-changed')); } catch (_) {}
|
||||
});
|
||||
});
|
||||
// Toggle permissions panel
|
||||
list.querySelectorAll('[data-adm-tok-toggle]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const panel = list.querySelector(`[data-adm-tok-perm="${btn.dataset.admTokToggle}"]`);
|
||||
if (!panel) return;
|
||||
panel.style.display = panel.style.display === 'none' ? '' : 'none';
|
||||
});
|
||||
});
|
||||
// Rename
|
||||
list.querySelectorAll('.adm-tok-rename').forEach(input => {
|
||||
const original = input.value;
|
||||
const commit = async () => {
|
||||
const name = (input.value || '').trim();
|
||||
if (!name || name === original) return;
|
||||
try {
|
||||
const r = await fetch(`/api/tokens/${input.dataset.tokenId}`, {
|
||||
method: 'PATCH', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
if (!r.ok) throw new Error('Save failed');
|
||||
loadTokens();
|
||||
} catch (_) { input.value = original; }
|
||||
};
|
||||
input.addEventListener('blur', commit);
|
||||
input.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); input.blur(); } });
|
||||
});
|
||||
// Scope toggle change → PATCH the whole scopes array for this token.
|
||||
list.querySelectorAll('.adm-tok-scope').forEach(cb => {
|
||||
cb.addEventListener('change', async () => {
|
||||
const tokenId = cb.dataset.tokenId;
|
||||
const panel = list.querySelector(`[data-adm-tok-perm="${tokenId}"]`);
|
||||
const msg = list.querySelector(`.adm-tok-scope-msg[data-token-id="${tokenId}"]`);
|
||||
const scopes = Array.from(panel.querySelectorAll('.adm-tok-scope:checked')).map(input => input.dataset.scope);
|
||||
try {
|
||||
const r = await fetch(`/api/tokens/${tokenId}`, {
|
||||
method: 'PATCH', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ scopes }),
|
||||
});
|
||||
const d = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(d.detail || 'Failed');
|
||||
if (msg) { msg.textContent = 'Saved'; msg.style.color = 'var(--green, #50fa7b)'; setTimeout(() => { msg.textContent = ''; }, 1200); }
|
||||
} catch (err) {
|
||||
cb.checked = !cb.checked;
|
||||
if (msg) { msg.textContent = (err && err.message) || 'Failed'; msg.style.color = 'var(--red)'; }
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (e) { list.innerHTML = '<div class="admin-error">Failed to load tokens</div>'; }
|
||||
@@ -2236,11 +2488,20 @@ function initTokenForm() {
|
||||
else { msg.textContent = data.detail || 'Failed'; msg.className = 'admin-error'; }
|
||||
} catch (e) { msg.textContent = 'Request failed'; msg.className = 'admin-error'; }
|
||||
});
|
||||
const TOKEN_COPY_ICON = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
|
||||
const TOKEN_CHECK_ICON = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
|
||||
el('adm-tokenCopyBtn').addEventListener('click', () => {
|
||||
const val = el('adm-tokenValue').textContent;
|
||||
const btn = el('adm-tokenCopyBtn');
|
||||
navigator.clipboard.writeText(val).then(() => {
|
||||
el('adm-tokenCopyBtn').textContent = 'Copied!';
|
||||
setTimeout(() => { el('adm-tokenCopyBtn').textContent = 'Copy'; }, 2000);
|
||||
btn.innerHTML = TOKEN_CHECK_ICON;
|
||||
btn.style.color = 'var(--accent, var(--red))';
|
||||
btn.style.opacity = '1';
|
||||
setTimeout(() => {
|
||||
btn.innerHTML = TOKEN_COPY_ICON;
|
||||
btn.style.color = '';
|
||||
btn.style.opacity = '0.7';
|
||||
}, 1600);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -2467,33 +2728,258 @@ function initDangerZone() {
|
||||
modalEl.querySelectorAll('[data-wipe-kind]').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const kind = btn.dataset.wipeKind;
|
||||
const label = _LABELS[kind] || kind;
|
||||
if (!await uiModule.styledConfirm(`Wipe ALL ${label}? This cannot be undone.`, { confirmText: 'Wipe', danger: true })) return;
|
||||
if (!await uiModule.styledConfirm(`Really wipe every one of your ${label}?`, { confirmText: 'Yes, wipe everything', danger: true })) return;
|
||||
btn.disabled = true; const prev = btn.textContent; btn.textContent = 'Wiping…';
|
||||
const isAll = kind === '__all__';
|
||||
const label = isAll ? 'data across every category' : (_LABELS[kind] || kind);
|
||||
if (!await uiModule.styledConfirm(`Delete ALL ${label}? This cannot be undone.`, { confirmText: 'Delete', danger: true })) return;
|
||||
if (!await uiModule.styledConfirm(`Really delete every one of your ${label}?`, { confirmText: isAll ? 'Yes, delete everything' : 'Yes, delete everything', danger: true })) return;
|
||||
btn.disabled = true;
|
||||
const prevHtml = btn.innerHTML;
|
||||
btn.innerHTML = isAll ? 'Deleting all…' : 'Deleting…';
|
||||
if (_wipeMsg) { _wipeMsg.textContent = ''; _wipeMsg.className = ''; }
|
||||
try {
|
||||
const res = await fetch(`/api/admin/wipe/${kind}`, { method: 'DELETE', credentials: 'same-origin' });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (res.ok) {
|
||||
if (_wipeMsg) { _wipeMsg.textContent = `Wiped ${data.count ?? 0} ${label}.`; _wipeMsg.className = 'admin-success'; }
|
||||
if (isAll) {
|
||||
// Iterate every known category. Failures in one shouldn't stop
|
||||
// the rest — record per-category counts and surface a summary.
|
||||
const kinds = Object.keys(_LABELS);
|
||||
const results = [];
|
||||
for (const k of kinds) {
|
||||
try {
|
||||
const r = await fetch(`/api/admin/wipe/${k}`, { method: 'DELETE', credentials: 'same-origin' });
|
||||
const d = await r.json().catch(() => ({}));
|
||||
results.push({ k, ok: r.ok, count: d.count ?? 0, error: r.ok ? null : (d.detail || 'failed') });
|
||||
} catch (e) {
|
||||
results.push({ k, ok: false, count: 0, error: e.message });
|
||||
}
|
||||
}
|
||||
const okCount = results.filter(r => r.ok).length;
|
||||
const total = results.reduce((n, r) => n + (r.ok ? r.count : 0), 0);
|
||||
const fails = results.filter(r => !r.ok).map(r => r.k);
|
||||
if (_wipeMsg) {
|
||||
if (!fails.length) {
|
||||
_wipeMsg.textContent = `Deleted ${total} items across all ${okCount} categories.`;
|
||||
_wipeMsg.className = 'admin-success';
|
||||
} else {
|
||||
_wipeMsg.textContent = `Deleted ${total} items; failed: ${fails.join(', ')}.`;
|
||||
_wipeMsg.className = 'admin-error';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (_wipeMsg) { _wipeMsg.textContent = data.detail || 'Failed'; _wipeMsg.className = 'admin-error'; }
|
||||
const res = await fetch(`/api/admin/wipe/${kind}`, { method: 'DELETE', credentials: 'same-origin' });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (res.ok) {
|
||||
if (_wipeMsg) { _wipeMsg.textContent = `Deleted ${data.count ?? 0} ${label}.`; _wipeMsg.className = 'admin-success'; }
|
||||
} else {
|
||||
if (_wipeMsg) { _wipeMsg.textContent = data.detail || 'Failed'; _wipeMsg.className = 'admin-error'; }
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (_wipeMsg) { _wipeMsg.textContent = 'Request failed: ' + e.message; _wipeMsg.className = 'admin-error'; }
|
||||
}
|
||||
btn.disabled = false; btn.textContent = prev;
|
||||
btn.disabled = false; btn.innerHTML = prevHtml;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
TERMINAL LOGS VIEWER
|
||||
═══════════════════════════════════════════ */
|
||||
let logsPollInterval = null;
|
||||
let isLogsPolling = false;
|
||||
let cachedLogs = [];
|
||||
let logsAbortController = null;
|
||||
|
||||
function renderLogs(isAutoPoll = false) {
|
||||
const consoleContainer = el('log-console-container');
|
||||
const levelSelect = el('log-level-select');
|
||||
const searchInput = el('log-search-input');
|
||||
|
||||
if (!consoleContainer) return;
|
||||
|
||||
const levelFilter = levelSelect ? levelSelect.value : 'ALL';
|
||||
const searchQuery = searchInput ? searchInput.value.trim().toLowerCase() : '';
|
||||
|
||||
let logs = cachedLogs;
|
||||
|
||||
// Filter by level locally
|
||||
if (levelFilter !== 'ALL') {
|
||||
logs = logs.filter(line => line.includes(` - ${levelFilter} - `));
|
||||
}
|
||||
|
||||
// Filter by search query locally
|
||||
if (searchQuery) {
|
||||
logs = logs.filter(line => line.toLowerCase().includes(searchQuery));
|
||||
}
|
||||
|
||||
if (logs.length === 0) {
|
||||
consoleContainer.innerHTML = '<div class="settings-system-logs-placeholder">No logs found matching current filters.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Preserve scroll position if user is reading previous logs
|
||||
const atBottom = consoleContainer.scrollHeight - consoleContainer.scrollTop - consoleContainer.clientHeight < 40;
|
||||
|
||||
consoleContainer.innerHTML = logs.map(line => {
|
||||
let levelClass = 'log-line-default';
|
||||
|
||||
if (line.includes(' - INFO - ')) {
|
||||
levelClass = 'log-line-info';
|
||||
} else if (line.includes(' - WARNING - ')) {
|
||||
levelClass = 'log-line-warning';
|
||||
} else if (line.includes(' - ERROR - ') || line.includes(' - CRITICAL - ')) {
|
||||
levelClass = 'log-line-error';
|
||||
} else if (line.includes(' - DEBUG - ')) {
|
||||
levelClass = 'log-line-debug';
|
||||
}
|
||||
|
||||
// XSS safe escape
|
||||
const escaped = line
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
return `<div class="log-line ${levelClass}">${escaped}</div>`;
|
||||
}).join('');
|
||||
|
||||
if (!isAutoPoll || atBottom) {
|
||||
consoleContainer.scrollTop = consoleContainer.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLogs(isAutoPoll = false) {
|
||||
const consoleContainer = el('log-console-container');
|
||||
const limitSelect = el('log-limit-select');
|
||||
|
||||
if (!consoleContainer) return;
|
||||
|
||||
const limit = limitSelect ? limitSelect.value : 200;
|
||||
|
||||
if (logsAbortController) {
|
||||
logsAbortController.abort();
|
||||
}
|
||||
logsAbortController = new AbortController();
|
||||
const { signal } = logsAbortController;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/diagnostics/logs?limit=${limit}`, {
|
||||
credentials: 'same-origin',
|
||||
signal
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
if (!isAutoPoll) {
|
||||
consoleContainer.innerHTML = '';
|
||||
const errDiv = document.createElement('div');
|
||||
errDiv.style.color = 'var(--red)';
|
||||
errDiv.style.fontWeight = '600';
|
||||
errDiv.textContent = `Failed to load logs: HTTP ${res.status}`;
|
||||
consoleContainer.appendChild(errDiv);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (data.status !== 'success' || !data.logs) {
|
||||
if (!isAutoPoll) {
|
||||
consoleContainer.innerHTML = '';
|
||||
const errDiv = document.createElement('div');
|
||||
errDiv.style.color = 'var(--red)';
|
||||
errDiv.style.fontWeight = '600';
|
||||
errDiv.textContent = 'Failed to parse logs data';
|
||||
consoleContainer.appendChild(errDiv);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
cachedLogs = data.logs;
|
||||
renderLogs(isAutoPoll);
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') {
|
||||
return; // Silently ignore deliberate abort
|
||||
}
|
||||
if (!isAutoPoll) {
|
||||
consoleContainer.innerHTML = '';
|
||||
const errDiv = document.createElement('div');
|
||||
errDiv.style.color = 'var(--red)';
|
||||
errDiv.style.fontWeight = '600';
|
||||
errDiv.textContent = `Error retrieving logs: ${err.message}`;
|
||||
consoleContainer.appendChild(errDiv);
|
||||
}
|
||||
} finally {
|
||||
if (logsAbortController?.signal === signal) {
|
||||
logsAbortController = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startLogsPolling() {
|
||||
if (isLogsPolling) return;
|
||||
isLogsPolling = true;
|
||||
const toggle = el('log-auto-refresh-toggle');
|
||||
if (toggle) toggle.checked = true;
|
||||
|
||||
logsPollInterval = setInterval(() => {
|
||||
const modal = el('settings-modal');
|
||||
const systemPanel = el('settings-modal')?.querySelector('[data-settings-panel="system"]');
|
||||
|
||||
// Safe self-cleanup if modal or panel is hidden/closed
|
||||
if (!modal || modal.classList.contains('hidden') || !systemPanel || systemPanel.classList.contains('hidden')) {
|
||||
stopLogsPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
loadLogs(true);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function stopLogsPolling() {
|
||||
if (!isLogsPolling) return;
|
||||
isLogsPolling = false;
|
||||
if (logsPollInterval) {
|
||||
clearInterval(logsPollInterval);
|
||||
logsPollInterval = null;
|
||||
}
|
||||
const toggle = el('log-auto-refresh-toggle');
|
||||
if (toggle) toggle.checked = false;
|
||||
}
|
||||
|
||||
function initLogsView() {
|
||||
const refreshBtn = el('log-refresh-btn');
|
||||
const levelSelect = el('log-level-select');
|
||||
const limitSelect = el('log-limit-select');
|
||||
const searchInput = el('log-search-input');
|
||||
const autoRefreshToggle = el('log-auto-refresh-toggle');
|
||||
|
||||
if (refreshBtn) refreshBtn.addEventListener('click', () => loadLogs(false));
|
||||
if (levelSelect) levelSelect.addEventListener('change', () => renderLogs(false));
|
||||
if (limitSelect) limitSelect.addEventListener('change', () => loadLogs(false));
|
||||
if (searchInput) searchInput.addEventListener('input', () => renderLogs(false));
|
||||
|
||||
if (autoRefreshToggle) {
|
||||
autoRefreshToggle.addEventListener('change', (e) => {
|
||||
if (e.target.checked) {
|
||||
startLogsPolling();
|
||||
} else {
|
||||
stopLogsPolling();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initial fetch on view loading
|
||||
loadLogs(false);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
INIT & REFRESH
|
||||
═══════════════════════════════════════════ */
|
||||
function initAll() {
|
||||
modalEl = el('settings-modal');
|
||||
const inits = [initSignupToggle, initAddUser, initEndpointForm, initMcpForm, initCalDAV, initBackup, initDangerZone, initTokenForm, () => settingsModule.initIntegrations()];
|
||||
const inits = [
|
||||
initSignupToggle, initAddUser, initEndpointForm, initMcpForm,
|
||||
initCalDAV, initBackup, initDangerZone, initTokenForm, initLogsView,
|
||||
() => settingsModule.initIntegrations()
|
||||
];
|
||||
for (const fn of inits) {
|
||||
try { fn(); } catch (e) { console.error('Admin init error in', fn.name || 'anonymous', e); }
|
||||
}
|
||||
@@ -2507,6 +2993,7 @@ function refreshAll() {
|
||||
loadBuiltinTools();
|
||||
loadMcpServers();
|
||||
loadTokens();
|
||||
loadLogs(false);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
@@ -2523,6 +3010,7 @@ export function open(tab) {
|
||||
}
|
||||
|
||||
export function close() {
|
||||
stopLogsPolling();
|
||||
settingsModule.close();
|
||||
}
|
||||
|
||||
|
||||
+47
-1
@@ -632,6 +632,28 @@ function _getModal() {
|
||||
|
||||
// ── Render dispatch ──
|
||||
|
||||
// Quick-add hint examples — the placeholder cycles through these every few
|
||||
// seconds so users see different prompt shapes (events, deadlines, recurring).
|
||||
const _QA_HINT_EXAMPLES = [
|
||||
'return home to Ithaca 1pm tmrw',
|
||||
'dinner with Penelope Friday 8pm',
|
||||
'coffee with Athena 9am Saturday',
|
||||
'call Telemachus tomorrow morning',
|
||||
'dentist appointment 3pm next Tuesday',
|
||||
'finish the wooden horse by Friday EOD',
|
||||
'gym 7am every weekday',
|
||||
'flight to Athens Sunday 6:30am',
|
||||
'crew muster 10am daily',
|
||||
'council on Ithaca Monday 2pm',
|
||||
];
|
||||
function _initQuickAddHintCycle() {
|
||||
const span = document.getElementById('qa-hint-example');
|
||||
if (!span) return;
|
||||
// Pick one random example per calendar open — no interval cycling.
|
||||
const idx = Math.floor(Math.random() * _QA_HINT_EXAMPLES.length);
|
||||
span.textContent = _QA_HINT_EXAMPLES[idx];
|
||||
}
|
||||
|
||||
// Stash the quick-add input's state (focus + caret + value) before a
|
||||
// re-render so background fetches don't kick the user out mid-type. Picked
|
||||
// up by _wireAll after the new DOM lands.
|
||||
@@ -846,7 +868,7 @@ function _headerHTML() {
|
||||
placeholder=" "
|
||||
autocomplete="off"
|
||||
/>
|
||||
<span class="cal-quickadd-hint" id="cal-quickadd-hint" aria-hidden="true"><span class="qa-hint-accent">Quick add</span> — return home to Ithaca 1pm tmrw <svg class="qa-hint-enter" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 10 4 15 9 20"/><path d="M20 4v7a4 4 0 0 1-4 4H4"/></svg></span>
|
||||
<span class="cal-quickadd-hint" id="cal-quickadd-hint" aria-hidden="true"><span class="qa-hint-accent">Quick add</span> — <span class="qa-hint-example" id="qa-hint-example">return home to Ithaca 1pm tmrw</span> <svg class="qa-hint-enter" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 10 4 15 9 20"/><path d="M20 4v7a4 4 0 0 1-4 4H4"/></svg></span>
|
||||
<span class="cal-quickadd-status" id="cal-quickadd-status"></span>
|
||||
</div>`;
|
||||
}
|
||||
@@ -1913,6 +1935,7 @@ function _wireAll(body) {
|
||||
// ── Quick-add input ─────────────────────────────────────────────
|
||||
const _qaInput = document.getElementById('cal-quickadd');
|
||||
const _qaStatus = document.getElementById('cal-quickadd-status');
|
||||
_initQuickAddHintCycle();
|
||||
if (_qaInput && !_qaInput._wired) {
|
||||
_qaInput._wired = true;
|
||||
const _submitQA = async () => {
|
||||
@@ -3139,6 +3162,29 @@ function _showEventForm(existing, defaultDate, defaultEndDate) {
|
||||
// mode opens already expanded when there's any detail content to see.
|
||||
titleInput?.addEventListener('focus', () => setExpanded(true), { once: true });
|
||||
|
||||
// Live time parse: typing a time like "11pm" or "15:30" into the title
|
||||
// updates the hero clock + start input on the fly. The same parser still
|
||||
// runs again on submit, but doing it live makes the hero clock track
|
||||
// intent immediately instead of jumping at save.
|
||||
if (titleInput) {
|
||||
titleInput.addEventListener('input', () => {
|
||||
if (document.getElementById('cal-f-allday')?.checked) return;
|
||||
const tt = _parseTitleTime(titleInput.value);
|
||||
if (!tt) return;
|
||||
const startEl = document.getElementById('cal-f-start');
|
||||
const endEl = document.getElementById('cal-f-end');
|
||||
const newStart = `${String(tt.h).padStart(2, '0')}:${String(tt.m).padStart(2, '0')}`;
|
||||
if (!startEl || startEl.value === newStart) return;
|
||||
const toMin = (v) => { const p = (v || '').split(':'); return p.length === 2 ? (+p[0]) * 60 + (+p[1]) : null; };
|
||||
const s0 = toMin(startEl.value), e0 = toMin(endEl?.value);
|
||||
const dur = (s0 != null && e0 != null && e0 > s0) ? e0 - s0 : 60;
|
||||
startEl.value = newStart;
|
||||
const endMin = (tt.h * 60 + tt.m + dur) % 1440;
|
||||
if (endEl) endEl.value = `${String(Math.floor(endMin / 60)).padStart(2, '0')}:${String(endMin % 60).padStart(2, '0')}`;
|
||||
startEl.dispatchEvent(new Event('input'));
|
||||
});
|
||||
}
|
||||
|
||||
// Location → Apple Maps. The pin button next to the input is enabled
|
||||
// only when there's a non-empty location, and its href tracks the live
|
||||
// input value. Apple's universal URL opens the native Maps app on
|
||||
|
||||
@@ -787,6 +787,19 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
try { await documentModule.saveDocument({ silent: true }); } catch (_e) { /* best-effort */ }
|
||||
fd.append('active_doc_id', documentModule.getCurrentDocId());
|
||||
}
|
||||
// Active email context — when an email reader is open, pass its
|
||||
// uid/folder/account so "reply", "summarize", "what does this say"
|
||||
// resolve to the email the user is actually looking at instead of
|
||||
// making the agent invent a new markdown draft with fake headers.
|
||||
try {
|
||||
const getEmailCtx = window.__odysseusGetActiveEmailContext;
|
||||
const emCtx = typeof getEmailCtx === 'function' ? getEmailCtx() : null;
|
||||
if (emCtx && emCtx.uid) {
|
||||
fd.append('active_email_uid', String(emCtx.uid));
|
||||
fd.append('active_email_folder', String(emCtx.folder || 'INBOX'));
|
||||
if (emCtx.account) fd.append('active_email_account', String(emCtx.account));
|
||||
}
|
||||
} catch (_e) { /* best-effort */ }
|
||||
// Web toggle: pre-search in Chat mode, tool permission in Agent mode
|
||||
const toggleState = Storage.loadToggleState();
|
||||
let isAgentMode = (toggleState.mode || 'chat') === 'agent';
|
||||
|
||||
@@ -185,7 +185,7 @@ export function handleUIControl(uiData) {
|
||||
} else if (uiEvent === 'open_email_reply' || uiData.ui_event === 'open_email_reply') {
|
||||
import('./emailInbox.js').then(function(mod) {
|
||||
var fn = mod.openReplyDraft || (mod.default && mod.default.openReplyDraft);
|
||||
if (fn) fn(uiData.uid, uiData.folder || 'INBOX', uiData.mode || 'reply');
|
||||
if (fn) fn(uiData.uid, uiData.folder || 'INBOX', uiData.mode || 'reply', uiData.body || '');
|
||||
}).catch(function(e) {
|
||||
console.warn('open_email_reply failed:', e);
|
||||
});
|
||||
|
||||
@@ -40,7 +40,7 @@ export const EVAL_PROMPTS = {
|
||||
chat: [
|
||||
// ── ★ Featured — prompts that have actually broken frontier models ──
|
||||
{ sub: '★ Featured', label: 'Sum digits 2^100', answer: '115', prompt: 'Compute the sum of the decimal digits of 2^100. Do NOT use code execution — work it out by reasoning about the number. Show every step, then end with the final number on its own line.' },
|
||||
{ sub: '★ Featured', label: 'Three jugs', answer: '4 pours: 7→5, 5→3, 3→7, 5→3', prompt: 'You have three jugs of capacities 7, 5, and 3 liters. The 7-liter jug starts full; the others empty. Using only pouring (no markings), produce the shortest sequence of pours that leaves exactly 2 liters in the 3-liter jug. Output each step as `pour A → B` on its own line. Then state the total number of pours on a final line.' },
|
||||
{ sub: '★ Featured', label: 'Three jugs', answer: '2 pours: 7→5, 7→3', prompt: 'You have three jugs of capacities 7, 5, and 3 liters. The 7-liter jug starts full; the others empty. Using only pouring (no markings), produce the shortest sequence of pours that leaves exactly 2 liters in the 3-liter jug. Output each step as `pour A → B` on its own line. Then state the total number of pours on a final line.' },
|
||||
|
||||
{ sub: 'Visual', label: 'Draw SVG', prompt: 'Output a complete self-contained HTML file (```html block, no explanation, no other text) that centers a single SVG illustration on a simple background. The SVG must use only inline shapes — no <img>, no external assets, no JavaScript. Make it expressive and detailed. The SVG should depict: a friendly robot' },
|
||||
{ sub: 'Visual explain', label: 'Black hole HTML', prompt: 'Output a complete HTML file (```html block, no explanation outside the code) that visually explains how a black hole forms. Use four labeled "frames" laid out left-to-right (or stacked on small screens) showing: 1) a glowing massive star, 2) the star going supernova with shockwave rings, 3) collapse into a singularity, 4) the final black hole with a curved accretion disk and bent light around it. Use only vanilla HTML, CSS, and inline SVG — no JavaScript, no images. Each frame should have a one-sentence caption.' },
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// Per-backend × per-model install recipes for the Dependencies tab.
|
||||
//
|
||||
// Each entry says: when you're about to serve `model` on `backend`, here's
|
||||
// the exact shell sequence to make the venv + install the right packages.
|
||||
// Entries are matched first-hit; put the more specific patterns ABOVE the
|
||||
// generic fallback for that backend.
|
||||
|
||||
// Recipes carry two variants per entry:
|
||||
// variants.pip → install into the configured venv via uv/pip
|
||||
// variants.docker → pull the official container image
|
||||
//
|
||||
// The renderer prepends a `source <venv>/bin/activate` for the pip variant
|
||||
// (env_prefix handles activation for Run). The docker variant skips the
|
||||
// activate line — `docker pull` doesn't need a venv.
|
||||
|
||||
const _RECIPES = [
|
||||
// ── vllm ──────────────────────────────────────────────────────────────
|
||||
// MiniMax M2/M2.7 — same as the generic vllm install/image for now;
|
||||
// kept as its own entry so future model-specific patches land in one
|
||||
// obvious place without touching the catch-all.
|
||||
{
|
||||
backend: 'vllm',
|
||||
label: 'MiniMax M2 / M2.7',
|
||||
match: (m) => /minimax[-_]?m\s?2(\.7)?/i.test(m || ''),
|
||||
variants: {
|
||||
pip: { commands: ['uv pip install -U vllm --torch-backend auto'] },
|
||||
docker: { commands: ['docker pull vllm/vllm-openai:latest'] },
|
||||
},
|
||||
},
|
||||
// Generic vllm fallback.
|
||||
{
|
||||
backend: 'vllm',
|
||||
label: 'Any vLLM model',
|
||||
match: () => true,
|
||||
variants: {
|
||||
pip: { commands: ['uv pip install -U vllm --torch-backend auto'] },
|
||||
docker: { commands: ['docker pull vllm/vllm-openai:latest'] },
|
||||
},
|
||||
},
|
||||
|
||||
// ── sglang ────────────────────────────────────────────────────────────
|
||||
{
|
||||
backend: 'sglang',
|
||||
label: 'Any SGLang model',
|
||||
match: () => true,
|
||||
variants: {
|
||||
pip: { commands: ['uv pip install -U "sglang[all]" --torch-backend auto'] },
|
||||
docker: { commands: ['docker pull lmsysorg/sglang:latest'] },
|
||||
},
|
||||
},
|
||||
|
||||
// ── llama.cpp ─────────────────────────────────────────────────────────
|
||||
{
|
||||
backend: 'llama_cpp',
|
||||
label: 'Any GGUF model',
|
||||
match: () => true,
|
||||
variants: {
|
||||
pip: { commands: ['CMAKE_ARGS="-DGGML_CUDA=on" uv pip install -U "llama-cpp-python[server]"'] },
|
||||
docker: { commands: ['docker pull ghcr.io/ggerganov/llama.cpp:server-cuda'] },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const RECIPE_VARIANTS = ['pip', 'docker'];
|
||||
export const RECIPE_DEFAULT_VARIANT = 'pip';
|
||||
|
||||
// Get the commands array for a recipe + variant. Falls back to pip when
|
||||
// the requested variant isn't defined for the recipe.
|
||||
export function recipeCommands(recipe, variant) {
|
||||
if (!recipe) return [];
|
||||
const v = (recipe.variants || {})[variant] || (recipe.variants || {}).pip;
|
||||
return (v && v.commands) || [];
|
||||
}
|
||||
|
||||
// Backends we surface a recipe panel for. Other rows in the Dependencies
|
||||
// list keep the existing flat Install/Reinstall button without an expand
|
||||
// affordance.
|
||||
export const RECIPE_BACKENDS = new Set(['vllm', 'sglang', 'llama_cpp']);
|
||||
|
||||
// All recipe entries for a given backend, in catalog order. The first one
|
||||
// is the model-specific match (when present); the last is always the
|
||||
// generic fallback.
|
||||
export function recipesForBackend(backend) {
|
||||
return _RECIPES.filter((r) => r.backend === backend);
|
||||
}
|
||||
|
||||
// Pick the best recipe for a backend + model id. Returns the catalog
|
||||
// fallback when nothing more specific matches, or null if the backend
|
||||
// isn't in the catalog at all.
|
||||
export function pickRecipe(backend, modelId) {
|
||||
const candidates = recipesForBackend(backend);
|
||||
if (!candidates.length) return null;
|
||||
for (const r of candidates) {
|
||||
try { if (r.match(modelId)) return r; } catch (_) {}
|
||||
}
|
||||
return candidates[candidates.length - 1] || null;
|
||||
}
|
||||
@@ -65,7 +65,13 @@ import spinnerModule from './spinner.js';
|
||||
|
||||
// ── Error diagnosis ──
|
||||
|
||||
function _openCookbookDependencies(pkgName = '') {
|
||||
// Re-exported so callers (Launch-tab pre-flight) can deep-link into the
|
||||
// Dependencies tab + auto-expand a specific backend's recipe panel and
|
||||
// pre-select the model they were trying to launch.
|
||||
export function openCookbookDependencies(pkgName = '', opts = {}) {
|
||||
_openCookbookDependencies(pkgName, opts);
|
||||
}
|
||||
function _openCookbookDependencies(pkgName = '', opts = {}) {
|
||||
const cookbook = window.cookbookModule;
|
||||
if (cookbook && typeof cookbook.open === 'function') {
|
||||
cookbook.open({ tab: 'Dependencies' });
|
||||
@@ -94,6 +100,34 @@ function _openCookbookDependencies(pkgName = '') {
|
||||
row.scrollIntoView({ block: 'center' });
|
||||
row.classList.add('cookbook-pkg-flash');
|
||||
setTimeout(() => row.classList.remove('cookbook-pkg-flash'), 1800);
|
||||
// Pre-flight deep link: auto-expand the recipe panel + pre-select
|
||||
// the model the user was trying to launch. The dropdown values are
|
||||
// now full model ids (sourced from _cachedModelIds), so we match by
|
||||
// exact value first, then fall back to a substring match.
|
||||
if (opts.expandRecipe) {
|
||||
const caret = row.querySelector('[data-dep-recipe-toggle]');
|
||||
if (caret && caret.getAttribute('aria-expanded') !== 'true') caret.click();
|
||||
if (opts.model) {
|
||||
const sel = document.querySelector(`[data-dep-recipe-pick="${CSS.escape(opts.expandRecipe)}"]`);
|
||||
if (sel) {
|
||||
const wanted = String(opts.model);
|
||||
let matched = false;
|
||||
for (let i = 0; i < sel.options.length; i++) {
|
||||
if (sel.options[i].value === wanted) {
|
||||
sel.value = wanted; matched = true; break;
|
||||
}
|
||||
}
|
||||
if (!matched) {
|
||||
for (let i = 0; i < sel.options.length; i++) {
|
||||
if (sel.options[i].value && wanted.includes(sel.options[i].value)) {
|
||||
sel.value = sel.options[i].value; matched = true; break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (matched) sel.dispatchEvent(new Event('change'));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
tryHighlight();
|
||||
@@ -626,7 +660,24 @@ export function _showDiagnosis(panel, diagnosis, sourceText) {
|
||||
// the full error+context for a forum/discord paste.
|
||||
const toolbar = document.createElement('div');
|
||||
toolbar.className = 'cookbook-diag-toolbar';
|
||||
toolbar.style.cssText = 'display:flex;justify-content:flex-end;align-items:center;gap:4px;margin-bottom:-2px;';
|
||||
// Left side carries the diagnosis text (message + suggestion); buttons
|
||||
// stay on the right. Was a separate body row below the toolbar, but
|
||||
// the message reads more like "this is what the toolbar is for" when
|
||||
// it sits inline with Copy / × Dismiss.
|
||||
toolbar.style.cssText = 'display:flex;align-items:flex-start;gap:8px;margin-bottom:-2px;';
|
||||
|
||||
const textWrap = document.createElement('div');
|
||||
textWrap.style.cssText = 'flex:1;min-width:0;font-size:11px;line-height:1.35;';
|
||||
const msg = document.createElement('div');
|
||||
msg.className = 'cookbook-diag-message';
|
||||
msg.textContent = diagnosis.message;
|
||||
textWrap.appendChild(msg);
|
||||
const suggestion = document.createElement('div');
|
||||
suggestion.className = 'cookbook-diag-suggestion';
|
||||
suggestion.textContent = suggestionText;
|
||||
suggestion.style.cssText = 'opacity:0.75;margin-top:1px;';
|
||||
textWrap.appendChild(suggestion);
|
||||
toolbar.appendChild(textWrap);
|
||||
|
||||
const copyBtn = document.createElement('button');
|
||||
copyBtn.type = 'button';
|
||||
@@ -660,18 +711,6 @@ export function _showDiagnosis(panel, diagnosis, sourceText) {
|
||||
toolbar.appendChild(dismissBtn);
|
||||
diag.appendChild(toolbar);
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'cookbook-diag-body';
|
||||
const msg = document.createElement('div');
|
||||
msg.className = 'cookbook-diag-message';
|
||||
msg.textContent = diagnosis.message;
|
||||
body.appendChild(msg);
|
||||
const suggestion = document.createElement('div');
|
||||
suggestion.className = 'cookbook-diag-suggestion';
|
||||
suggestion.textContent = suggestionText;
|
||||
body.appendChild(suggestion);
|
||||
diag.appendChild(body);
|
||||
|
||||
const runFix = async (fix, button, busyLabel = fix.label, onStart = null, onDone = null) => {
|
||||
if (!fix || !button || button.dataset.busy) return;
|
||||
button.dataset.busy = '1';
|
||||
|
||||
+376
-8
@@ -31,6 +31,44 @@ import {
|
||||
} from './cookbook.js';
|
||||
import uiModule from './ui.js';
|
||||
import spinnerModule from './spinner.js';
|
||||
import { _loadTasks, _tmuxGracefulKill } from './cookbookRunning.js';
|
||||
import { openCookbookDependencies } from './cookbook-diagnosis.js';
|
||||
|
||||
// Map a serve-backend code (vllm / sglang / llamacpp) → the package name
|
||||
// the Dependencies API reports. Used to look up "is this backend installed
|
||||
// on the target server" before firing a launch.
|
||||
const _BACKEND_PKG = { vllm: 'vllm', sglang: 'sglang', llamacpp: 'llama_cpp' };
|
||||
|
||||
// Pre-launch: ask the deps API whether the chosen backend is present on
|
||||
// the target server. Returns true if it's good to go, false if we should
|
||||
// block and route the user into Dependencies.
|
||||
async function _ensureBackendInstalled(runBackend, host, port, envPath, modelName) {
|
||||
const pkgName = _BACKEND_PKG[runBackend];
|
||||
if (!pkgName) return true; // unknown backend — don't block
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (host) {
|
||||
params.set('host', host);
|
||||
if (port) params.set('ssh_port', String(port));
|
||||
if (envPath) params.set('venv', envPath);
|
||||
}
|
||||
const r = await fetch('/api/cookbook/packages' + (params.toString() ? '?' + params : ''));
|
||||
const d = await r.json();
|
||||
const pkg = (d.packages || []).find(p => p.name === pkgName);
|
||||
if (pkg && pkg.installed) return true;
|
||||
} catch (_) {
|
||||
// If we can't tell, don't block — the server's own serve route will
|
||||
// surface a clearer error anyway.
|
||||
return true;
|
||||
}
|
||||
const targetLabel = host || 'this server';
|
||||
uiModule.showToast(
|
||||
`${pkgName} not installed on ${targetLabel}. Opening Dependencies — pick your model and click Run.`,
|
||||
6000
|
||||
);
|
||||
openCookbookDependencies(pkgName, { expandRecipe: pkgName, model: modelName });
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── What Fits? (hardware model fitting) ──
|
||||
|
||||
@@ -127,7 +165,12 @@ export function _renderGpuToggles(system) {
|
||||
_gpuToggleTotal = 0;
|
||||
return;
|
||||
}
|
||||
if (!_gpuToggleTotal) _gpuToggleTotal = total;
|
||||
// Update on every scan that returns a positive total — previously this
|
||||
// only set on the first scan, so switching servers (e.g. local 1-GPU
|
||||
// first, then a 4-GPU remote) left the Run-panel GPU buttons stuck on
|
||||
// the original count. Zero/missing totals still don't clobber a known
|
||||
// good value (avoids flicker during an in-flight re-probe).
|
||||
if (total > 0) _gpuToggleTotal = total;
|
||||
|
||||
container._groups = groups;
|
||||
if (container._activeGroup === undefined) container._activeGroup = 0; // auto = largest pool
|
||||
@@ -159,8 +202,17 @@ export function _renderGpuToggles(system) {
|
||||
// visual highlight. Before this, _activeCount stayed undefined → no
|
||||
// gpu_count param sent → backend's fallback could rank against RAM on
|
||||
// mixed-resource boxes ("tightest" sorted by RAM instead of GPU).
|
||||
if (container._activeCount === undefined && validCounts.length) {
|
||||
container._activeCount = maxGpu;
|
||||
//
|
||||
// On boxes where total RAM > total VRAM, default to RAM (count=0) instead
|
||||
// — RAM is the dominant pool so it's the better starting filter.
|
||||
if (container._activeCount === undefined) {
|
||||
const ramGb = Number(system.total_ram_gb) || 0;
|
||||
const vramGb = Number(system.gpu_vram_gb) || 0;
|
||||
if (ramGb > vramGb) {
|
||||
container._activeCount = 0;
|
||||
} else if (validCounts.length) {
|
||||
container._activeCount = maxGpu;
|
||||
}
|
||||
}
|
||||
html += '<button class="hwfit-gpu-btn" data-count="0" title="CPU / RAM only">RAM</button>';
|
||||
const hasExplicitCount = typeof container._activeCount === 'number';
|
||||
@@ -363,7 +415,7 @@ function _scanSig() {
|
||||
hk: _currentServerValue(),
|
||||
u: document.getElementById('hwfit-usecase')?.value || '',
|
||||
s: document.getElementById('hwfit-search')?.value?.trim() || '',
|
||||
o: sortEl?.value || 'score',
|
||||
o: sortEl?.value || 'newest',
|
||||
r: sortEl?.dataset.reverse === '1' ? 1 : 0,
|
||||
q: document.getElementById('hwfit-quant')?.value || '',
|
||||
c: _ctxValue(),
|
||||
@@ -582,7 +634,7 @@ export async function _hwfitFetch(fresh = false) {
|
||||
}).catch(() => {});
|
||||
}
|
||||
try {
|
||||
const sortBy = document.getElementById('hwfit-sort')?.value || 'score';
|
||||
const sortBy = document.getElementById('hwfit-sort')?.value || 'newest';
|
||||
const quantPref = document.getElementById('hwfit-quant')?.value || '';
|
||||
const targetCtx = _ctxValue();
|
||||
// Get active GPU count from toggles
|
||||
@@ -710,7 +762,7 @@ export async function _hwfitFetch(fresh = false) {
|
||||
// 1st click on a column = highest first; clicking it again = lowest first.
|
||||
if (!isImageMode) {
|
||||
const sortSel = document.getElementById('hwfit-sort');
|
||||
const sortKey = sortSel?.value || 'score';
|
||||
const sortKey = sortSel?.value || 'newest';
|
||||
const asc = sortSel?.dataset.reverse === '1'; // reversed → ascending (lowest first)
|
||||
if (sortKey === 'fit') {
|
||||
// fit_level is categorical (perfect→good→marginal→too_tight), not numeric,
|
||||
@@ -723,6 +775,18 @@ export async function _hwfitFetch(fresh = false) {
|
||||
const as = Number(a.score) || 0, bs = Number(b.score) || 0;
|
||||
return asc ? as - bs : bs - as;
|
||||
});
|
||||
} else if (sortKey === 'newest') {
|
||||
// release_date is an ISO-ish "YYYY-MM-DD" string — lexical sort is
|
||||
// chronological. Default direction: newest first (reverse=undefined).
|
||||
data.models.sort((a, b) => {
|
||||
const ad = String(a.release_date || ''), bd = String(b.release_date || '');
|
||||
if (ad === bd) return 0;
|
||||
// Empty dates land last regardless of direction so the column never
|
||||
// floats undated rows above real releases.
|
||||
if (!ad) return 1;
|
||||
if (!bd) return -1;
|
||||
return asc ? (ad < bd ? -1 : 1) : (ad < bd ? 1 : -1);
|
||||
});
|
||||
} else {
|
||||
const field = { score: 'score', vram: 'required_gb', speed: 'speed_tps', params: 'params_b', context: 'context' }[sortKey] || 'score';
|
||||
data.models.sort((a, b) => {
|
||||
@@ -750,6 +814,80 @@ export async function _hwfitFetch(fresh = false) {
|
||||
}
|
||||
}
|
||||
|
||||
// Renders a non-blocking hardware visibility warning when Cookbook is using
|
||||
// container-visible hardware that may not match the user's actual host machine.
|
||||
function _renderHwVisibilityWarning(sys) {
|
||||
const row = document.getElementById('hwfit-hw-row');
|
||||
if (!row) return;
|
||||
|
||||
let box = document.getElementById('hwfit-hw-visibility-warning');
|
||||
|
||||
// Manual hardware is an explicit user override, so avoid showing stale
|
||||
// container-detection warnings once the user has chosen a simulated profile.
|
||||
const warning = sys?.manual_hardware ? null : sys?.hardware_visibility_warning;
|
||||
|
||||
if (!warning) {
|
||||
if (box) box.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!box) {
|
||||
box = document.createElement('div');
|
||||
box.id = 'hwfit-hw-visibility-warning';
|
||||
box.className = 'hwfit-loading hwfit-hw-visibility-warning';
|
||||
row.insertAdjacentElement('afterend', box);
|
||||
}
|
||||
|
||||
box.innerHTML = `
|
||||
<div class="hwfit-hw-visibility-warning-title">${esc(warning.title || 'Hardware visibility note')}</div>
|
||||
<div class="hwfit-hw-visibility-warning-body">${esc(warning.message || '')}</div>
|
||||
<div class="hwfit-hw-visibility-warning-actions">
|
||||
<button type="button" class="hwfit-gpu-btn" data-hw-action="manual">Edit manual hardware</button>
|
||||
<button type="button" class="hwfit-gpu-btn" data-hw-action="rescan">Rescan</button>
|
||||
<button type="button" class="hwfit-gpu-btn" data-hw-action="copy">Copy diagnostics</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
box.querySelector('[data-hw-action="manual"]')?.addEventListener('click', () => {
|
||||
const panel = document.getElementById('hwfit-manual-panel');
|
||||
if (panel) panel.classList.remove('hidden');
|
||||
document.getElementById('hwfit-hw-manual-btn')?.scrollIntoView?.({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
});
|
||||
});
|
||||
|
||||
box.querySelector('[data-hw-action="rescan"]')?.addEventListener('click', () => {
|
||||
_resetGpuToggleState();
|
||||
_hwfitCache = null;
|
||||
_hwfitFetch(true);
|
||||
});
|
||||
|
||||
box.querySelector('[data-hw-action="copy"]')?.addEventListener('click', () => {
|
||||
// Keep diagnostics copy/paste friendly for GitHub issues and Docker support.
|
||||
const text = [
|
||||
'Odysseus Cookbook hardware diagnostics',
|
||||
`probe_scope=${sys?.probe_scope || ''}`,
|
||||
`containerized=${sys?.containerized === true}`,
|
||||
`backend=${sys?.backend || ''}`,
|
||||
`has_gpu=${sys?.has_gpu === true}`,
|
||||
`gpu_name=${sys?.gpu_name || ''}`,
|
||||
`gpu_count=${sys?.gpu_count || 0}`,
|
||||
`gpu_vram_gb=${sys?.gpu_vram_gb || ''}`,
|
||||
`ram=${sys?.available_ram_gb || '?'} / ${sys?.total_ram_gb || '?'} GB`,
|
||||
`cpu_cores=${sys?.cpu_cores || ''}`,
|
||||
`cpu_name=${sys?.cpu_name || ''}`,
|
||||
'',
|
||||
'Useful checks:',
|
||||
'docker compose exec odysseus nvidia-smi -L',
|
||||
'docker compose exec odysseus cat /proc/meminfo | head',
|
||||
'docker compose exec odysseus python -c "from services.hwfit.hardware import detect_system; import json; print(json.dumps(detect_system(fresh=True), indent=2))"',
|
||||
].join('\n');
|
||||
|
||||
_copyText(text);
|
||||
});
|
||||
}
|
||||
|
||||
export function _hwfitRenderHw(el, sys) {
|
||||
if (!el || !sys) return;
|
||||
// Cache system info globally so other modules can read VRAM without refetching
|
||||
@@ -838,6 +976,7 @@ export function _hwfitRenderHw(el, sys) {
|
||||
+ chip('cores', cores)
|
||||
+ chip('backend', esc(sys.backend || ''))
|
||||
+ manualChip;
|
||||
_renderHwVisibilityWarning(sys);
|
||||
// Body click → toggle "off" (dimmed, still visible). Membership of
|
||||
// _dismissedHwChips is what the ranker reads, so both add+remove
|
||||
// here also flips the model list. The manual chip is excluded —
|
||||
@@ -968,7 +1107,7 @@ function _modeLabel(model) {
|
||||
|
||||
export const _hwfitColumns = [
|
||||
{ key: 'fit', label: 'Fit', cls: 'hwfit-fit' },
|
||||
{ key: null, label: 'Model', cls: 'hwfit-name' },
|
||||
{ key: 'newest', label: 'Model (latest)', cls: 'hwfit-name' },
|
||||
{ key: 'params',label: 'Param', cls: 'hwfit-c-params' },
|
||||
{ key: null, label: 'Quant', cls: 'hwfit-c-quant' },
|
||||
{ key: 'vram', label: 'VRAM', cls: 'hwfit-c-vram' },
|
||||
@@ -998,7 +1137,7 @@ export function _hwfitRenderList(el, models) {
|
||||
return;
|
||||
}
|
||||
const sortSel = document.getElementById('hwfit-sort');
|
||||
const currentSort = sortSel?.value || 'score';
|
||||
const currentSort = sortSel?.value || 'newest';
|
||||
const isReversed = sortSel?.dataset.reverse === '1';
|
||||
// Active budget for the Fit column label \u2014 make it obvious whether the
|
||||
// ranking is against GPU or RAM so "tightest" can't be ambiguous on a
|
||||
@@ -1027,6 +1166,13 @@ export function _hwfitRenderList(el, models) {
|
||||
// (Budget tag removed — the GPU/RAM/N-GPU suffix next to "Fit" was noise;
|
||||
// the toggle row already shows which budget is active.)
|
||||
}
|
||||
// The Model column's "(newest)" / "(oldest)" suffix flips with the sort
|
||||
// direction so the user can see at a glance which way they're sorted.
|
||||
if (col.key === 'newest' && col.key === currentSort) {
|
||||
label = isReversed ? 'Model (oldest)' : 'Model (latest)';
|
||||
} else if (col.key === 'newest') {
|
||||
label = 'Model (latest)';
|
||||
}
|
||||
html += `<span class="hwfit-col ${col.cls}${sortable}${active}"${dataAttr}>${label}${arrow}</span>`;
|
||||
}
|
||||
html += '</div>';
|
||||
@@ -1181,6 +1327,72 @@ function _syncHostFromScanDropdown() {
|
||||
return host;
|
||||
}
|
||||
|
||||
// Minimum backend version a given model needs. Returns a semver string like
|
||||
// "0.10.0" or null when the model has no known floor. Hardcoded for now —
|
||||
// when the vLLM-recipes integration lands we can pull this from the upstream
|
||||
// recipe page instead. Keep this conservative: a null return means "any
|
||||
// installed version passes", so we don't false-positive launches.
|
||||
function _minBackendVersion(modelName, backend) {
|
||||
const n = (modelName || '').toLowerCase();
|
||||
if (backend === 'vllm') {
|
||||
// MiniMax M2 / M2.5 / M2.7 — minimax_m2 parser shipped in 0.10.0
|
||||
if (n.includes('minimax') && n.match(/\bm2(?:\.\d)?\b/)) return '0.10.0';
|
||||
// MiniMax M3 — newer parser registered in 0.11.x
|
||||
if (n.includes('minimax') && n.includes('m3')) return '0.11.0';
|
||||
// DeepSeek V3 / V3.1 / R1 — MoE expert-parallel paths matured in 0.7.0+
|
||||
if (n.includes('deepseek') && (n.includes('v3') || n.includes('r1'))) return '0.7.0';
|
||||
// Qwen3 reasoning models — qwen3 reasoning parser added in 0.7.0
|
||||
if (n.includes('qwen3') && !n.includes('coder') && !n.includes('instruct')) return '0.7.0';
|
||||
// GLM-4.5 / GLM-4.6 — glm45 reasoning parser added in 0.8.0
|
||||
if (n.includes('glm-4.5') || n.includes('glm-4.6') || n.includes('glm-5')) return '0.8.0';
|
||||
// gpt-oss reasoning models — gpt_oss parser
|
||||
if (n.includes('gpt-oss')) return '0.10.0';
|
||||
// Llama-4 multimodal — landed in 0.7.0
|
||||
if (n.includes('llama-4') || n.includes('llama4')) return '0.7.0';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Tiny semver compare: returns <0 / 0 / >0 like strcmp. Tolerates "0.10",
|
||||
// "0.10.0", "0.10.0+cu124" — pre-release / build suffixes are stripped.
|
||||
function _cmpSemver(a, b) {
|
||||
const _parse = (s) => String(s || '').split(/[.+-]/).filter(p => /^\d+$/.test(p)).map(Number);
|
||||
const A = _parse(a), B = _parse(b);
|
||||
for (let i = 0; i < Math.max(A.length, B.length); i++) {
|
||||
const av = A[i] || 0, bv = B[i] || 0;
|
||||
if (av !== bv) return av - bv;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Map the detected GPU + the model's quant to SGLang's URL-hash params so
|
||||
// the cookbook page lands on the right preset. SGLang supports:
|
||||
// hw = b200 | b300 | gb200 | gb300 | mi300x | mi325x | mi350x | mi355x | h200
|
||||
// quant = mxfp8 | bf16
|
||||
// variant = default strategy = balanced nodes = single
|
||||
// We only set what we can confidently infer; anything missing degrades to
|
||||
// SGLang's own default (which is `h200` + bf16 single-node balanced).
|
||||
function _sglangHashFor(modelData) {
|
||||
const sys = (typeof _hwfitCache !== 'undefined' ? _hwfitCache?.system : null) || {};
|
||||
const gpuName = String(sys.gpu_name || '').toLowerCase();
|
||||
let hw = '';
|
||||
if (/\bgb300/.test(gpuName)) hw = 'gb300';
|
||||
else if (/\bgb200/.test(gpuName)) hw = 'gb200';
|
||||
else if (/\bb300/.test(gpuName)) hw = 'b300';
|
||||
else if (/\bb200/.test(gpuName)) hw = 'b200';
|
||||
else if (/\bh200/.test(gpuName)) hw = 'h200';
|
||||
else if (/mi355/.test(gpuName)) hw = 'mi355x';
|
||||
else if (/mi350/.test(gpuName)) hw = 'mi350x';
|
||||
else if (/mi325/.test(gpuName)) hw = 'mi325x';
|
||||
else if (/mi300/.test(gpuName)) hw = 'mi300x';
|
||||
const qRaw = String(modelData?.quant || '').toLowerCase();
|
||||
// mxfp8 covers fp8 / mxfp8 / nvfp4; bf16 covers everything else cheap.
|
||||
const quant = /fp8|mxfp|nvfp/.test(qRaw) ? 'mxfp8' : 'bf16';
|
||||
const parts = ['variant=default', `quant=${quant}`, 'strategy=balanced', 'nodes=single'];
|
||||
if (hw) parts.unshift(`hw=${hw}`);
|
||||
return '#' + parts.join('&');
|
||||
}
|
||||
|
||||
export function _expandModelRow(row, modelData) {
|
||||
const list = row.closest('.hwfit-list');
|
||||
if (!list) return;
|
||||
@@ -1203,11 +1415,23 @@ export function _expandModelRow(row, modelData) {
|
||||
|
||||
const dlSource = _downloadSourceRepo(modelData, backend);
|
||||
const hfUrl = `https://huggingface.co/${dlSource.repo}`;
|
||||
// Official vendor recipe deep-links. These point to vLLM / SGLang's curated
|
||||
// hardware-specific launch-command pages. They 404 for uncatalogued models \u2014
|
||||
// a known tradeoff; user just gets the vendor's "model not found" page.
|
||||
const _recipeRepo = modelData.name || '';
|
||||
const _vllmUrl = _recipeRepo ? `https://recipes.vllm.ai/${_recipeRepo}` : '';
|
||||
const _sglangUrl = _recipeRepo ? `https://docs.sglang.io/cookbook/autoregressive/${_recipeRepo}${_sglangHashFor(modelData)}` : '';
|
||||
let html = `<div class="hwfit-action-panel" data-model-name="${esc(modelData.name)}">`;
|
||||
html += `<div class="hwfit-panel-header">`;
|
||||
html += `<span class="hwfit-panel-model">${esc(modelData.name)}${dlSource.kind ? ` <span style="opacity:0.5;font-size:10px;">(${esc(dlSource.kind)} ${esc(modelData.quant || '')})</span>` : (modelData.quant_repo ? ` <span style="opacity:0.5;font-size:10px;">(${esc(modelData.quant)})</span>` : '')}</span>`;
|
||||
html += `<span class="hwfit-panel-badge">${esc(label)}</span>`;
|
||||
html += `<a href="${esc(hfUrl)}" target="_blank" rel="noopener" class="hwfit-panel-hf-link" title="View download source on HuggingFace">HF \u2197</a>`;
|
||||
if (backend === 'vllm' && _vllmUrl) {
|
||||
html += `<a href="${esc(_vllmUrl)}" target="_blank" rel="noopener" class="hwfit-panel-hf-link" title="vLLM official recipe (curated launch command). 404s if this model isn't in vLLM's recipes catalog.">vLLM \u2197</a>`;
|
||||
}
|
||||
if (backend === 'sglang' && _sglangUrl) {
|
||||
html += `<a href="${esc(_sglangUrl)}" target="_blank" rel="noopener" class="hwfit-panel-hf-link" title="SGLang cookbook (hash pre-filled with your detected hardware). 404s if this model isn't in SGLang's cookbook catalog.">SGLang \u2197</a>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
html += `<div class="hwfit-panel-actions">`;
|
||||
html += `<button class="cookbook-btn hwfit-dl-btn">Download</button>`;
|
||||
@@ -1276,6 +1500,133 @@ export function _expandModelRow(row, modelData) {
|
||||
return;
|
||||
}
|
||||
|
||||
// ─── Pre-launch: stop the model already serving on this host ───────
|
||||
// Two servers can't share port 8000. Without this, the new launch
|
||||
// silently collided and the user saw no feedback. We surface the
|
||||
// conflict and offer to kill the running one first as the default
|
||||
// action (it's almost always what the user wants).
|
||||
try {
|
||||
const _qrHostStr = _envState.remoteHost || '';
|
||||
const _activeServes = _loadTasks().filter(t =>
|
||||
t && t.type === 'serve'
|
||||
&& (t.remoteHost || '') === _qrHostStr
|
||||
&& (t.status === 'running' || t.status === 'ready' || t._serveReady)
|
||||
);
|
||||
if (_activeServes.length) {
|
||||
const _names = _activeServes.map(t => t.payload?.repo_id || t.repo || t.name || '?').filter(Boolean);
|
||||
const _ok = await window.styledConfirm?.(
|
||||
`${_names.length} model${_names.length === 1 ? '' : 's'} already serving on ${_qrHostStr || 'local'} (${_names.join(', ')}). Port 8000 will collide. Stop the running model and launch this one?`,
|
||||
{ confirmText: 'Stop & launch', cancelText: 'Cancel' }
|
||||
);
|
||||
if (!_ok) return;
|
||||
// Mark + kill each running serve, then wait briefly for the
|
||||
// tmux session to actually go down before we kick off the new
|
||||
// launch. Otherwise vLLM still races against the dying socket.
|
||||
quickRunBtn.disabled = true;
|
||||
quickRunBtn.textContent = 'Stopping…';
|
||||
for (const t of _activeServes) {
|
||||
try {
|
||||
// Use that task's own Stop button if it's rendered (handles
|
||||
// endpoint cleanup, Ollama unload, fade-out). Falls back to
|
||||
// a direct tmux kill if the Active tab isn't in the DOM yet.
|
||||
const _taskEl = document.querySelector(`.cookbook-task[data-task-id="${t.sessionId}"]`);
|
||||
const _stopBtn = _taskEl?.querySelector('.cookbook-task-action-stop');
|
||||
if (_stopBtn) {
|
||||
_stopBtn.click();
|
||||
} else {
|
||||
await fetch('/api/shell/exec', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: _tmuxGracefulKill(t) }),
|
||||
});
|
||||
}
|
||||
} catch (_killErr) { /* best-effort */ }
|
||||
}
|
||||
// Give the OS a beat to release port 8000.
|
||||
await new Promise(r => setTimeout(r, 2500));
|
||||
}
|
||||
} catch (_e) { /* best-effort */ }
|
||||
|
||||
// ─── Pre-launch driver check ─────────────────────────────────────
|
||||
// vLLM/SGLang need a working CUDA/ROCm driver. nvidia-smi failures
|
||||
// surface as system.gpu_error from our hardware probe; "no GPU
|
||||
// detected" is the other common case. Bail with a clear message
|
||||
// before kicking off the long install/launch chain — otherwise the
|
||||
// user watches `pip install vllm` finish, then sees a cryptic CUDA
|
||||
// error 10 minutes later. (llama.cpp / Ollama have CPU fallbacks
|
||||
// so they skip this gate.)
|
||||
const _qrBackendDetect = _detectBackend(modelData);
|
||||
const _qrRunBackend = _qrBackendDetect.backend || 'vllm';
|
||||
if (_qrRunBackend === 'vllm' || _qrRunBackend === 'sglang') {
|
||||
const _sys = _hwfitCache?.system || {};
|
||||
if (_sys.gpu_error) {
|
||||
uiModule.showError(`Can't launch: GPU driver error — ${_sys.gpu_error}. Reinstall or repair the NVIDIA driver, then re-scan.`);
|
||||
return;
|
||||
}
|
||||
if (!_sys.has_gpu || !(_sys.gpu_count > 0)) {
|
||||
uiModule.showError(`Can't launch: no GPU detected by nvidia-smi. ${_qrRunBackend === 'vllm' ? 'vLLM' : 'SGLang'} needs a working CUDA or ROCm device.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Pre-launch install + version check ─────────────────────────
|
||||
// Catches:
|
||||
// a) "command not found" (binary not in PATH)
|
||||
// b) "version too old" (model needs e.g. vllm >= 0.10.0 for the
|
||||
// reasoning/tool parser registered for it).
|
||||
// Both cases would otherwise fail 10s-3min into the launch with a
|
||||
// cryptic shell error. Best-effort: a venv activated only by the
|
||||
// launch wrapper can false-negative the PATH check, in which case
|
||||
// the launch proceeds and the existing diagnosis layer handles it.
|
||||
if (_qrRunBackend === 'vllm' || _qrRunBackend === 'sglang') {
|
||||
try {
|
||||
const _qrHostStr = _envState.remoteHost || '';
|
||||
const _coreCheck = _qrRunBackend === 'vllm'
|
||||
? "command -v vllm >/dev/null 2>&1 && vllm --version 2>&1 | grep -oE '[0-9]+\\.[0-9]+(\\.[0-9]+)?' | head -1 || echo MISSING"
|
||||
: "python3 -c 'import sglang, sys; sys.stdout.write(sglang.__version__)' 2>/dev/null || echo MISSING";
|
||||
const _wrappedCheck = _qrHostStr
|
||||
? `ssh -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new ${_qrHostStr} "bash -lc ${JSON.stringify(_coreCheck)}"`
|
||||
: `bash -lc ${JSON.stringify(_coreCheck)}`;
|
||||
const _chkRes = await fetch('/api/shell/exec', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: _wrappedCheck, timeout: 10 }),
|
||||
});
|
||||
if (_chkRes.ok) {
|
||||
const _chk = await _chkRes.json();
|
||||
const _stdout = String(_chk.stdout || '').trim();
|
||||
const _stderr = String(_chk.stderr || '').trim();
|
||||
const _out = `${_stdout}\n${_stderr}`;
|
||||
if (_out.includes('MISSING')) {
|
||||
const _pkg = _qrRunBackend === 'vllm' ? 'vLLM' : 'SGLang';
|
||||
const _hint = _qrRunBackend === 'vllm'
|
||||
? 'uv pip install -U vllm --torch-backend auto'
|
||||
: "pip install -U 'sglang[all]'";
|
||||
uiModule.showError(`Can't launch: ${_pkg} isn't installed${_qrHostStr ? ' on ' + _qrHostStr : ''}. Install it first:\n${_hint}`);
|
||||
return;
|
||||
}
|
||||
// Version-floor check. _minBackendVersion returns null when this
|
||||
// model has no known requirement — in which case any installed
|
||||
// version passes.
|
||||
const _minVer = _minBackendVersion(modelData.name, _qrRunBackend);
|
||||
const _verMatch = _stdout.match(/(\d+\.\d+(?:\.\d+)?)/);
|
||||
const _curVer = _verMatch ? _verMatch[1] : '';
|
||||
if (_minVer && _curVer && _cmpSemver(_curVer, _minVer) < 0) {
|
||||
const _pkg = _qrRunBackend === 'vllm' ? 'vLLM' : 'SGLang';
|
||||
const _hint = _qrRunBackend === 'vllm'
|
||||
? 'uv pip install -U vllm --torch-backend auto'
|
||||
: "pip install -U 'sglang[all]'";
|
||||
uiModule.showError(`Can't launch: ${modelData.name} needs ${_pkg} ≥ ${_minVer}, but ${_curVer} is installed${_qrHostStr ? ' on ' + _qrHostStr : ''}. Upgrade:\n${_hint}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (_e) {
|
||||
// Network/exec failed — fall through and let the launch try.
|
||||
}
|
||||
}
|
||||
|
||||
quickRunBtn.disabled = true;
|
||||
quickRunBtn.textContent = 'Starting...';
|
||||
|
||||
@@ -1353,6 +1704,23 @@ export function _expandModelRow(row, modelData) {
|
||||
// schema (repo_id + cmd) — sending `command`/`model` failed Pydantic
|
||||
// validation (422), which is why Run silently did nothing.
|
||||
const _srv = _serverByVal(_envState.remoteServerKey || host);
|
||||
|
||||
// Pre-flight: if the backend isn't installed on the target server,
|
||||
// route the user into Dependencies → recipe panel for that backend
|
||||
// instead of launching into an obvious "command not found" failure.
|
||||
const _ok = await _ensureBackendInstalled(
|
||||
runBackend,
|
||||
host,
|
||||
(_srv && _srv.port) || undefined,
|
||||
_envState.envPath || '',
|
||||
modelData.name,
|
||||
);
|
||||
if (!_ok) {
|
||||
quickRunBtn.disabled = false;
|
||||
quickRunBtn.textContent = 'Run';
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
repo_id: modelData.name,
|
||||
cmd: cmd,
|
||||
|
||||
+348
-60
@@ -8,6 +8,7 @@ import spinnerModule from './spinner.js';
|
||||
import { providerLogo } from './providers.js';
|
||||
import { makeWindowDraggable } from './windowDrag.js';
|
||||
import { _diagnose, _showDiagnosis, _clearDiagnosis, _runQuickCmd, ERROR_PATTERNS } from './cookbook-diagnosis.js';
|
||||
import { RECIPE_BACKENDS, recipesForBackend, pickRecipe, recipeCommands, RECIPE_DEFAULT_VARIANT } from './cookbook-deps-recipes.js';
|
||||
import { _hwfitCache, _hwfitDebounce, _hwfitFetch, _hwfitInit, _hwfitRenderList, _hwfitRenderHw, _renderGpuToggles, _expandModelRow, _fitColors, _hwfitColumns, _cachedModelIds, _gpuToggleTotal, _resetGpuToggleState } from './cookbook-hwfit.js';
|
||||
|
||||
// Sub-modules
|
||||
@@ -233,22 +234,39 @@ function _detectModelOptimizations(modelName) {
|
||||
const n = (modelName || '').toLowerCase();
|
||||
const opts = { envVars: [], flags: [], tips: [] };
|
||||
|
||||
// Qwen3.5 MoE models
|
||||
// Qwen3.5 MoE models — MoE-specific env vars + expert-parallel.
|
||||
// The --reasoning-parser flag is added uniformly below via
|
||||
// _detectReasoningParser, no longer hardcoded here.
|
||||
if (n.includes('qwen3.5') || n.includes('qwen3-') && (n.includes('a10b') || n.includes('a22b') || n.includes('a3b'))) {
|
||||
opts.envVars.push('VLLM_USE_DEEP_GEMM=0', 'VLLM_USE_FLASHINFER_MOE_FP16=1', 'VLLM_USE_FLASHINFER_SAMPLER=0', 'OMP_NUM_THREADS=4');
|
||||
opts.flags.push('--enable-expert-parallel', '--reasoning-parser qwen3');
|
||||
opts.flags.push('--enable-expert-parallel');
|
||||
opts.tips.push('MoE optimizations: expert parallel + flashinfer MoE kernels');
|
||||
}
|
||||
// Qwen3 MoE (non-3.5)
|
||||
else if (n.includes('qwen3') && (n.includes('a10b') || n.includes('a22b') || n.includes('a3b'))) {
|
||||
opts.envVars.push('VLLM_USE_DEEP_GEMM=0', 'VLLM_USE_FLASHINFER_MOE_FP16=1');
|
||||
opts.flags.push('--enable-expert-parallel', '--reasoning-parser qwen3');
|
||||
opts.flags.push('--enable-expert-parallel');
|
||||
opts.tips.push('MoE optimizations: expert parallel');
|
||||
}
|
||||
// DeepSeek MoE
|
||||
else if (n.includes('deepseek') && (n.includes('v3') || n.includes('r1'))) {
|
||||
// DeepSeek MoE — V3 / V3.1 / V4 (and future Vx), R1 / R2 reasoning.
|
||||
// Anything v-{integer} or r-{integer} family from DeepSeek is MoE in
|
||||
// current architectures. These models also require fp8 KV cache to
|
||||
// fit at meaningful context with current tensor-parallel layouts —
|
||||
// the launch crashes otherwise (--kv-cache-dtype auto → bf16 OOMs).
|
||||
else if (n.includes('deepseek') && /\b(v[3-9]|v\d{2,}|r[1-9])\b/.test(n)) {
|
||||
opts.flags.push('--enable-expert-parallel');
|
||||
opts.tips.push('MoE expert parallel for DeepSeek');
|
||||
opts.kvCacheDtype = 'fp8';
|
||||
opts.tips.push('fp8 KV cache required — bf16 OOMs at usable context');
|
||||
}
|
||||
// Reasoning parser — applies independently of MoE detection. Without this
|
||||
// flag, models like MiniMax-M2.x, DeepSeek-R1, Qwen3 reasoning, GLM-4.x,
|
||||
// gpt-oss leak <think> blocks as plain text instead of separating them
|
||||
// into the reasoning_content channel.
|
||||
const _reasoningParser = _detectReasoningParser(modelName);
|
||||
if (_reasoningParser) {
|
||||
opts.flags.push(`--reasoning-parser ${_reasoningParser}`);
|
||||
opts.tips.push(`Reasoning parser (${_reasoningParser}): splits <think> tokens into a separate channel`);
|
||||
}
|
||||
// Speculative decoding — pick the right MTP method per model family.
|
||||
// opts.spec.{method,tokens} seed the UI dropdown/input; the actual flag is
|
||||
@@ -257,7 +275,7 @@ function _detectModelOptimizations(modelName) {
|
||||
if (n.includes('qwen3-next') || (n.includes('qwen3.5') && (n.includes('a10b') || n.includes('a22b')))) {
|
||||
specDefault = { method: 'qwen3_next_mtp', tokens: 2 };
|
||||
} else if (
|
||||
(n.includes('deepseek') && (n.includes('v3') || n.includes('v3.1') || n.includes('r1'))) ||
|
||||
(n.includes('deepseek') && /\b(v[3-9]|v\d{2,}|r[1-9])\b/.test(n)) ||
|
||||
n.includes('kimi-k2') || n.includes('kimi_k2') ||
|
||||
n.includes('glm-4.5') || n.includes('glm4.5') ||
|
||||
n.includes('minimax-m1') || n.includes('minimax_m1')
|
||||
@@ -273,6 +291,36 @@ function _detectModelOptimizations(modelName) {
|
||||
return opts;
|
||||
}
|
||||
|
||||
/** Detect the right vLLM --reasoning-parser based on model name.
|
||||
* Returns the parser slug (matches vLLM's official list) or null when the
|
||||
* model isn't a reasoning model. Without the right parser, thinking tokens
|
||||
* leak as plain text instead of being split into a separate channel.
|
||||
* Source: vllm/reasoning/__init__.py registered parsers.
|
||||
*/
|
||||
export function _detectReasoningParser(modelName) {
|
||||
const n = (modelName || '').toLowerCase();
|
||||
// MiniMax M2 / M2.5 / M2.7 — released with a dedicated parser. Catch M2
|
||||
// before plain "minimax" so M2.x doesn't fall through to a wrong parser.
|
||||
if (n.includes('minimax') && n.match(/\bm2(?:\.\d)?\b/)) return 'minimax_m2';
|
||||
// DeepSeek-R1 / V3-Thinking / V3.1-Thinking variants. Bare V3/V3.1 (non-
|
||||
// thinking) skip this — they're not reasoning models.
|
||||
if (n.includes('deepseek') && (n.includes('r1') || n.includes('thinking'))) return 'deepseek_r1';
|
||||
// Qwen3 / Qwen3.5 reasoning models. Qwen3-Coder + Qwen3-Instruct don't
|
||||
// emit <think> blocks, so skip the parser there.
|
||||
if (n.includes('qwen3') && !n.includes('coder') && !n.includes('instruct')) return 'qwen3';
|
||||
// GLM-4 / GLM-4.5 / GLM-4.6 with reasoning.
|
||||
if (n.includes('glm-4') || n.includes('glm-5')) return 'glm45';
|
||||
// OpenAI gpt-oss family.
|
||||
if (n.includes('gpt-oss')) return 'gpt_oss';
|
||||
// Hunyuan A13B reasoning.
|
||||
if (n.includes('hunyuan') && n.includes('a13b')) return 'hunyuan_a13b';
|
||||
// IBM Granite reasoning.
|
||||
if (n.includes('granite') && (n.includes('reason') || n.includes('think'))) return 'granite';
|
||||
// InternLM reasoning.
|
||||
if (n.includes('internlm')) return 'internlm';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Detect the right vLLM tool-call-parser based on model name.
|
||||
* Qwen tool-call formats split by generation:
|
||||
* - Qwen3-Coder → qwen3_coder (XML <tool_call> with named params)
|
||||
@@ -416,7 +464,10 @@ export function _buildServeCmd(f, modelName, backend) {
|
||||
const _py3Bin = _venvBin ? `${_venvBin}python3` : 'python3';
|
||||
let cmd = '';
|
||||
if (backend === 'vllm') {
|
||||
const gpuId = f.gpu_id?.trim() || '';
|
||||
// GPU list comes from the Row-1 button strip (data-field="gpus") —
|
||||
// the bare "auto" input that used to back gpu_id is gone, and the
|
||||
// button strip is the only source for which devices to pin.
|
||||
const gpuId = (f.gpus || f.gpu_id || '').toString().trim();
|
||||
if (gpuId) cmd += `CUDA_VISIBLE_DEVICES=${gpuId} `;
|
||||
if (f.moe_env) {
|
||||
const _opts = _detectModelOptimizations(modelName);
|
||||
@@ -458,7 +509,10 @@ export function _buildServeCmd(f, modelName, backend) {
|
||||
cmd += ` --speculative-config '{"method":"${_specMethod}","num_speculative_tokens":${_specToks}}'`;
|
||||
}
|
||||
} else if (backend === 'sglang') {
|
||||
const gpuId = f.gpu_id?.trim() || '';
|
||||
// GPU list comes from the Row-1 button strip (data-field="gpus") —
|
||||
// the bare "auto" input that used to back gpu_id is gone, and the
|
||||
// button strip is the only source for which devices to pin.
|
||||
const gpuId = (f.gpus || f.gpu_id || '').toString().trim();
|
||||
if (gpuId) cmd += `CUDA_VISIBLE_DEVICES=${gpuId} `;
|
||||
const _extraEnv = (f.extra_env ?? '').toString().replace(/\s+/g, ' ').trim();
|
||||
if (_extraEnv) cmd += _extraEnv + ' ';
|
||||
@@ -475,7 +529,9 @@ export function _buildServeCmd(f, modelName, backend) {
|
||||
if (f.enforce_eager) cmd += ' --disable-cuda-graph';
|
||||
} else if (backend === 'llamacpp') {
|
||||
const ggufPath = f._gguf_path || 'model.gguf';
|
||||
const gpuId = f.gpu_id?.trim() || '';
|
||||
// GPU list — read from gpus (button strip); fall back to gpu_id for
|
||||
// backward-compat with older saved presets that pre-date the removal.
|
||||
const gpuId = (f.gpus || f.gpu_id || '').toString().trim();
|
||||
const py = _isWindows() ? 'python' : 'python3';
|
||||
// CPU-only serve (-ngl 0): drop the GPU-only flags, otherwise the command
|
||||
// mixes "zero GPU layers" with CUDA unified-memory + flash-attn and fails to
|
||||
@@ -737,6 +793,22 @@ async function _fetchDependencies() {
|
||||
return `<button class="cookbook-dep-tag cookbook-dep-install" data-dep-pip="${esc(pkg.pip)}" data-dep-target="${isLocal ? 'local' : 'remote'}">Install</button>`;
|
||||
};
|
||||
|
||||
// Per-package inline glyphs — same accent-coloured marks used in the
|
||||
// Backend picker on the Run page, so the Dependencies row visually
|
||||
// matches the engine you're configuring. Unknown packages get no
|
||||
// icon (the name alone is fine for librosa, hf_transfer, etc.).
|
||||
const _DEP_GLYPHS = {
|
||||
vllm: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 4l7 16 7-16"/><path d="M14 4l4 9 3-9"/></svg>',
|
||||
sglang: '<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" stroke="none" aria-hidden="true"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>',
|
||||
llama_cpp: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M8 12h8M12 8v8"/></svg>',
|
||||
ollama: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 10a6 6 0 0 1 12 0v4a4 4 0 0 1-8 0v-1"/><circle cx="10" cy="9" r="1"/><circle cx="14" cy="9" r="1"/></svg>',
|
||||
diffusers: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M5 19l2-2M17 7l2-2"/></svg>',
|
||||
};
|
||||
const _depGlyphHtml = (name) => {
|
||||
const g = _DEP_GLYPHS[name];
|
||||
return g ? `<span class="cookbook-dep-glyph" aria-hidden="true" style="display:inline-flex;align-items:center;justify-content:center;width:14px;height:14px;color:var(--accent, var(--red));margin-right:5px;vertical-align:-2px;">${g}</span>` : '';
|
||||
};
|
||||
|
||||
const _depRow = (pkg) => {
|
||||
const isLocal = pkg.target === 'local';
|
||||
const isSystemDep = pkg.kind === 'system';
|
||||
@@ -757,9 +829,16 @@ async function _fetchDependencies() {
|
||||
} else if (pkg.name === 'sglang' && pkg.installed) {
|
||||
_rebuildBtn = `<button type="button" class="cookbook-dep-tag cookbook-dep-rebuild cookbook-dep-reinstall" data-reinstall-pkg="sglang" title="Force-reinstall SGLang (pulls a matching torch). Runs as a tmux task in the Running tab.">Reinstall</button>`;
|
||||
}
|
||||
// For backends with a recipe catalog (vllm / sglang / llama_cpp),
|
||||
// append a caret button that toggles a per-row recipe panel below.
|
||||
const hasRecipe = RECIPE_BACKENDS.has(pkg.name);
|
||||
const recipeCaret = hasRecipe
|
||||
? `<button class="cookbook-dep-tag cookbook-dep-recipe-caret" data-dep-recipe-toggle="${esc(pkg.name)}" title="Pick a model to see the exact install commands" aria-expanded="false" style="background:none;border:1px solid var(--border);padding:2px 6px;display:inline-flex;align-items:center;cursor:pointer;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="transition:transform 0.15s"><polyline points="6 9 12 15 18 9"/></svg></button>`
|
||||
: '';
|
||||
const recipePanel = hasRecipe ? _recipePanelHtml(pkg.name) : '';
|
||||
return `<div class="cookbook-dep-row${winBlocked ? ' cookbook-dep-blocked' : ''}" data-pkg-name="${esc(pkg.name)}" data-dep-pip="${esc(pkg.pip || '')}" data-dep-target="${isLocal ? 'local' : 'remote'}" data-dep-kind="${esc(pkg.kind || 'python')}">`
|
||||
+ `<div class="cookbook-dep-info">`
|
||||
+ `<div class="memory-item-title">${esc(pkg.name)}</div>`
|
||||
+ `<div class="memory-item-title">${_depGlyphHtml(pkg.name)}${esc(pkg.name)}</div>`
|
||||
+ `<div class="memory-item-meta" style="font-size:10px;opacity:0.5;margin-top:2px;">${esc(pkg.desc)}</div>`
|
||||
+ note
|
||||
+ updateNote
|
||||
@@ -767,9 +846,65 @@ async function _fetchDependencies() {
|
||||
+ _rebuildBtn
|
||||
+ `<span class="cookbook-dep-tag cookbook-dep-cat">${esc(pkg.category)}</span>`
|
||||
+ _statusTag(pkg, isLocal, isSystemDep, winBlocked)
|
||||
+ `</div>`;
|
||||
+ recipeCaret
|
||||
+ `</div>`
|
||||
+ recipePanel;
|
||||
};
|
||||
|
||||
// Prepend the configured venv's activate line (pip variant only) so
|
||||
// the user sees a paste-ready sequence; Run keeps using env_prefix to
|
||||
// activate the same venv before the pip command. Docker variant skips
|
||||
// the activate line — `docker pull` doesn't need a venv.
|
||||
function _recipeDisplayText(commands, variant) {
|
||||
if (variant === 'docker') return commands.join('\n');
|
||||
const envPath = (_envState.envPath || '').replace(/\/+$/, '');
|
||||
const activate = envPath
|
||||
? `source ${envPath}${envPath.endsWith('/bin/activate') ? '' : '/bin/activate'}`
|
||||
: '# (activate your venv first)';
|
||||
return [activate, ...commands].join('\n');
|
||||
}
|
||||
|
||||
// Per-backend recipe panel (model picker + commands + Copy/Run).
|
||||
// Lives directly below the row it expands and starts collapsed.
|
||||
// The model picker lists every downloaded model from _cachedModelIds
|
||||
// (the same set the Launch tab uses); pickRecipe() then finds the
|
||||
// best-matching recipe for whatever the user selects, with the
|
||||
// backend's generic entry as the fallback.
|
||||
function _recipePanelHtml(backend) {
|
||||
const candidates = recipesForBackend(backend);
|
||||
if (!candidates.length) return '';
|
||||
const downloadedIds = _cachedModelIds ? Array.from(_cachedModelIds).sort() : [];
|
||||
const modelOptions = downloadedIds.length
|
||||
? downloadedIds.map(id => `<option value="${esc(id)}">${esc(id)}</option>`).join('')
|
||||
: '';
|
||||
// "Other" entry: user types/pastes an id, OR uses the generic fallback
|
||||
// when no models have been downloaded yet.
|
||||
const otherOpt = `<option value="">Other (generic ${esc(backend)} install)</option>`;
|
||||
const opts = modelOptions + otherOpt;
|
||||
// Initial recipe: the generic fallback (matches first time, no model id).
|
||||
const initial = pickRecipe(backend, '') || candidates[0];
|
||||
const initialVariant = RECIPE_DEFAULT_VARIANT;
|
||||
const initialCmds = recipeCommands(initial, initialVariant);
|
||||
const rightActive = initialVariant === 'docker' ? ' mode-right' : '';
|
||||
return `<div class="cookbook-dep-recipe-panel" data-dep-recipe-panel="${esc(backend)}" data-dep-recipe-active-variant="${esc(initialVariant)}" style="display:none;margin:-4px 0 8px;padding:8px 12px 10px;background:rgba(0,0,0,0.04);border:1px solid var(--border);border-top:none;border-radius:0 0 6px 6px;">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
|
||||
<span style="font-size:11px;opacity:0.75;flex-shrink:0;">Serving which model?</span>
|
||||
<select class="settings-select cookbook-dep-recipe-pick" data-dep-recipe-pick="${esc(backend)}" style="flex:1;font-size:11px;padding:3px 6px;">${opts}</select>
|
||||
<div class="mode-toggle${rightActive}" data-dep-recipe-variants="${esc(backend)}" style="flex-shrink:0;">
|
||||
<button type="button" class="mode-toggle-btn${initialVariant === 'pip' ? ' active' : ''}" data-dep-recipe-variant="${esc(backend)}" data-variant="pip" aria-pressed="${initialVariant === 'pip'}">Pip/uv</button>
|
||||
<button type="button" class="mode-toggle-btn${initialVariant === 'docker' ? ' active' : ''}" data-dep-recipe-variant="${esc(backend)}" data-variant="docker" aria-pressed="${initialVariant === 'docker'}">Docker</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="position:relative;">
|
||||
<pre class="cookbook-dep-recipe-cmds" data-dep-recipe-cmds="${esc(backend)}" data-dep-recipe-install="${esc(initialCmds.join('\n'))}" style="margin:0;padding:8px 36px 8px 10px;background:rgba(0,0,0,0.08);border-radius:4px;font-size:11px;line-height:1.5;overflow-x:auto;white-space:pre;">${esc(_recipeDisplayText(initialCmds, initialVariant))}</pre>
|
||||
<button type="button" id="recipe-copy-${esc(backend)}" class="cookbook-dep-recipe-copy" data-dep-recipe-copy="${esc(backend)}" title="Copy" aria-label="Copy" style="position:absolute;top:6px;right:6px;padding:3px 5px;background:none;border:none;color:inherit;opacity:0.7;cursor:pointer;display:inline-flex;align-items:center;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;justify-content:flex-end;margin-top:6px;">
|
||||
<button type="button" class="cookbook-dep-tag cookbook-dep-install cookbook-dep-recipe-run" data-dep-recipe-run="${esc(backend)}" style="display:inline-flex;align-items:center;gap:4px;cursor:pointer;"><svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>Run</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const _section = (title, note, items) =>
|
||||
items.length
|
||||
? `<div class="cookbook-dep-section"><span class="cookbook-dep-section-title">${title}</span><span class="cookbook-dep-section-note">${note}</span></div>` + items.map(_depRow).join('')
|
||||
@@ -866,7 +1001,7 @@ async function _fetchDependencies() {
|
||||
}
|
||||
|
||||
// Wire install buttons (not-installed packages)
|
||||
list.querySelectorAll('.cookbook-dep-install').forEach(btn => {
|
||||
list.querySelectorAll('.cookbook-dep-install:not(.cookbook-dep-recipe-run)').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
const pipName = btn.dataset.depPip;
|
||||
@@ -875,6 +1010,135 @@ async function _fetchDependencies() {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Recipe panel wiring (per-backend dropdown with model + commands) ──
|
||||
// Caret toggle: shows/hides the panel directly below the backend row.
|
||||
list.querySelectorAll('[data-dep-recipe-toggle]').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const backend = btn.dataset.depRecipeToggle;
|
||||
const panel = list.querySelector(`[data-dep-recipe-panel="${CSS.escape(backend)}"]`);
|
||||
if (!panel) return;
|
||||
const open = panel.style.display === 'none' || !panel.style.display;
|
||||
panel.style.display = open ? 'block' : 'none';
|
||||
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
const caret = btn.querySelector('svg');
|
||||
if (caret) caret.style.transform = open ? 'rotate(180deg)' : '';
|
||||
});
|
||||
});
|
||||
// Re-render the <pre> for a backend using the currently-active variant
|
||||
// (pip / docker) and the currently-picked model. Used by every input
|
||||
// that changes which install sequence we should show.
|
||||
function _refreshRecipePre(backend) {
|
||||
const panel = list.querySelector(`[data-dep-recipe-panel="${CSS.escape(backend)}"]`);
|
||||
if (!panel) return;
|
||||
const variant = panel.dataset.depRecipeActiveVariant || RECIPE_DEFAULT_VARIANT;
|
||||
const sel = panel.querySelector('[data-dep-recipe-pick]');
|
||||
const recipe = pickRecipe(backend, (sel && sel.value) || '');
|
||||
const cmds = recipeCommands(recipe, variant);
|
||||
const pre = panel.querySelector('[data-dep-recipe-cmds]');
|
||||
if (pre) {
|
||||
pre.textContent = _recipeDisplayText(cmds, variant);
|
||||
pre.dataset.depRecipeInstall = cmds.join('\n');
|
||||
}
|
||||
}
|
||||
// Model select: pickRecipe matches the model id against the catalog.
|
||||
list.querySelectorAll('[data-dep-recipe-pick]').forEach(sel => {
|
||||
sel.addEventListener('change', () => _refreshRecipePre(sel.dataset.depRecipePick));
|
||||
});
|
||||
// Variant toggle (Pip/uv vs Docker): mirrors the agent/chat mode-toggle
|
||||
// pattern — buttons get .active, container gets .mode-right when the
|
||||
// right slot is selected so the sliding pill animates over.
|
||||
list.querySelectorAll('[data-dep-recipe-variant]').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const backend = btn.dataset.depRecipeVariant;
|
||||
const variant = btn.dataset.variant;
|
||||
const panel = list.querySelector(`[data-dep-recipe-panel="${CSS.escape(backend)}"]`);
|
||||
if (!panel) return;
|
||||
panel.dataset.depRecipeActiveVariant = variant;
|
||||
const container = panel.querySelector('.mode-toggle[data-dep-recipe-variants]');
|
||||
if (container) container.classList.toggle('mode-right', variant === 'docker');
|
||||
panel.querySelectorAll('[data-dep-recipe-variant]').forEach(b => {
|
||||
const on = b.dataset.variant === variant;
|
||||
b.classList.toggle('active', on);
|
||||
b.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||||
});
|
||||
_refreshRecipePre(backend);
|
||||
});
|
||||
});
|
||||
// Copy: drop the visible command block on the clipboard.
|
||||
list.querySelectorAll('[data-dep-recipe-copy]').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
const backend = btn.dataset.depRecipeCopy;
|
||||
const pre = list.querySelector(`[data-dep-recipe-cmds="${CSS.escape(backend)}"]`);
|
||||
if (!pre) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(pre.textContent);
|
||||
uiModule.showToast('Copied');
|
||||
} catch {
|
||||
// Fallback for non-secure contexts: select the pre's text so
|
||||
// the user can Ctrl+C themselves.
|
||||
const sel = window.getSelection(); const range = document.createRange();
|
||||
range.selectNodeContents(pre); sel.removeAllRanges(); sel.addRange(range);
|
||||
}
|
||||
});
|
||||
});
|
||||
// Run: launch the install command(s) as a tmux task on the currently-
|
||||
// selected deps server. Activation comes from env_prefix (same plumbing
|
||||
// the Install button uses) so the install lands in the configured venv
|
||||
// instead of a fresh .venv in some random CWD.
|
||||
list.querySelectorAll('[data-dep-recipe-run]').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
const backend = btn.dataset.depRecipeRun;
|
||||
const pre = list.querySelector(`[data-dep-recipe-cmds="${CSS.escape(backend)}"]`);
|
||||
if (!pre) return;
|
||||
// Use the install-only command list (no activate line) — the
|
||||
// displayed source line is for the user's reading; env_prefix
|
||||
// handles it for the actual run.
|
||||
const installRaw = pre.dataset.depRecipeInstall || pre.textContent;
|
||||
const cmd = installRaw.split('\n').map(s => s.trim()).filter(Boolean).join(' && ');
|
||||
const depsSel = document.getElementById('hwfit-deps-server');
|
||||
if (depsSel) _applyServerSelection(depsSel.value);
|
||||
const targetHost = _envState.remoteHost || 'local';
|
||||
// Build env_prefix from the configured envPath (matches _installDep).
|
||||
let envPrefix = '';
|
||||
if (_envState.env === 'venv' && _envState.envPath) {
|
||||
const p = _envState.envPath;
|
||||
envPrefix = 'source ' + _shellQuote(p.endsWith('/bin/activate') ? p : p + '/bin/activate');
|
||||
} else if (_envState.env === 'conda' && _envState.envPath) {
|
||||
envPrefix = 'eval "$(conda shell.bash hook)" && conda activate ' + _shellQuote(_envState.envPath);
|
||||
}
|
||||
const reqBody = {
|
||||
repo_id: `${backend} setup`,
|
||||
cmd: cmd,
|
||||
remote_host: _envState.remoteHost || undefined,
|
||||
ssh_port: _getPort(_envState.remoteHost) || undefined,
|
||||
env_prefix: envPrefix || undefined,
|
||||
platform: _envState.platform || undefined,
|
||||
};
|
||||
try {
|
||||
const res = await fetch('/api/model/serve', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(reqBody),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || !data.ok) {
|
||||
uiModule.showToast('Run failed: ' + String(data.detail || data.error || `HTTP ${res.status}`).slice(0, 200));
|
||||
return;
|
||||
}
|
||||
const payload = { repo_id: `${backend} setup`, _cmd: cmd, remote_host: _envState.remoteHost || '', _dep: true };
|
||||
_addTask(data.session_id, `${backend} setup`, 'download', payload);
|
||||
uiModule.showToast(`Running ${backend} setup on ${targetHost}…`);
|
||||
} catch (err) {
|
||||
uiModule.showToast('Run failed: ' + err.message);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// Wire the ⋮ menu on installed packages — currently just "Update".
|
||||
function _showDepMenu(anchor) {
|
||||
document.querySelectorAll('.cookbook-dep-menu').forEach(d => d.remove());
|
||||
@@ -1404,16 +1668,49 @@ function _wireTabEvents(body) {
|
||||
const dlFoldBody = document.getElementById('cookbook-dl-tab-fold-body');
|
||||
const dlFoldChevron = document.getElementById('cookbook-dl-tab-chevron');
|
||||
if (dlFold && dlFoldBody && dlFoldChevron) {
|
||||
const _setFolded = (folded, persist = true) => {
|
||||
// Toggle via class so CSS transition animates the height/opacity
|
||||
// — display:none was an instant on/off and felt jarring.
|
||||
dlFoldBody.classList.toggle('is-folded', folded);
|
||||
dlFoldChevron.textContent = folded ? '▸' : '▾';
|
||||
dlFold.classList.toggle('is-folded', folded);
|
||||
if (persist) {
|
||||
try { localStorage.setItem('cookbook_dl_tab_folded_v1', folded ? '1' : '0'); } catch {}
|
||||
}
|
||||
};
|
||||
dlFold.addEventListener('click', () => {
|
||||
const folded = dlFoldBody.style.display === 'none';
|
||||
dlFoldBody.style.display = folded ? '' : 'none';
|
||||
dlFoldChevron.textContent = folded ? '▾' : '▸';
|
||||
// Toggle is-folded class on the h2 so the line under it only shows when
|
||||
// the section is collapsed (the body's content normally provides
|
||||
// separation; with no body visible, the line gives the h2 definition).
|
||||
dlFold.classList.toggle('is-folded', !folded);
|
||||
try { localStorage.setItem('cookbook_dl_tab_folded_v1', folded ? '0' : '1'); } catch {}
|
||||
const folded = dlFoldBody.classList.contains('is-folded');
|
||||
_setFolded(!folded);
|
||||
});
|
||||
// Auto-fold on any downward scroll inside the cookbook modal,
|
||||
// and auto-expand when the user scrolls all the way back to the
|
||||
// top of whichever scroller they're in. The chevron ▸ still
|
||||
// toggles manually.
|
||||
const _maybeFold = () => {
|
||||
if (dlFoldBody.classList.contains('is-folded')) return;
|
||||
_setFolded(true, /* persist */ false);
|
||||
};
|
||||
const _maybeExpand = () => {
|
||||
if (!dlFoldBody.classList.contains('is-folded')) return;
|
||||
_setFolded(false, /* persist */ false);
|
||||
};
|
||||
// Capture phase so scrolls on nested scrollers (.hwfit-list,
|
||||
// .cookbook-body, .modal-content) all hit us.
|
||||
const _modal = dlFold.closest('#cookbook-modal') || document;
|
||||
const _lastY = new WeakMap();
|
||||
_modal.addEventListener('scroll', (e) => {
|
||||
const tgt = e.target;
|
||||
if (!tgt || typeof tgt.scrollTop !== 'number') return;
|
||||
// Ignore scrolls that originate INSIDE the Direct Download body
|
||||
// (e.g. the Trending models list) — those are local to the
|
||||
// section and shouldn't auto-fold the section that owns them.
|
||||
if (dlFoldBody.contains && (tgt === dlFoldBody || dlFoldBody.contains(tgt))) return;
|
||||
const y = tgt.scrollTop;
|
||||
const prev = _lastY.get(tgt) || 0;
|
||||
if (y > prev) _maybeFold();
|
||||
else if (y <= 0) _maybeExpand();
|
||||
_lastY.set(tgt, y);
|
||||
}, true);
|
||||
}
|
||||
const hfToggle = document.getElementById('cookbook-hf-latest-toggle');
|
||||
const hfArrow = document.getElementById('cookbook-hf-latest-arrow');
|
||||
@@ -1571,9 +1868,9 @@ function _wireTabEvents(body) {
|
||||
document.getElementById('hwfit-server-select')?.addEventListener('change', _onServerChange);
|
||||
}
|
||||
|
||||
// Browse Ollama library — popular models from ollama.com via cached backend
|
||||
// proxy. Click a row → fills the download input with `<name>:<size>` so the
|
||||
// existing Download button kicks off `ollama pull`.
|
||||
// Browse Ollama library popup removed — Engine = Ollama in the
|
||||
// Scan / Download filter covers this use case. The handler below is a
|
||||
// no-op now because the elements no longer exist.
|
||||
const olToggle = document.getElementById('cookbook-ollama-toggle');
|
||||
const olArrow = document.getElementById('cookbook-ollama-arrow');
|
||||
const olList = document.getElementById('cookbook-ollama-list');
|
||||
@@ -1774,8 +2071,8 @@ function _renderRecipes() {
|
||||
|
||||
// Tabs
|
||||
html += '<div class="cookbook-tabs">';
|
||||
html += '<button class="cookbook-tab" data-backend="Serve"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" stroke="none" style="vertical-align:-1px;margin-right:3px;"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>Launch</button>';
|
||||
html += '<button class="cookbook-tab active" data-backend="Search"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" style="vertical-align:-1px;margin-right:3px;"><polyline points="7 14 12 19 17 14"/><line x1="12" y1="19" x2="12" y2="5"/><line x1="5" y1="21" x2="19" y2="21"/></svg>Download</button>';
|
||||
html += '<button class="cookbook-tab" data-backend="Serve"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" style="vertical-align:-1px;margin-right:3px;"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><circle cx="6" cy="6" r="1"/><circle cx="6" cy="18" r="1"/></svg>Serve</button>';
|
||||
html += '<button class="cookbook-tab" data-backend="Dependencies"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" style="vertical-align:-1px;margin-right:3px;"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>Dependencies</button>';
|
||||
html += '<button class="cookbook-tab" data-backend="Settings"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" style="vertical-align:-1px;margin-right:3px;"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>Settings</button>';
|
||||
html += '</div>';
|
||||
@@ -1788,9 +2085,9 @@ function _renderRecipes() {
|
||||
// State persisted to localStorage so the fold survives reloads.
|
||||
const _dlTabFolded = (() => { try { return localStorage.getItem('cookbook_dl_tab_folded_v1') === '1'; } catch { return false; } })();
|
||||
html += '<div style="display:flex;align-items:center;gap:8px;margin-bottom:2px;">';
|
||||
html += `<h2 id="cookbook-dl-tab-fold" class="${_dlTabFolded ? 'is-folded' : ''}" style="margin:0;padding:0;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:space-between;user-select:none;flex:1;">Download<span id="cookbook-dl-tab-chevron" style="display:inline-block;transition:transform 0.15s;font-size:1.1em;margin-left:8px;opacity:0.85;">${_dlTabFolded ? '▸' : '▾'}</span></h2>`;
|
||||
html += `<h2 id="cookbook-dl-tab-fold" class="${_dlTabFolded ? 'is-folded' : ''}" style="margin:0;padding:0;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:space-between;user-select:none;flex:1;">Direct Download<span id="cookbook-dl-tab-chevron" style="display:inline-block;transition:transform 0.15s;font-size:1.1em;margin-left:8px;opacity:0.85;">${_dlTabFolded ? '▸' : '▾'}</span></h2>`;
|
||||
html += '</div>';
|
||||
html += `<div id="cookbook-dl-tab-fold-body" style="${_dlTabFolded ? 'display:none;' : ''}">`;
|
||||
html += `<div id="cookbook-dl-tab-fold-body" class="${_dlTabFolded ? 'is-folded' : ''}">`;
|
||||
html += '<p class="memory-desc doclib-desc" style="margin-top:6px;">Download from <a href="https://huggingface.co/models" target="_blank" rel="noopener" style="color:var(--accent,var(--red));text-decoration:none;"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:1px;"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>HuggingFace</a> by pasting model link, or download directly in the Scan section below.</p>';
|
||||
html += '<div class="hwfit-container" id="hwfit-container">';
|
||||
|
||||
@@ -1820,42 +2117,34 @@ function _renderRecipes() {
|
||||
// silently sending downloads to the wrong server. An empty selection means Local; the user
|
||||
// chooses a remote server explicitly via the dropdown.
|
||||
|
||||
// Manual download input
|
||||
html += `<div style="margin-top:7px;margin-bottom:2px;display:flex;gap:4px;align-items:center;">`;
|
||||
// Manual download input — server picker on the same row as the repo input,
|
||||
// on the left. The standalone "add server" button is gone (use Settings).
|
||||
html += `<div class="cookbook-dl-input" style="margin-top:7px;display:flex;gap:4px;align-items:center;">`;
|
||||
if (_es.servers.length > 1) {
|
||||
html += `<select class="cookbook-field-input hwfit-dl-server" id="hwfit-dl-server" style="height:28px;position:relative;top:0px;">`;
|
||||
html += `<select class="cookbook-field-input hwfit-dl-server" id="hwfit-dl-server" style="height:28px;flex-shrink:0;">`;
|
||||
html += _buildServerOpts(true);
|
||||
html += `</select>`;
|
||||
} else {
|
||||
html += `<input type="hidden" id="hwfit-dl-server" value="local" />`;
|
||||
}
|
||||
html += `<button class="memory-toolbar-btn cookbook-dl-add-server" title="Add server in Settings" style="height:28px;">add server</button>`;
|
||||
html += `</div>`;
|
||||
html += `<div class="cookbook-dl-input" style="margin-top:0;">`;
|
||||
html += `<input type="text" class="cookbook-dl-repo" id="cookbook-dl-repo" placeholder="org/model-name, qwen2.5:14b, or HF URL" />`;
|
||||
html += `<input type="text" class="cookbook-dl-repo" id="cookbook-dl-repo" placeholder="org/model-name, qwen2.5:14b, or HF URL" style="flex:1;min-width:0;" />`;
|
||||
html += `<button class="cookbook-btn cookbook-dl-btn" id="cookbook-dl-btn">Download</button>`;
|
||||
html += `</div>`;
|
||||
// Browse Ollama library — fetches popular models from ollama.com via the
|
||||
// /api/cookbook/ollama/library cached proxy, click → fills the input with
|
||||
// `<name>:<size>` so the existing Download button kicks off `ollama pull`.
|
||||
html += `<div style="margin-top:5px;position:relative;top:-3px;">`;
|
||||
html += `<div style="display:flex;gap:4px;align-items:center;">`;
|
||||
html += `<button type="button" class="memory-toolbar-btn" id="cookbook-ollama-toggle" style="flex:1;text-align:left;height:26px;display:flex;align-items:center;gap:6px;border-radius:4px;">`;
|
||||
html += `<span id="cookbook-ollama-arrow" style="display:inline-block;transition:transform 0.15s;pointer-events:none;">▸</span>`;
|
||||
html += `<span style="pointer-events:none;">Browse Ollama library</span>`;
|
||||
html += `</button>`;
|
||||
html += `<button type="button" class="memory-toolbar-btn" id="cookbook-ollama-refresh" title="Refresh" style="height:26px;width:26px;padding:0;border-radius:4px;">↻</button>`;
|
||||
html += `</div>`;
|
||||
html += `<div id="cookbook-ollama-list" style="display:none;margin-top:4px;max-height:320px;overflow-y:auto;flex-direction:column;gap:4px;"></div>`;
|
||||
html += `</div>`;
|
||||
// Ollama-library browse used to live here as its own collapsible dropdown,
|
||||
// but that duplicated the Engine filter (which already has Ollama). The
|
||||
// standalone UI is gone — to find Ollama models, set Engine = Ollama in
|
||||
// the Scan / Download section below.
|
||||
// Latest HF models that fit — collapsible card list
|
||||
html += `<div style="margin-top:5px;position:relative;top:-3px;">`;
|
||||
html += `<div style="margin-top:5px;position:relative;top:-11px;">`;
|
||||
html += `<div style="display:flex;gap:4px;align-items:center;">`;
|
||||
html += `<button type="button" class="memory-toolbar-btn" id="cookbook-hf-latest-toggle" style="flex:1;text-align:left;height:26px;display:flex;align-items:center;gap:6px;border-radius:4px;">`;
|
||||
html += `<span id="cookbook-hf-latest-arrow" style="display:inline-block;transition:transform 0.15s;pointer-events:none;">\u25B8</span>`;
|
||||
html += `<span style="pointer-events:none;">Trending models that fit your hardware</span>`;
|
||||
html += `<button type="button" class="memory-toolbar-btn" id="cookbook-hf-latest-toggle" style="flex:1;text-align:left;height:28px;font-size:11px;display:flex;align-items:center;gap:6px;border-radius:5px;">`;
|
||||
// Trending-up icon (accent) so the section reads as "what's hot".
|
||||
html += `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--accent, var(--red))" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex-shrink:0;pointer-events:none;"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/></svg>`;
|
||||
html += `<span style="pointer-events:none;flex:1;">Trending models that fit your hardware</span>`;
|
||||
// Chevron moved to the RIGHT \u2014 collapsed = pointing right, expanded
|
||||
// = rotated 90deg into a down chevron (handled by existing toggle CSS).
|
||||
html += `<span id="cookbook-hf-latest-arrow" style="display:inline-block;transition:transform 0.15s;pointer-events:none;opacity:0.6;font-size:11px;">\u25B8</span>`;
|
||||
html += `</button>`;
|
||||
html += `<button type="button" class="memory-toolbar-btn" id="cookbook-hf-latest-refresh" title="Refresh" style="height:26px;width:26px;padding:0;border-radius:4px;">\u21BB</button>`;
|
||||
html += `</div>`;
|
||||
html += `<div id="cookbook-hf-latest-list" style="display:none;margin-top:4px;max-height:320px;overflow-y:auto;flex-direction:column;gap:4px;"></div>`;
|
||||
html += `</div>`;
|
||||
@@ -1876,9 +2165,10 @@ function _renderRecipes() {
|
||||
// Image tab removed — text→image gen is gone from this build (only inpaint
|
||||
// remains, which uses its own settings panel). Vision (multimodal) stays.
|
||||
html += '<option value="multimodal">Vision</option></select>';
|
||||
// Engine sits next to the type filter so the "what category / which serving
|
||||
// path" filters live together; Quant + Context are storage-format and budget
|
||||
// levers, grouped to the right.
|
||||
// Search moved next to the Type filter so the two primary picks
|
||||
// (what category + free text) sit together; the more advanced
|
||||
// levers (Engine / Quant / Context) live to the right.
|
||||
html += '<input type="text" class="cookbook-field-input hwfit-search" id="hwfit-search" placeholder="Search models..." style="flex:1;" />';
|
||||
html += '<span class="hwfit-engine-wrap">';
|
||||
html += '<select class="cookbook-field-input hwfit-engine" id="hwfit-engine" style="height:28px;" title="Filter by serving engine">';
|
||||
html += '<option value="">Engine</option>';
|
||||
@@ -1893,7 +2183,7 @@ function _renderRecipes() {
|
||||
// quant for every model instead of silently filtering to Q4.
|
||||
html += '<span class="hwfit-quant-wrap">';
|
||||
html += '<select class="cookbook-field-input hwfit-quant" id="hwfit-quant" style="height:28px;">';
|
||||
html += '<option value="" selected>Quant: All</option>';
|
||||
html += '<option value="" selected>Quant</option>';
|
||||
html += '<option value="Q4_K_M">Q4</option><option value="Q8_0">Q8</option>';
|
||||
html += '<option value="Q6_K">Q6</option><option value="Q5_K_M">Q5</option>';
|
||||
html += '<option value="Q3_K_M">Q3</option><option value="Q2_K">Q2</option>';
|
||||
@@ -1906,21 +2196,19 @@ function _renderRecipes() {
|
||||
html += '<label class="hwfit-ctx-control" title="Context length for fit estimates. Lower it to find more models that could fit your hardware.">';
|
||||
html += '<span>Context</span><span class="hwfit-help-chip hwfit-help-chip-inline" title="Context length. Lower it to find more models that could fit your hardware; raise it when you need longer chats or documents.">?</span><input type="range" id="hwfit-context" min="0" max="5" step="1" value="3" />';
|
||||
html += '<output id="hwfit-context-label">50k</output></label>';
|
||||
// Search lives at the far right of the toolbar so the controls (Type/Quant/
|
||||
// Engine/Context) read as a row of compact filters followed by free-text.
|
||||
html += '<input type="text" class="cookbook-field-input hwfit-search" id="hwfit-search" placeholder="Search models..." style="flex:1;" />';
|
||||
html += '</div>';
|
||||
html += '<div class="hwfit-toolbar" style="margin-top:7px;">';
|
||||
html += '<select class="cookbook-field-input hwfit-server-select" id="hwfit-server-select" style="height:28px;min-width:88px;position:relative;top:0px;">';
|
||||
html += _buildServerOpts(false);
|
||||
html += '</select>';
|
||||
html += '<div class="hwfit-gpu-toggles" id="hwfit-gpu-toggles"></div>';
|
||||
// Scan/refresh button (icon-only) where the quant dropdown used to sit.
|
||||
html += '<button type="button" class="hwfit-gpu-btn" id="hwfit-rescan" title="Re-scan hardware" style="flex-shrink:0;position:relative;top:-3px;left:-1px;">↻ RESCAN</button>';
|
||||
// (Rescan button removed — Edit handles manual hardware updates;
|
||||
// automatic re-probe runs on container restart.)
|
||||
html += '<button type="button" class="hwfit-gpu-btn hwfit-hw-manual-btn" id="hwfit-hw-manual-btn" title="Set hardware manually" style="flex-shrink:0;position:relative;top:-3px;left:-1px;display:inline-flex;align-items:center;gap:3px;"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>EDIT</button>';
|
||||
// Sort state — the clickable column headers read/write this (pewds' original
|
||||
// sort paradigm). Newest is reachable by clicking the Model column header.
|
||||
html += '<select class="cookbook-field-input hwfit-sort" id="hwfit-sort" style="display:none">';
|
||||
html += '<option value="newest" selected>Latest</option>';
|
||||
html += '<option value="fit">Fit</option><option value="score">Score</option><option value="vram">VRAM</option>';
|
||||
html += '<option value="speed">Speed</option><option value="params">Params</option>';
|
||||
html += '<option value="context">Context</option></select>';
|
||||
|
||||
+100
-44
@@ -808,7 +808,7 @@ function _winSessionCmd(task, tmuxArgs) {
|
||||
return host ? `ssh ${pf}${host} 'tmux ${tmuxArgs}' 2>/dev/null` : `tmux ${tmuxArgs} 2>/dev/null`;
|
||||
}
|
||||
|
||||
function _tmuxGracefulKill(task) {
|
||||
export function _tmuxGracefulKill(task) {
|
||||
if (_isWindows(task)) {
|
||||
const host = task.remoteHost;
|
||||
const sd = host ? '$env:TEMP\\odysseus-sessions' : '$env:TEMP\\odysseus-tmux';
|
||||
@@ -825,6 +825,48 @@ function _tmuxGracefulKill(task) {
|
||||
return `tmux send-keys -t ${task.sessionId} C-c 2>/dev/null; sleep 2; tmux kill-session -t ${task.sessionId} 2>/dev/null`;
|
||||
}
|
||||
|
||||
// Force-kill escalation: SIGKILL the tmux pane's owning PID and any children,
|
||||
// then nuke the session. Use AFTER the graceful kill when the process is
|
||||
// still detected — vLLM sometimes ignores SIGINT during model init, and a
|
||||
// stuck CUDA context can survive `tmux kill-session` alone.
|
||||
export function _tmuxForceKill(task) {
|
||||
if (_isWindows(task)) {
|
||||
// Windows graceful path already does Stop-Process -Force, so the same
|
||||
// command serves as the "force" variant.
|
||||
return _tmuxGracefulKill(task);
|
||||
}
|
||||
const sid = task.sessionId;
|
||||
const inner =
|
||||
`PIDS=$(tmux list-panes -t ${sid} -F "#{pane_pid}" 2>/dev/null); ` +
|
||||
`if [ -n "$PIDS" ]; then ` +
|
||||
` for P in $PIDS; do ` +
|
||||
` pkill -KILL -P "$P" 2>/dev/null; ` +
|
||||
` kill -9 "$P" 2>/dev/null; ` +
|
||||
` done; ` +
|
||||
`fi; ` +
|
||||
`tmux kill-session -t ${sid} 2>/dev/null`;
|
||||
if (task.remoteHost) {
|
||||
return `ssh ${_sshPrefix(_getPort(task))}${task.remoteHost} ${_shQuote(inner)}`;
|
||||
}
|
||||
return inner;
|
||||
}
|
||||
|
||||
// Returns a shell snippet that prints "ALIVE" if the tmux session still
|
||||
// exists (or its main PID is still listed in /proc), "DEAD" otherwise.
|
||||
// Used by the Stop-all escalation to decide whether to force-kill.
|
||||
export function _tmuxIsAliveCheck(task) {
|
||||
if (_isWindows(task)) {
|
||||
// Skip the check on Windows — the graceful path already force-kills.
|
||||
return null;
|
||||
}
|
||||
const sid = task.sessionId;
|
||||
const inner = `if tmux has-session -t ${sid} 2>/dev/null; then echo ALIVE; else echo DEAD; fi`;
|
||||
if (task.remoteHost) {
|
||||
return `ssh ${_sshPrefix(_getPort(task))}${task.remoteHost} ${_shQuote(inner)}`;
|
||||
}
|
||||
return inner;
|
||||
}
|
||||
|
||||
function _shQuote(value) {
|
||||
return "'" + String(value ?? '').replace(/'/g, "'\\''") + "'";
|
||||
}
|
||||
@@ -1643,7 +1685,7 @@ export function _renderRunningTab() {
|
||||
runTab.className = 'cookbook-tab';
|
||||
runTab.dataset.backend = 'Running';
|
||||
const _errCount = tasks.filter(t => t.status === 'error' || t.status === 'crashed').length;
|
||||
runTab.innerHTML = `Running${activeCountHtml}${_errCount ? `<span class="cookbook-tab-error-dot"></span>` : ''}`;
|
||||
runTab.innerHTML = `Active${activeCountHtml}${_errCount ? `<span class="cookbook-tab-error-dot"></span>` : ''}`;
|
||||
tabBar.insertBefore(runTab, tabBar.firstChild);
|
||||
runTab.addEventListener('click', () => {
|
||||
tabBar.querySelectorAll('.cookbook-tab').forEach(t => t.classList.remove('active'));
|
||||
@@ -1654,7 +1696,7 @@ export function _renderRunningTab() {
|
||||
});
|
||||
} else if (runTab) {
|
||||
const _errCount2 = tasks.filter(t => t.status === 'error' || t.status === 'crashed').length;
|
||||
runTab.innerHTML = tasks.length ? `Running${activeCountHtml}${_errCount2 ? '<span class="cookbook-tab-error-dot"></span>' : ''}` : 'Running';
|
||||
runTab.innerHTML = tasks.length ? `Active${activeCountHtml}${_errCount2 ? '<span class="cookbook-tab-error-dot"></span>' : ''}` : 'Active';
|
||||
if (!hasContent) {
|
||||
if (runTab.classList.contains('active')) {
|
||||
const wfTab = tabBar.querySelector('.cookbook-tab[data-backend="Search"]');
|
||||
@@ -1669,9 +1711,13 @@ export function _renderRunningTab() {
|
||||
group = document.createElement('div');
|
||||
group.className = 'cookbook-group hidden';
|
||||
group.dataset.backendGroup = 'Running';
|
||||
group.innerHTML = '<div class="admin-card" style="flex:1;display:flex;flex-direction:column;overflow:hidden;">' +
|
||||
// No `flex:1` on the card — with overflow:visible (forced via #cookbook-modal
|
||||
// .cookbook-group > .admin-card), flex:1 collapsed the card to body height
|
||||
// and the body's scrollHeight stopped tracking the overflowing children.
|
||||
// Sized-to-content means cookbook-body's overflow-y:auto kicks in naturally.
|
||||
group.innerHTML = '<div class="admin-card" style="display:flex;flex-direction:column;">' +
|
||||
'<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;">' +
|
||||
'<h2 style="margin:0;padding:0;line-height:1;">Running <span id="running-count" class="memory-count" style="font-size:0.6em;opacity:0.6;font-weight:normal">' + activeCount + '</span></h2>' +
|
||||
'<h2 style="margin:0;padding:0;line-height:1;">Active <span id="running-count" class="memory-count" style="font-size:0.6em;opacity:0.6;font-weight:normal">' + activeCount + '</span></h2>' +
|
||||
'</div>' +
|
||||
'<p class="memory-desc doclib-desc" style="margin-top:6px;">Active downloads and serving processes.</p>' +
|
||||
'</div>';
|
||||
@@ -1751,7 +1797,7 @@ export function _renderRunningTab() {
|
||||
// green when reachable, red if any serve task on it is crashed/unreachable.
|
||||
const _secDot = (key && allTasks.some(_serveTaskFailed)) ? 'fail' : 'ok';
|
||||
const _dotTitle = key ? (_secDot === 'fail' ? 'Server not responding' : 'Reachable') : 'Local (this machine)';
|
||||
sec.insertAdjacentHTML('afterbegin', `<div class="cookbook-section-header" data-collapse="${bodyId}"><svg class="cookbook-section-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><polyline points="6 9 12 15 18 9"/></svg><span class="cookbook-srv-status ${_secDot}" title="${_dotTitle}" style="flex-shrink:0;position:relative;top:0px;"></span><span class="cookbook-section-title" style="margin:0;">${esc(sg.name)}</span><button class="cookbook-btn cookbook-stop-all-btn" data-stop-server="${esc(key)}">Stop all</button><button class="cookbook-btn cookbook-clear-btn" data-clear-server="${esc(key)}">Clear finished</button></div><div id="${bodyId}" class="cookbook-section-body"></div>`);
|
||||
sec.insertAdjacentHTML('afterbegin', `<div class="cookbook-section-header" data-collapse="${bodyId}"><svg class="cookbook-section-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><polyline points="6 9 12 15 18 9"/></svg><span class="cookbook-srv-status ${_secDot}" title="${_dotTitle}" style="flex-shrink:0;position:relative;top:0px;"></span><span class="cookbook-section-title" style="margin:0;">${esc(sg.name)}</span><button class="cookbook-btn cookbook-stop-all-btn" data-stop-server="${esc(key)}" title="Stop all running servers"><svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" stroke="none" aria-hidden="true" style="vertical-align:-1px;margin-right:4px;"><rect x="5" y="5" width="14" height="14" rx="1.5"/></svg>Stop all</button><button class="cookbook-btn cookbook-clear-btn" data-clear-server="${esc(key)}" title="Clear finished tasks"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="vertical-align:-1px;margin-right:4px;"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>Clear finished</button></div><div id="${bodyId}" class="cookbook-section-body"></div>`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1762,9 +1808,21 @@ export function _renderRunningTab() {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation(); // don't toggle the section collapse (was an inline onclick, blocked by CSP)
|
||||
const host = btn.dataset.clearServer;
|
||||
if (!await window.styledConfirm(`Clear finished tasks on ${_serverName(host)}?`, { confirmText: 'Clear' })) return;
|
||||
const allTasks = _loadTasks();
|
||||
const toRemove = allTasks.filter(t => (t.remoteHost || '') === host && _canClearTask(t));
|
||||
// Bail with a clear message instead of silently doing nothing when
|
||||
// every task on this server is still running (nothing finished to
|
||||
// clear yet) — the previous behavior looked like the button was dead.
|
||||
if (!toRemove.length) {
|
||||
const stillRunning = allTasks.filter(t => (t.remoteHost || '') === host && t.status === 'running').length;
|
||||
const _msg = stillRunning
|
||||
? `No finished tasks on ${_serverName(host)} — ${stillRunning} still running. Stop them first to clear.`
|
||||
: `No finished tasks on ${_serverName(host)}.`;
|
||||
if (window.uiModule?.showToast) window.uiModule.showToast(_msg);
|
||||
else alert(_msg);
|
||||
return;
|
||||
}
|
||||
if (!await window.styledConfirm(`Clear ${toRemove.length} finished task${toRemove.length === 1 ? '' : 's'} on ${_serverName(host)}?`, { confirmText: 'Clear' })) return;
|
||||
const remaining = allTasks.filter(t => (t.remoteHost || '') !== host || !_canClearTask(t));
|
||||
_saveTasks(remaining);
|
||||
// Fade/slide each finished card out (same exit as the per-card clear)
|
||||
@@ -2100,57 +2158,43 @@ export function _renderRunningTab() {
|
||||
dropdown.className = 'cookbook-task-dropdown';
|
||||
|
||||
const items = [];
|
||||
// ── Run section ─────────────────────────────────────────────
|
||||
// Queued download: let the user jump the queue and start it immediately
|
||||
// (downloads otherwise run one-at-a-time per server).
|
||||
if (task.type === 'download' && task.status === 'queued') {
|
||||
items.push({ label: 'Start now', action: 'start-now', custom: () => {
|
||||
items.push({ group: 'run', label: 'Start now', action: 'start-now', custom: () => {
|
||||
_startQueuedDownload(task);
|
||||
_renderRunningTab();
|
||||
}});
|
||||
}
|
||||
if (task.status !== 'running' && task.status !== 'queued') {
|
||||
items.push({ label: 'Reconnect', action: 'reconnect' });
|
||||
items.push({ group: 'run', label: 'Reconnect tmux', action: 'reconnect' });
|
||||
}
|
||||
if (task.status === 'running') {
|
||||
items.push({ label: 'Stop', action: 'stop', danger: true });
|
||||
items.push({ group: 'run', label: 'Stop', action: 'stop', danger: true });
|
||||
}
|
||||
items.push({ label: 'Restart', action: 'retry' });
|
||||
// Edit serve — open the full serve panel (same as the edit icon),
|
||||
// switching to this task's server first so the model is found.
|
||||
items.push({ group: 'run', label: 'Restart', action: 'retry' });
|
||||
// ── Edit section ────────────────────────────────────────────
|
||||
// Merged "Edit & relaunch" — opens the structured serve panel
|
||||
// pre-filled with this task's config. The old standalone "Edit
|
||||
// cmd & relaunch" raw-text dialog is now reachable from inside
|
||||
// that panel (Show command). Single entry-point per task.
|
||||
if (task.type === 'serve' && task.payload?.repo_id) {
|
||||
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() });
|
||||
items.push({ group: 'edit', label: 'Edit & relaunch', action: 'edit-panel', tooltip: 'Open the Serve config panel pre-filled with this task — pick a different backend, change GPUs, edit env vars or the raw cmd, then Launch.', custom: () => _openEdit() });
|
||||
}
|
||||
// Save serve — save current launch config as a preset.
|
||||
if (task.type === 'serve' && task.payload?._cmd) {
|
||||
items.push({ label: 'Save serve', action: 'save', custom: () => {
|
||||
items.push({ group: 'edit', label: 'Save serve', action: 'save', custom: () => {
|
||||
if (!_saveTaskAsPreset(task)) { uiModule.showToast('Already saved'); return; }
|
||||
uiModule.showToast('Saved to presets');
|
||||
_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 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 {
|
||||
await fetch('/api/shell/exec', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: _tmuxGracefulKill(task) }),
|
||||
});
|
||||
} catch {}
|
||||
_removeTask(task.sessionId);
|
||||
// Relaunch on the task's OWN host, not the current global selection.
|
||||
_launchServeTask(task.name, task.payload.repo_id, newCmd, task.payload._fields, task.remoteHost || '');
|
||||
}});
|
||||
}
|
||||
// ── Endpoint section ────────────────────────────────────────
|
||||
// Manual endpoint registration — fallback for when auto-add fails
|
||||
// (e.g. probe timeout on a remote that's slow). Forces adding this
|
||||
// serve to the model-endpoints list regardless of prior flag state.
|
||||
if (task.type === 'serve' && task.payload?._cmd) {
|
||||
items.push({ label: 'Register endpoint', action: 'register-endpoint', custom: async () => {
|
||||
items.push({ group: 'endpoint', label: 'Register endpoint', action: 'register-endpoint', custom: async () => {
|
||||
const host = _connectHostFromRemote(task.remoteHost);
|
||||
const portMatch = task.payload?._cmd?.match(/--port\s+(\d+)/);
|
||||
const port = portMatch ? portMatch[1] : '8000';
|
||||
@@ -2195,31 +2239,32 @@ export function _renderRunningTab() {
|
||||
}
|
||||
}});
|
||||
}
|
||||
// ── Copy section ────────────────────────────────────────────
|
||||
if (_isWindows(task)) {
|
||||
const host = task.remoteHost;
|
||||
const sd = host ? '$env:TEMP\\odysseus-sessions' : '$env:TEMP\\odysseus-tmux';
|
||||
const logCmd = host
|
||||
? `ssh ${_sshPrefix(_getPort(task))}${host} "powershell -Command \\"Get-Content '${sd}\\${task.sessionId}.log' -Wait\\""`
|
||||
: `powershell -Command "Get-Content (Join-Path $env:TEMP 'odysseus-tmux\\${task.sessionId}.log') -Wait"`;
|
||||
items.push({ label: 'Copy log cmd', action: 'copy-tmux', custom: () => {
|
||||
items.push({ group: 'copy', label: 'Copy log cmd', action: 'copy-tmux', custom: () => {
|
||||
_copyText(logCmd);
|
||||
}});
|
||||
} else {
|
||||
// Just the tmux command itself — no ssh wrapper.
|
||||
const tmuxAttach = `tmux attach -t ${task.sessionId}`;
|
||||
items.push({ label: 'Copy tmux', action: 'copy-tmux', custom: () => {
|
||||
items.push({ group: 'copy', label: 'Copy tmux', action: 'copy-tmux', custom: () => {
|
||||
_copyText(tmuxAttach);
|
||||
}});
|
||||
}
|
||||
if (_shouldOfferCrashReport(task)) {
|
||||
items.push({ label: 'Copy crash report', action: 'copy-crash-report', custom: () => {
|
||||
items.push({ group: 'copy', label: 'Copy crash report', action: 'copy-crash-report', custom: () => {
|
||||
const out = (el.querySelector('.cookbook-output-pre')?.textContent || task.output || '');
|
||||
_copyText(_buildCrashReport(task, out));
|
||||
uiModule.showToast('Copied crash report');
|
||||
}});
|
||||
}
|
||||
// Copy the last 50 lines of the task's output/log.
|
||||
items.push({ label: 'Copy last 50 lines', action: 'copy-log', custom: () => {
|
||||
items.push({ group: 'copy', label: 'Copy last 50 lines', action: 'copy-log', custom: () => {
|
||||
const out = (el.querySelector('.cookbook-output-pre')?.textContent || task.output || '');
|
||||
const last = out.split('\n').slice(-50).join('\n');
|
||||
if (!last.trim()) {
|
||||
@@ -2233,8 +2278,10 @@ export function _renderRunningTab() {
|
||||
// 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.
|
||||
// ── Danger section ──────────────────────────────────────────
|
||||
const _isLive = task.type === 'serve' && ['running', 'ready', 'loading', 'warming', 'starting'].includes(task.status || '');
|
||||
items.push({
|
||||
group: 'danger',
|
||||
label: _isLive ? 'Stop and remove' : 'Remove',
|
||||
action: 'kill',
|
||||
tooltip: _isLive
|
||||
@@ -2242,10 +2289,8 @@ export function _renderRunningTab() {
|
||||
: '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).
|
||||
items.push({ label: 'Cancel', action: 'cancel', mobileOnly: true, custom: () => {} });
|
||||
// Cancel = mobile-only dismiss item. Same pattern as the email kebab.
|
||||
items.push({ group: 'danger', label: 'Cancel', action: 'cancel', mobileOnly: true, custom: () => {} });
|
||||
|
||||
const _MENU_ICONS = {
|
||||
'start-now': '<polygon points="6 4 20 12 6 20 6 4"/>',
|
||||
@@ -2262,7 +2307,18 @@ export function _renderRunningTab() {
|
||||
kill: '<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>',
|
||||
cancel: '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>',
|
||||
};
|
||||
let _lastGroup = null;
|
||||
for (const item of items) {
|
||||
// Insert a thin divider whenever the group changes, so the
|
||||
// user can visually scan Run / Edit / Endpoint / Copy / Danger
|
||||
// blocks instead of one long undifferentiated list.
|
||||
if (item.group && _lastGroup && item.group !== _lastGroup) {
|
||||
const sep = document.createElement('div');
|
||||
sep.className = 'cookbook-dropdown-divider';
|
||||
sep.style.cssText = 'height:1px;margin:4px 6px;background:color-mix(in srgb, var(--fg) 12%, transparent);pointer-events:none;';
|
||||
dropdown.appendChild(sep);
|
||||
}
|
||||
_lastGroup = item.group || _lastGroup;
|
||||
const div = document.createElement('div');
|
||||
div.className = 'dropdown-item-compact'
|
||||
+ (item.danger ? ' cookbook-dropdown-danger' : '')
|
||||
@@ -2652,7 +2708,7 @@ async function _reconnectTask(el, task) {
|
||||
// capture-pane lets the existing _reconnectTask flow pick up
|
||||
// the real state (running, finished, or truly dead).
|
||||
const _reconnectFix = {
|
||||
label: 'Reconnect',
|
||||
label: 'Reconnect tmux',
|
||||
action: () => {
|
||||
_updateTask(task.sessionId, { status: 'running' });
|
||||
el.dataset.status = 'running';
|
||||
|
||||
+310
-46
@@ -9,6 +9,7 @@ import spinnerModule from './spinner.js';
|
||||
import { providerLogo } from './providers.js';
|
||||
import { modelColor } from './chatRenderer.js';
|
||||
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
import { openCookbookDependencies } from './cookbook-diagnosis.js';
|
||||
|
||||
// Shared state/functions injected by init()
|
||||
let _envState;
|
||||
@@ -546,7 +547,14 @@ function _rerenderCachedModels() {
|
||||
: (_es.gpus || detectedGpuIds));
|
||||
const tpOpts = [1,2,4,8].map(n => `<option${defaultTp==String(n)?' selected':''}>${n}</option>`).join('');
|
||||
const dtypeOpts = ['auto','float16','bfloat16'].map(d => `<option value="${d}"${sv('dtype','auto')===d?' selected':''}>${d}</option>`).join('');
|
||||
const vllmKvCacheOpts = ['auto','fp8'].map(d => `<option value="${d}"${sv('vllm_kv_cache_dtype','auto')===d?' selected':''}>${d}</option>`).join('');
|
||||
// KV cache default — most models are fine on auto, but a few
|
||||
// (e.g. DeepSeek-V3/V4/R1 MoE) need fp8 explicitly or the launch
|
||||
// OOMs. _detectModelOptimizations seeds opts.kvCacheDtype for
|
||||
// those families; honour it unless the user has a saved override.
|
||||
const _kvOptsCheck = _detectModelOptimizations(repo);
|
||||
const _kvAutoDefault = (_kvOptsCheck && _kvOptsCheck.kvCacheDtype) || 'auto';
|
||||
const _kvSelected = sv('vllm_kv_cache_dtype', _kvAutoDefault);
|
||||
const vllmKvCacheOpts = ['auto','fp8'].map(d => `<option value="${d}"${_kvSelected===d?' selected':''}>${d}</option>`).join('');
|
||||
const _l = (name, tip) => `<span>${name}<span class="hwfit-hint" title="${tip}">?</span></span>`;
|
||||
const _ggufChoices = _runnableGgufFiles(m);
|
||||
const _savedGguf = String(sv('gguf_file', '') || '');
|
||||
@@ -572,12 +580,22 @@ function _rerenderCachedModels() {
|
||||
const _arrowTitle = _modelPresets.length > 0
|
||||
? `${_modelPresets.length} saved launch config${_modelPresets.length === 1 ? '' : 's'} for ${_repoShort} — click ▾ to load or delete`
|
||||
: `No saved launch configs for ${_repoShort} yet — click Save to add one`;
|
||||
let _slotsHtml = `<div class="cookbook-serve-slots cookbook-saved-split">`
|
||||
// Wrap the Save split in a <label> so it picks up the same "field
|
||||
// title + ?-help" treatment as Backend / venv / Port / GPUs sitting
|
||||
// beside it in Row 1. Button text is "Save" (the action), label is
|
||||
// "Settings" (what the saved blob represents).
|
||||
let _slotsHtml = `<label>${_l('Settings','Saved launch configurations for this model — click ▾ to load or delete')}`
|
||||
+ `<div class="cookbook-serve-slots cookbook-saved-split">`
|
||||
+ `<button type="button" class="cookbook-slot-btn cookbook-saved-save" title="Save current config"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>Save</button>`
|
||||
+ `<button type="button" class="cookbook-slot-btn cookbook-saved-arrow" title="${esc(_arrowTitle)}">${_arrowLabel}</button>`
|
||||
+ `</div>`;
|
||||
+ `</div></label>`;
|
||||
|
||||
let panelHtml = `<div class="hwfit-serve-panel">`;
|
||||
// Runtime-readiness note pinned at the top of the serve area so the
|
||||
// user sees "vLLM ready on …" before scrolling into the configure
|
||||
// form. Hidden until the readiness probe returns. The × button
|
||||
// dismisses it for this panel only (re-shows on re-expand).
|
||||
panelHtml += `<div class="hwfit-serve-runtime-note" style="display:none;font-size:11px;line-height:1.35;color:var(--fg-muted);margin:0 0 8px;padding:6px 28px 6px 10px;border-radius:5px;background:color-mix(in srgb, var(--fg) 4%, transparent);border:1px solid color-mix(in srgb, var(--border) 60%, transparent);position:relative;"><span class="hwfit-serve-runtime-text"></span><button type="button" class="hwfit-serve-runtime-close" title="Dismiss" aria-label="Dismiss" style="position:absolute;top:-8px;right:5px;background:none;border:0;color:inherit;cursor:pointer;padding:2px 4px;line-height:1;font-size:13px;opacity:0.6;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div>`;
|
||||
// Warn when serving a model whose download hasn't fully completed —
|
||||
// the user CAN still hit Launch (vLLM/llama-server will start, then
|
||||
// crash trying to read missing shards), but they should know.
|
||||
@@ -596,9 +614,19 @@ function _rerenderCachedModels() {
|
||||
? [['llamacpp','llama.cpp'],['ollama','Ollama']]
|
||||
: [['vllm','vLLM'],['sglang','SGLang'],['llamacpp','llama.cpp'],['ollama','Ollama'],['diffusers','Diffusers']];
|
||||
const backendOpts = _backendChoices.map(([v,l]) => `<option value="${v}"${defaultBackend===v?' selected':''}>${l}</option>`).join('');
|
||||
panelHtml += `<label>${_l('Backend','Inference engine: vLLM, SGLang, llama.cpp, Ollama, or Diffusers')}<select class="hwfit-sf" data-field="backend">${backendOpts}</select></label>`;
|
||||
// Custom Backend picker — native <select> can't host SVG inside
|
||||
// options, so we render a button + menu that show the backend logo
|
||||
// beside its name. The hidden <select.hwfit-sf data-field="backend">
|
||||
// stays as the source-of-truth so every existing change handler
|
||||
// (updateBackendVisibility, runtime readiness, command builder)
|
||||
// still fires via dispatchEvent('change') on selection.
|
||||
panelHtml += `<label>${_l('Backend','Inference engine: vLLM, SGLang, llama.cpp, Ollama, or Diffusers')}<div class="hwfit-backend-picker" data-backend-picker style="position:relative;width:100%;"><select class="hwfit-sf hwfit-backend-source" data-field="backend" style="display:none;">${backendOpts}</select><button type="button" class="hwfit-backend-btn" data-backend-btn aria-haspopup="listbox" aria-expanded="false" style="display:flex;align-items:center;gap:6px;width:100%;height:28px;padding:0 8px;background:var(--bg);color:var(--fg);border:1px solid var(--border);border-radius:4px;font:inherit;font-size:11px;cursor:pointer;text-align:left;"><span class="hwfit-backend-btn-icon" data-backend-icon-slot aria-hidden="true" style="display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;color:var(--accent, var(--red));flex-shrink:0;"></span><span class="hwfit-backend-btn-label" data-backend-label style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"></span><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="opacity:0.6;flex-shrink:0;"><polyline points="6 9 12 15 18 9"/></svg></button><div class="hwfit-backend-menu" data-backend-menu role="listbox" hidden style="position:absolute;top:calc(100% + 4px);left:0;right:0;z-index:100;background:var(--panel, var(--bg));border:1px solid var(--border);border-radius:6px;box-shadow:0 6px 20px rgba(0,0,0,0.22);padding:4px;"></div></div></label>`;
|
||||
panelHtml += `<input type="hidden" class="hwfit-sf" data-field="host" value="${esc(_es.remoteHost || '')}" />`;
|
||||
panelHtml += `<label>${_l('venv','Path to Python venv or conda env activate script')}<input type="text" class="hwfit-sf hwfit-sf-wide" data-field="venv" value="${esc(sv('venv', _es.envPath || _srvVenv || ''))}" placeholder="~/venv" /></label>`;
|
||||
// Dtype lives in Row 1 (next to venv) — it's the first knob people
|
||||
// change when matching the model to the box, so it earns top-row
|
||||
// real estate over Row 2's launch-tuning controls.
|
||||
panelHtml += `<label>${_l('Dtype','Data type for weights. auto picks best for GPU')}<select class="hwfit-sf" data-field="dtype">${dtypeOpts}</select></label>`;
|
||||
const defaultPort = defaultBackend === 'ollama' ? '11434' : _nextAvailablePort();
|
||||
panelHtml += `<label>${_l('Port','HTTP port for the API server')}<input type="text" class="hwfit-sf" data-field="port" value="${esc(sv('port', defaultPort))}" /></label>`;
|
||||
const _activeGpus = (defaultGpus || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||
@@ -609,12 +637,16 @@ function _rerenderCachedModels() {
|
||||
const on = _activeGpus.includes(String(i));
|
||||
_gpuBtnsHtml += `<button type="button" class="cookbook-gpu-btn${on ? ' active' : ''}" data-gpu="${i}">${i}</button>`;
|
||||
}
|
||||
panelHtml += `<label>${_l('GPUs','Toggle which GPUs to use')}<div class="cookbook-gpu-group">${_gpuBtnsHtml}</div><input type="hidden" class="hwfit-sf" data-field="gpus" value="${esc(defaultGpus)}" /></label>`;
|
||||
// Save / saved-configs split button — moved into Row 1 (next to GPUs)
|
||||
// so it shares the same baseline as the rest of the top controls.
|
||||
// GPUs button strip moved to Row 2 (next to GPU Mem) below. 4px
|
||||
// margin on the left, 8px on the right — extra 4px right-side gap
|
||||
// separates the GPU chiclets from the GPU Mem field that follows
|
||||
// (asked-for breathing room; 4px on either side felt cramped on
|
||||
// the GPU-Mem boundary).
|
||||
const _gpusLabelHtml = `<label class="hwfit-gpus-label" style="margin:0 8px 0 4px;">${_l('GPUs','Toggle which GPUs to use')}<div class="cookbook-gpu-group">${_gpuBtnsHtml}</div><input type="hidden" class="hwfit-sf" data-field="gpus" value="${esc(defaultGpus)}" /></label>`;
|
||||
// Save / saved-configs split button — sits at the right end of Row 1.
|
||||
panelHtml += _slotsHtml;
|
||||
panelHtml += `</div>`;
|
||||
panelHtml += `<div class="hwfit-serve-runtime-note" style="display:none;font-size:11px;line-height:1.35;color:var(--fg-muted);margin-top:-4px;"></div>`;
|
||||
// (hwfit-serve-runtime-note moved to the top of the panel — see above.)
|
||||
if (_ggufChoices.length > 1) {
|
||||
// Show the GGUF File dropdown for BOTH llama.cpp and Ollama — Ollama
|
||||
// also needs to know which exact .gguf to import via the new
|
||||
@@ -631,15 +663,22 @@ function _rerenderCachedModels() {
|
||||
// TP / Context / GPU / GPU Mem / Max Seqs / Dtype. Everything else
|
||||
// (Swap, KV Cache, Attention backend, Env vars, llama.cpp batch/ubatch)
|
||||
// moved to the Advanced fold below to keep this row scannable.
|
||||
panelHtml += `<div class="hwfit-serve-row hwfit-backend-vllm hwfit-backend-sglang hwfit-backend-llamacpp hwfit-backend-ollama">`;
|
||||
panelHtml += `<div class="hwfit-serve-row hwfit-serve-row-core hwfit-backend-vllm hwfit-backend-sglang hwfit-backend-llamacpp hwfit-backend-ollama">`;
|
||||
// Order: TP → Context → Max Seqs → GPUs → GPU Mem.
|
||||
// Dtype moved up to Row 1. GPUs moved here next to GPU Mem so the
|
||||
// "which devices + how much of them" decisions sit adjacent. Max
|
||||
// Seqs follows Context per the "request-shape" cluster.
|
||||
panelHtml += `<label class="hwfit-backend-vllm hwfit-backend-sglang">${_l('TP','Tensor Parallelism — split model across N GPUs')}<select class="hwfit-sf" data-field="tp">${tpOpts}</select></label>`;
|
||||
// ctx resets to the model's max on every panel open (the real ctx slider
|
||||
// lives in the Scan/Download toolbar — see cookbook.js .hwfit-ctx-control).
|
||||
panelHtml += `<label>${_l('Context','Max tokens per request — resets to the model max on every open. Lower = less VRAM')}<input type="text" class="hwfit-sf" data-field="ctx" value="${esc(m.context_length || m.context || '20000')}" /></label>`;
|
||||
panelHtml += `<label>${_l('GPU','Which GPU to use. Leave empty for default')}<input type="text" class="hwfit-sf" data-field="gpu_id" value="${esc(sv('gpu_id', ''))}" placeholder="auto" style="width:50px;" /></label>`;
|
||||
panelHtml += `<label class="hwfit-backend-vllm hwfit-backend-sglang">${_l('GPU Mem','Fraction of GPU memory (0.0–1.0). Lower if OOM')}<input type="text" class="hwfit-sf" data-field="gpu_mem" value="${esc(sv('gpu_mem', '0.90'))}" /></label>`;
|
||||
panelHtml += `<label class="hwfit-backend-vllm 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>`;
|
||||
// GPU "auto" field removed — the GPU button strip below already
|
||||
// writes data-field="gpus" (the canonical comma-separated device
|
||||
// list) and the command builders now read from that single source.
|
||||
panelHtml += `<label class="hwfit-backend-vllm hwfit-backend-sglang">${_l('GPU Mem','Fraction of GPU memory (0.0–1.0). Lower if OOM')}<input type="text" class="hwfit-sf" data-field="gpu_mem" value="${esc(sv('gpu_mem', '0.90'))}" /></label>`;
|
||||
// GPUs button strip at the far right of Row 2.
|
||||
panelHtml += _gpusLabelHtml;
|
||||
panelHtml += `</div>`;
|
||||
// ── Advanced (collapsed by default) ──
|
||||
// Everything below the fold is tuning users only touch occasionally:
|
||||
@@ -667,7 +706,10 @@ function _rerenderCachedModels() {
|
||||
// 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>`;
|
||||
// grid-column: 1 / -1 makes Env span every column of the Advanced
|
||||
// row's CSS grid (the old flex:1 1 100% did nothing in a grid
|
||||
// container — left an empty trailing column gap on wide modals).
|
||||
panelHtml += `<label class="hwfit-backend-vllm hwfit-backend-sglang" style="grid-column:1 / -1;">${_l('Env','Extra KEY=VALUE env-var pairs prepended to the launch (space-separated). Example: CUDACXX=$VIRTUAL_ENV/lib/python3.10/site-packages/nvidia/cuda_nvcc/bin/nvcc — points flashinfer at the venv-bundled nvcc when the system one is too old for your GPU.')}<input type="text" class="hwfit-sf" data-field="extra_env" value="${esc(sv('extra_env',''))}" placeholder="CUDACXX=/path/to/nvcc NCCL_P2P_DISABLE=1" style="width:100%;" /></label>`;
|
||||
panelHtml += `</div>`;
|
||||
// Advanced llama.cpp row (Batch / UBatch — moved out of Core for the
|
||||
// same "rarely touched" reason as the vLLM extras above).
|
||||
@@ -686,11 +728,36 @@ function _rerenderCachedModels() {
|
||||
panelHtml += `<label>Height${_h('Default output height')} <input type="text" class="hwfit-sf" data-field="diff_height" value="${esc(sv('diff_height', ''))}" placeholder="1024" /></label>`;
|
||||
panelHtml += `</div>`;
|
||||
// Row 3: Checkboxes (vLLM)
|
||||
// Order: Trust Remote → Auto Tool → Reasoning Parser (when the
|
||||
// model has one) → Enforce Eager → Prefix Caching. Reasoning
|
||||
// Parser was previously in a separate row below; the user wanted
|
||||
// it inline with the other vLLM toggles between Auto Tool and
|
||||
// Enforce Eager so the "what the model needs" decisions sit
|
||||
// together at the top.
|
||||
const _opts2_row3 = _detectModelOptimizations(repo);
|
||||
const _rp_flag = _opts2_row3.flags.find(f => f.includes('--reasoning-parser'));
|
||||
const _rp_name = _rp_flag ? _rp_flag.split(' ')[1] : '';
|
||||
panelHtml += `<div class="hwfit-serve-checks hwfit-backend-vllm hwfit-backend-sglang">`;
|
||||
panelHtml += `<label class="hwfit-sf-cb"><input type="checkbox" class="hwfit-sf" data-field="enforce_eager"${sv('enforce_eager',false)?' checked':''} /> Enforce Eager${_h('Disable CUDA graphs. Slower but uses less memory')}</label>`;
|
||||
panelHtml += `<label class="hwfit-sf-cb"><input type="checkbox" class="hwfit-sf" data-field="trust_remote"${sv('trust_remote',false)?' checked':''} /> Trust Remote Code${_h('Allow model to run custom code from HuggingFace')}</label>`;
|
||||
panelHtml += `<label class="hwfit-sf-cb"><input type="checkbox" class="hwfit-sf" data-field="prefix_cache"${sv('prefix_cache',false)?' checked':''} /> Prefix Caching${_h('Cache shared prompt prefixes across requests')}</label>`;
|
||||
panelHtml += `<label class="hwfit-sf-cb hwfit-backend-vllm"><input type="checkbox" class="hwfit-sf" data-field="auto_tool"${sv('auto_tool',false)?' checked':''} /> Auto Tool Choice${_h('Enable function/tool calling for agent mode')}</label>`;
|
||||
if (_rp_name) panelHtml += `<label class="hwfit-sf-cb hwfit-backend-vllm"><input type="checkbox" class="hwfit-sf" data-field="reasoning_parser" data-parser="${_rp_name}" /> Reasoning Parser <span class="hwfit-parser-tag">${_rp_name}</span></label>`;
|
||||
panelHtml += `<label class="hwfit-sf-cb"><input type="checkbox" class="hwfit-sf" data-field="enforce_eager"${sv('enforce_eager',false)?' checked':''} /> Enforce Eager${_h('Disable CUDA graphs. Slower but uses less memory')}</label>`;
|
||||
panelHtml += `<label class="hwfit-sf-cb"><input type="checkbox" class="hwfit-sf" data-field="prefix_cache"${sv('prefix_cache',false)?' checked':''} /> Prefix Caching${_h('Cache shared prompt prefixes across requests')}</label>`;
|
||||
// Inline the previously-second vLLM checks row so Expert Parallel /
|
||||
// Speculative / MoE Env sit next to Prefix Caching with no gap. All
|
||||
// three are vLLM-only — class-gated so they hide on SGLang.
|
||||
if (_opts2_row3.flags.includes('--enable-expert-parallel')) panelHtml += `<label class="hwfit-sf-cb hwfit-backend-vllm"><input type="checkbox" class="hwfit-sf" data-field="expert_parallel" /> Expert Parallel</label>`;
|
||||
{
|
||||
const _specDef = _opts2_row3.spec || { method: 'mtp', tokens: 3 };
|
||||
const _specMethod = sv('spec_method', _specDef.method);
|
||||
const _specTokens = sv('spec_tokens', String(_specDef.tokens));
|
||||
const _specMethods = ['mtp', 'qwen3_next_mtp', 'eagle', 'medusa', 'ngram'];
|
||||
if (!_specMethods.includes(_specMethod)) _specMethods.unshift(_specMethod);
|
||||
const _specOpts = _specMethods.map(m =>
|
||||
`<option value="${m}"${m === _specMethod ? ' selected' : ''}>${m}</option>`).join('');
|
||||
panelHtml += `<label class="hwfit-sf-cb hwfit-backend-vllm hwfit-spec-group"><input type="checkbox" class="hwfit-sf" data-field="speculative" /> Speculative <select class="hwfit-sf hwfit-spec-method" data-field="spec_method" title="vLLM --speculative-config method">${_specOpts}</select><input type="number" class="hwfit-sf hwfit-spec-tokens hwfit-spec-tokens-bare" data-field="spec_tokens" value="${esc(_specTokens)}" min="1" max="10" title="num_speculative_tokens" style="width:44px;" /><span class="hwfit-help-chip hwfit-help-chip-inline" title="MTP / speculative decoding is supported on a few model families only — turn it on when the model card explicitly recommends it. On supported models it can boost inference throughput up to ~3×; on unsupported models it will either be ignored or fail to launch." style="margin-left:6px;">?</span></label>`;
|
||||
}
|
||||
if (_opts2_row3.envVars.length) panelHtml += `<label class="hwfit-sf-cb hwfit-backend-vllm"><input type="checkbox" class="hwfit-sf" data-field="moe_env" /> MoE Env Vars</label>`;
|
||||
panelHtml += `</div>`;
|
||||
// Row 2c: llama.cpp fit/perf flags (set by Auto profiles, editable by hand)
|
||||
const _kvOpts = ['', 'q4_0', 'q8_0', 'f16'].map(k => `<option value="${k}"${sv('cache_type','')===k?' selected':''}>${k||'default'}</option>`).join('');
|
||||
@@ -739,33 +806,16 @@ function _rerenderCachedModels() {
|
||||
panelHtml += `</div><div class="hwfit-serve-row hwfit-backend-diffusers">`;
|
||||
panelHtml += `<label>Harmonize GPU${_h('Separate GPU for img2img/harmonize. Leave empty to use same GPU')}<input type="text" class="hwfit-sf" data-field="diff_harmonize_gpu" value="${esc(sv('diff_harmonize_gpu', ''))}" placeholder="auto" style="width:50px;" /></label>`;
|
||||
panelHtml += `</div>`;
|
||||
// Row 4: Extra args
|
||||
panelHtml += `<div class="hwfit-serve-extra">`;
|
||||
panelHtml += `<label>Extra args<input type="text" class="hwfit-sf" data-field="extra" value="${esc(sv('extra', ''))}" placeholder="--flag value" /></label>`;
|
||||
panelHtml += `</div>`;
|
||||
// Model-specific optimizations. The checks row always renders for the
|
||||
// vLLM backend so the Speculative (MTP) control is ALWAYS reachable —
|
||||
// even for models the auto-detector doesn't recognize. Expert-parallel,
|
||||
// reasoning-parser and MoE-env still only appear when auto-detected.
|
||||
const _opts2 = _detectModelOptimizations(repo);
|
||||
panelHtml += `<div class="hwfit-serve-checks hwfit-backend-vllm">`;
|
||||
if (_opts2.flags.includes('--enable-expert-parallel')) panelHtml += `<label class="hwfit-sf-cb"><input type="checkbox" class="hwfit-sf" data-field="expert_parallel" /> Expert Parallel</label>`;
|
||||
if (_opts2.flags.some(f => f.includes('--reasoning-parser'))) { const rp = _opts2.flags.find(f => f.includes('--reasoning-parser')).split(' ')[1]; panelHtml += `<label class="hwfit-sf-cb"><input type="checkbox" class="hwfit-sf" data-field="reasoning_parser" data-parser="${rp}" /> Reasoning Parser <span class="hwfit-parser-tag">${rp}</span></label>`; }
|
||||
{
|
||||
// Speculative decoding (vLLM --speculative-config). Default OFF; the
|
||||
// method/token defaults come from auto-detection when available,
|
||||
// else fall back to MTP/3. Toggling the checkbox is what actually
|
||||
// adds the flag at launch (see cookbook.js command builder).
|
||||
const _specDef = _opts2.spec || { method: 'mtp', tokens: 3 };
|
||||
const _specMethod = sv('spec_method', _specDef.method);
|
||||
const _specTokens = sv('spec_tokens', String(_specDef.tokens));
|
||||
const _specMethods = ['mtp', 'qwen3_next_mtp', 'eagle', 'medusa', 'ngram'];
|
||||
if (!_specMethods.includes(_specMethod)) _specMethods.unshift(_specMethod);
|
||||
const _specOpts = _specMethods.map(m =>
|
||||
`<option value="${m}"${m === _specMethod ? ' selected' : ''}>${m}</option>`).join('');
|
||||
panelHtml += `<label class="hwfit-sf-cb hwfit-spec-group"><input type="checkbox" class="hwfit-sf" data-field="speculative" /> Speculative <select class="hwfit-sf hwfit-spec-method" data-field="spec_method" title="vLLM --speculative-config method">${_specOpts}</select><span class="hwfit-numstep"><button type="button" class="hwfit-numstep-btn" data-step="-1" tabindex="-1" aria-label="Decrease">‹</button><input type="number" class="hwfit-sf hwfit-spec-tokens" data-field="spec_tokens" value="${esc(_specTokens)}" min="1" max="10" title="num_speculative_tokens" /><button type="button" class="hwfit-numstep-btn" data-step="1" tabindex="-1" aria-label="Increase">›</button></span><span class="hwfit-help-chip hwfit-help-chip-inline" title="MTP / speculative decoding is supported on a few model families only — turn it on when the model card explicitly recommends it. On supported models it can boost inference throughput up to ~3×; on unsupported models it will either be ignored or fail to launch." style="margin-left:6px;">?</span></label>`;
|
||||
}
|
||||
if (_opts2.envVars.length) panelHtml += `<label class="hwfit-sf-cb"><input type="checkbox" class="hwfit-sf" data-field="moe_env" /> MoE Env Vars</label>`;
|
||||
// Expert Parallel / Speculative / MoE Env moved into Row 3 above so
|
||||
// the vLLM-only toggles sit next to Prefix Caching with no gap.
|
||||
// Extra args sits below the vLLM checks (Reasoning Parser + Spec)
|
||||
// so it reads as "after the advanced toggles, any other flags".
|
||||
panelHtml += `<div class="hwfit-serve-extra">`;
|
||||
panelHtml += `<label>Extra args<input type="text" class="hwfit-sf" data-field="extra" value="${esc(sv('extra', ''))}" placeholder="--flag value" /></label>`;
|
||||
panelHtml += `</div>`;
|
||||
// ── End Advanced fold ──
|
||||
panelHtml += `</details>`;
|
||||
@@ -958,37 +1008,183 @@ function _rerenderCachedModels() {
|
||||
if (ok === false) clearInterval(_vramTimer);
|
||||
}, 4000);
|
||||
|
||||
// Show/hide backend-specific sections
|
||||
// Backend icons — accent color, rendered via currentColor. vLLM gets
|
||||
// a stylized double-V mark, the others fall back to a recognizable
|
||||
// glyph for the engine family. Shown beside each option in the
|
||||
// custom picker so the dropdown lists "[V] vLLM", "[⚡] SGLang", etc.
|
||||
const _BACKEND_GLYPHS = {
|
||||
vllm: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 4l7 16 7-16"/><path d="M14 4l4 9 3-9"/></svg>',
|
||||
sglang: '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none" aria-hidden="true"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>',
|
||||
llamacpp: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M8 12h8M12 8v8"/></svg>',
|
||||
ollama: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 10a6 6 0 0 1 12 0v4a4 4 0 0 1-8 0v-1"/><circle cx="10" cy="9" r="1"/><circle cx="14" cy="9" r="1"/></svg>',
|
||||
diffusers: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M5 19l2-2M17 7l2-2"/></svg>',
|
||||
};
|
||||
|
||||
// ── Custom Backend picker wiring ────────────────────────────────
|
||||
// Reads the option list from the hidden <select.hwfit-backend-source>
|
||||
// so the canonical (value, label) pairs come from one place.
|
||||
const _backendPicker = panel.querySelector('[data-backend-picker]');
|
||||
const _backendSource = panel.querySelector('.hwfit-backend-source');
|
||||
const _backendBtn = panel.querySelector('[data-backend-btn]');
|
||||
const _backendMenu = panel.querySelector('[data-backend-menu]');
|
||||
const _backendBtnLabel = panel.querySelector('[data-backend-label]');
|
||||
const _backendBtnIconSlot = _backendBtn?.querySelector('[data-backend-icon-slot]');
|
||||
|
||||
function _setBackendBtnState(v) {
|
||||
if (!_backendBtn) return;
|
||||
const opt = _backendSource?.querySelector(`option[value="${CSS.escape(v)}"]`);
|
||||
const label = opt ? opt.textContent : v;
|
||||
if (_backendBtnLabel) _backendBtnLabel.textContent = label;
|
||||
if (_backendBtnIconSlot) _backendBtnIconSlot.innerHTML = _BACKEND_GLYPHS[v] || _BACKEND_GLYPHS.vllm;
|
||||
}
|
||||
|
||||
function _renderBackendMenu() {
|
||||
if (!_backendMenu || !_backendSource) return;
|
||||
const items = Array.from(_backendSource.options).map(o => ({ value: o.value, label: o.textContent }));
|
||||
_backendMenu.innerHTML = items.map(it => `
|
||||
<button type="button" role="option" class="hwfit-backend-item" data-value="${it.value}" style="all:unset;display:flex;align-items:center;gap:8px;width:100%;padding:6px 9px;border-radius:5px;font-size:12px;cursor:pointer;color:var(--fg);box-sizing:border-box;">
|
||||
<span class="hwfit-backend-item-icon" style="display:inline-flex;align-items:center;justify-content:center;width:14px;height:14px;color:var(--accent, var(--red));flex-shrink:0;">${_BACKEND_GLYPHS[it.value] || _BACKEND_GLYPHS.vllm}</span>
|
||||
<span class="hwfit-backend-item-label" style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${it.label}</span>
|
||||
</button>
|
||||
`).join('');
|
||||
// Hover styling (no global CSS rule — keep it self-contained).
|
||||
_backendMenu.querySelectorAll('.hwfit-backend-item').forEach(btn => {
|
||||
btn.addEventListener('mouseenter', () => { btn.style.background = 'color-mix(in srgb, var(--fg) 8%, transparent)'; });
|
||||
btn.addEventListener('mouseleave', () => { btn.style.background = ''; });
|
||||
btn.addEventListener('click', (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const v = btn.dataset.value;
|
||||
if (_backendSource && _backendSource.value !== v) {
|
||||
_backendSource.value = v;
|
||||
_backendSource.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
_setBackendBtnState(v);
|
||||
_closeBackendMenu();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _openBackendMenu() {
|
||||
if (!_backendMenu || !_backendBtn) return;
|
||||
_backendMenu.hidden = false;
|
||||
_backendBtn.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
function _closeBackendMenu() {
|
||||
if (!_backendMenu || !_backendBtn) return;
|
||||
_backendMenu.hidden = true;
|
||||
_backendBtn.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
if (_backendBtn) {
|
||||
_backendBtn.addEventListener('click', (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
if (_backendMenu.hidden) _openBackendMenu();
|
||||
else _closeBackendMenu();
|
||||
});
|
||||
document.addEventListener('click', (ev) => {
|
||||
if (!_backendMenu.hidden && !_backendPicker?.contains(ev.target)) _closeBackendMenu();
|
||||
});
|
||||
document.addEventListener('keydown', (ev) => {
|
||||
if (ev.key === 'Escape' && !_backendMenu.hidden) {
|
||||
ev.stopPropagation();
|
||||
_closeBackendMenu();
|
||||
}
|
||||
}, { capture: true });
|
||||
}
|
||||
_renderBackendMenu();
|
||||
_setBackendBtnState(_backendSource?.value || defaultBackend);
|
||||
|
||||
function updateBackendVisibility() {
|
||||
const b = panel.querySelector('[data-field="backend"]')?.value || 'vllm';
|
||||
panel.querySelectorAll('[class*="hwfit-backend-"]').forEach(el => {
|
||||
// Skip the entire backend-picker subtree — the picker's own
|
||||
// classes (`hwfit-backend-picker`, `-btn`, `-menu`, `-item`,
|
||||
// `-btn-icon`, `-btn-label`, `-item-icon`, `-item-label`) all
|
||||
// match the wildcard and would get hidden as if they were
|
||||
// "backend-specific form sections", which left the dropdown
|
||||
// looking empty / collapsed.
|
||||
if (el.closest('.hwfit-backend-picker')) return;
|
||||
const show = el.classList.contains(`hwfit-backend-${b}`);
|
||||
el.style.display = show ? '' : 'none';
|
||||
});
|
||||
_setBackendBtnState(b);
|
||||
}
|
||||
updateBackendVisibility();
|
||||
|
||||
async function updateRuntimeReadinessNote() {
|
||||
const note = panel.querySelector('.hwfit-serve-runtime-note');
|
||||
if (!note) return;
|
||||
// Mirror the message into a small chip next to the model title at
|
||||
// the top of the card, so the readiness state is visible without
|
||||
// having to look down into the panel body.
|
||||
// Clean up any title chip from previous versions — the readiness
|
||||
// text now lives inside the panel at the top, not in the card title.
|
||||
const card = panel.closest('.doclib-card, .memory-item');
|
||||
const titleEl = card ? card.querySelector('.memory-item-title') : null;
|
||||
const titleChip = titleEl ? titleEl.querySelector('.hwfit-serve-runtime-chip') : null;
|
||||
if (titleChip) titleChip.remove();
|
||||
const backend = panel.querySelector('[data-field="backend"]')?.value || 'vllm';
|
||||
const noteText = note.querySelector('.hwfit-serve-runtime-text');
|
||||
const _writeNote = (s) => { if (noteText) noteText.textContent = s; else note.textContent = s; };
|
||||
if (!['vllm', 'sglang', 'llamacpp', 'diffusers'].includes(backend)) {
|
||||
note.style.display = 'none';
|
||||
note.textContent = '';
|
||||
_writeNote('');
|
||||
return;
|
||||
}
|
||||
// Wire dismiss once per note element.
|
||||
const _closeBtn = note.querySelector('.hwfit-serve-runtime-close');
|
||||
if (_closeBtn && !_closeBtn._wired) {
|
||||
_closeBtn._wired = true;
|
||||
_closeBtn.addEventListener('click', (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
note.style.display = 'none';
|
||||
panel._runtimeNoteDismissed = true;
|
||||
});
|
||||
}
|
||||
// If the user dismissed it earlier on this panel, don't re-show.
|
||||
if (panel._runtimeNoteDismissed) return;
|
||||
const seq = (panel._runtimeReadinessSeq || 0) + 1;
|
||||
panel._runtimeReadinessSeq = seq;
|
||||
note.style.display = '';
|
||||
note.textContent = 'Checking runtime on selected server...';
|
||||
_writeNote('Checking runtime on selected server…');
|
||||
note.style.borderColor = '';
|
||||
note.style.color = 'var(--fg-muted)';
|
||||
try {
|
||||
const { pkg, target } = await _fetchServeRuntimePackage(panel, backend);
|
||||
if (panel._runtimeReadinessSeq !== seq) return;
|
||||
note.textContent = _runtimeNoteText(backend, pkg, target);
|
||||
note.style.color = pkg?.installed ? 'var(--fg-muted)' : 'var(--red)';
|
||||
_writeNote(_runtimeNoteText(backend, pkg, target));
|
||||
if (!pkg?.installed) {
|
||||
note.style.color = 'var(--red)';
|
||||
note.style.borderColor = 'color-mix(in srgb, var(--red) 40%, transparent)';
|
||||
note.style.background = 'color-mix(in srgb, var(--red) 8%, transparent)';
|
||||
// Append an accent-color link straight to the Dependencies
|
||||
// recipe panel for this backend so the user has one click
|
||||
// to the fix instead of hunting for the right row.
|
||||
if (noteText) {
|
||||
const pkgName = pkg?.name || ({ vllm: 'vllm', sglang: 'sglang', llamacpp: 'llama_cpp', diffusers: 'diffusers' }[backend]);
|
||||
const repo = (panel.closest('.doclib-card, .memory-item')?.dataset?.repo) || '';
|
||||
const link = document.createElement('a');
|
||||
link.href = '#';
|
||||
link.textContent = ' Install in Dependencies →';
|
||||
link.style.cssText = 'color:var(--accent, var(--red));text-decoration:underline;font-weight:600;margin-left:4px;';
|
||||
link.addEventListener('click', (ev) => {
|
||||
ev.preventDefault();
|
||||
if (pkgName) openCookbookDependencies(pkgName, { expandRecipe: pkgName, model: repo });
|
||||
});
|
||||
noteText.appendChild(link);
|
||||
}
|
||||
} else {
|
||||
// Healthy / ready → green so the user reads "good to go" at a
|
||||
// glance instead of scanning fg-muted for a state.
|
||||
note.style.color = 'var(--green, #4caf50)';
|
||||
note.style.borderColor = 'color-mix(in srgb, var(--green, #4caf50) 40%, transparent)';
|
||||
note.style.background = 'color-mix(in srgb, var(--green, #4caf50) 8%, transparent)';
|
||||
}
|
||||
} catch (err) {
|
||||
if (panel._runtimeReadinessSeq !== seq) return;
|
||||
note.textContent = `Runtime readiness unavailable: ${err?.message || err}`;
|
||||
_writeNote(`Runtime readiness unavailable: ${err?.message || err}`);
|
||||
note.style.color = 'var(--fg-muted)';
|
||||
}
|
||||
}
|
||||
@@ -1688,15 +1884,39 @@ function _rerenderCachedModels() {
|
||||
// Cancel button — collapses the serve config panel (same effect as
|
||||
// tapping the row to toggle it shut). Mobile users wanted an explicit
|
||||
// "back out" affordance next to Launch.
|
||||
panel.querySelector('.hwfit-serve-cancel')?.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
const _collapsePanel = () => {
|
||||
panel._cleanupRuntimeReadiness?.();
|
||||
panel.remove();
|
||||
item.classList.remove('doclib-card-expanded');
|
||||
item.style.flexDirection = '';
|
||||
item.style.alignItems = '';
|
||||
if (list) { list.style.minHeight = ''; list.style.maxHeight = ''; }
|
||||
};
|
||||
panel.querySelector('.hwfit-serve-cancel')?.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
_collapsePanel();
|
||||
});
|
||||
// Esc anywhere on the page closes the open serve panel. Skips when
|
||||
// the user is typing in a field — they want Esc to deselect / blur
|
||||
// those, not collapse the form they're configuring.
|
||||
const _onEscClose = (ev) => {
|
||||
if (ev.key !== 'Escape') return;
|
||||
if (!panel.isConnected) {
|
||||
document.removeEventListener('keydown', _onEscClose, true);
|
||||
return;
|
||||
}
|
||||
const t = ev.target;
|
||||
const inField = t && (
|
||||
t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable
|
||||
);
|
||||
if (inField) return;
|
||||
// Skip when one of the dropdown/menu popovers is open — the
|
||||
// popovers handle their own Esc and use stopPropagation, so any
|
||||
// Esc that bubbles here means nothing else claimed it.
|
||||
ev.stopPropagation();
|
||||
_collapsePanel();
|
||||
};
|
||||
document.addEventListener('keydown', _onEscClose, true);
|
||||
|
||||
// Launch button
|
||||
panel.querySelector('.hwfit-serve-launch').addEventListener('click', async (ev) => {
|
||||
@@ -1751,6 +1971,50 @@ function _rerenderCachedModels() {
|
||||
else serveState[el.dataset.field] = el.value;
|
||||
});
|
||||
serveState.backend = serveState.backend || (_detectBackend(m).backend) || 'vllm';
|
||||
|
||||
// Pre-launch: check our own task list for a serve already running
|
||||
// on this host. Offer to stop+launch as the default action — the
|
||||
// SSH-based port probe below is more thorough but it can miss
|
||||
// when SSH glitches or `ss` isn't installed. This catches the
|
||||
// common case instantly without waiting for a network round-trip.
|
||||
try {
|
||||
const _runningMod = await import('./cookbookRunning.js');
|
||||
const _hostStr = _envState.remoteHost || '';
|
||||
const _active = (_runningMod._loadTasks ? _runningMod._loadTasks() : []).filter(t =>
|
||||
t && t.type === 'serve'
|
||||
&& (t.remoteHost || '') === _hostStr
|
||||
&& (t.status === 'running' || t.status === 'ready' || t._serveReady)
|
||||
);
|
||||
if (_active.length) {
|
||||
const _names = _active.map(t => t.payload?.repo_id || t.repo || t.name || '?').filter(Boolean);
|
||||
const _ok = await window.styledConfirm(
|
||||
`${_active.length} model${_active.length === 1 ? '' : 's'} already serving on ${_hostStr || 'local'} (${_names.join(', ')}). Port 8000 will collide. Stop the running model and launch this one?`,
|
||||
{ title: 'Server already running', confirmText: 'Stop & launch', cancelText: 'Cancel' },
|
||||
);
|
||||
if (!_ok) { _restoreLaunchBtn(); return; }
|
||||
// Kill each active serve; prefer the rendered Stop button so
|
||||
// endpoint cleanup + Ollama unload run normally. Fall back to
|
||||
// a raw tmux kill when the Active tab isn't in the DOM.
|
||||
for (const t of _active) {
|
||||
try {
|
||||
const _el = document.querySelector(`.cookbook-task[data-task-id="${t.sessionId}"]`);
|
||||
const _btn = _el?.querySelector('.cookbook-task-action-stop');
|
||||
if (_btn) {
|
||||
_btn.click();
|
||||
} else if (_runningMod._tmuxGracefulKill) {
|
||||
await fetch('/api/shell/exec', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: _runningMod._tmuxGracefulKill(t) }),
|
||||
});
|
||||
}
|
||||
} catch (_killErr) { /* best-effort */ }
|
||||
}
|
||||
// Give the OS a beat to release port 8000.
|
||||
await new Promise(r => setTimeout(r, 2500));
|
||||
}
|
||||
} catch (_e) { /* best-effort */ }
|
||||
|
||||
const backendWarning = _serveBackendWarning(m, repo, serveState.backend, serveState);
|
||||
if (backendWarning) {
|
||||
_restoreLaunchBtn();
|
||||
|
||||
+181
-22
@@ -2472,6 +2472,8 @@ import * as Modals from './modalManager.js';
|
||||
}
|
||||
// Hide toolbar items that have no clean WYSIWYG equivalent in email (Code).
|
||||
document.querySelectorAll('.md-toolbar-email-hide').forEach(el => { el.style.display = 'none'; });
|
||||
// Show email-only toolbar items (AI reply button).
|
||||
document.querySelectorAll('.md-toolbar-email-only').forEach(el => { el.style.display = 'inline-flex'; });
|
||||
if (emailHeader) emailHeader.style.display = '';
|
||||
if (emailActions) emailActions.style.display = '';
|
||||
// Emails have their own complete footer (Close / More / Send), so hide the
|
||||
@@ -2864,6 +2866,8 @@ import * as Modals from './modalManager.js';
|
||||
if (emailActions) emailActions.style.display = 'none';
|
||||
// Restore toolbar items that were hidden for email (Code dropdown).
|
||||
document.querySelectorAll('.md-toolbar-email-hide').forEach(el => { el.style.display = ''; });
|
||||
// Re-hide email-only toolbar items (AI reply button).
|
||||
document.querySelectorAll('.md-toolbar-email-only').forEach(el => { el.style.display = 'none'; });
|
||||
// Restore the generic documents action bar + its bottom footer (Close /
|
||||
// Copy / Export) for non-email docs.
|
||||
const docActions = document.getElementById('doc-editor-actions');
|
||||
@@ -3206,7 +3210,95 @@ import * as Modals from './modalManager.js';
|
||||
renderTabs();
|
||||
}
|
||||
|
||||
async function _aiReply() {
|
||||
// Fast/Full + optional context popover for the doc-editor email Reply button.
|
||||
// Mirrors the email reader's AI reply choice popover so the UX is identical:
|
||||
// textarea for an optional steering note, then Fast (lightning) or Full
|
||||
// (concentric dot) buttons; both feed into _aiReply with the chosen mode.
|
||||
let _docAiReplyChoiceMenu = null;
|
||||
function _closeDocAiReplyChoice() {
|
||||
if (_docAiReplyChoiceMenu) {
|
||||
try { _docAiReplyChoiceMenu.remove(); } catch (_) {}
|
||||
_docAiReplyChoiceMenu = null;
|
||||
}
|
||||
}
|
||||
function _showDocAiReplyChoice(btn) {
|
||||
_closeDocAiReplyChoice();
|
||||
if (!btn) return;
|
||||
const rect = btn.getBoundingClientRect();
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'doc-ai-reply-choice';
|
||||
const menuMaxW = Math.min(240, window.innerWidth - 16);
|
||||
const left = Math.max(8, Math.min(rect.left, window.innerWidth - menuMaxW - 8));
|
||||
const estHeight = 150;
|
||||
const spaceBelow = window.innerHeight - rect.bottom - 8;
|
||||
const spaceAbove = rect.top - 8;
|
||||
const top = (spaceBelow >= estHeight || spaceBelow >= spaceAbove)
|
||||
? Math.max(8, Math.min(rect.bottom + 6, window.innerHeight - estHeight - 8))
|
||||
: Math.max(8, rect.top - estHeight - 6);
|
||||
menu.style.cssText = [
|
||||
'position:fixed',
|
||||
`left:${left}px`,
|
||||
`top:${top}px`,
|
||||
`max-width:${menuMaxW}px`,
|
||||
'box-sizing:border-box',
|
||||
'z-index:10060',
|
||||
'display:flex',
|
||||
'gap:6px',
|
||||
'padding:6px',
|
||||
'background:var(--bg,#111)',
|
||||
'border:1px solid var(--border,#333)',
|
||||
'border-radius:7px',
|
||||
'box-shadow:0 8px 24px rgba(0,0,0,.28)',
|
||||
].join(';');
|
||||
menu.innerHTML = `
|
||||
<div style="display:flex;flex-direction:column;gap:6px;min-width:200px;">
|
||||
<textarea data-note-input rows="2" placeholder="Add context (optional)" style="width:100%;box-sizing:border-box;resize:vertical;min-height:42px;font-family:inherit;font-size:11px;padding:5px 6px;border-radius:5px;border:1px solid var(--border,#333);background:var(--bg-elev,#1a1a1a);color:var(--fg);"></textarea>
|
||||
<div style="display:flex;align-items:center;gap:4px;">
|
||||
<button class="memory-toolbar-btn" data-mode="ai-reply-fast" title="Shorter, faster draft" style="display:inline-flex;align-items:center;justify-content:center;gap:5px;flex:1;">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="var(--accent, var(--red))" aria-hidden="true"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
|
||||
Fast
|
||||
</button>
|
||||
<button class="memory-toolbar-btn" data-mode="ai-reply-full" title="Fuller reply with more context" style="display:inline-flex;align-items:center;justify-content:center;gap:5px;flex:1;">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" style="color:var(--accent, var(--red));"><circle cx="12" cy="12" r="6"/></svg>
|
||||
Full
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const noteInput = menu.querySelector('[data-note-input]');
|
||||
setTimeout(() => noteInput?.focus(), 0);
|
||||
menu.addEventListener('mousedown', (ev) => ev.stopPropagation());
|
||||
menu.addEventListener('click', async (ev) => {
|
||||
const choice = ev.target.closest('[data-mode]');
|
||||
if (!choice) return;
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const mode = choice.getAttribute('data-mode') || 'ai-reply-fast';
|
||||
const noteHint = (noteInput?.value || '').trim();
|
||||
_closeDocAiReplyChoice();
|
||||
await _aiReply({ mode, noteHint });
|
||||
});
|
||||
document.body.appendChild(menu);
|
||||
_docAiReplyChoiceMenu = menu;
|
||||
const outsideClose = (ev) => {
|
||||
if (menu.contains(ev.target)) return;
|
||||
document.removeEventListener('click', outsideClose, true);
|
||||
_closeDocAiReplyChoice();
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', outsideClose, true), 0);
|
||||
// Esc to close.
|
||||
const escClose = (ev) => {
|
||||
if (ev.key === 'Escape') {
|
||||
ev.stopPropagation();
|
||||
document.removeEventListener('keydown', escClose, true);
|
||||
_closeDocAiReplyChoice();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', escClose, true);
|
||||
}
|
||||
|
||||
async function _aiReply(opts = {}) {
|
||||
const { mode = 'auto', noteHint = '' } = (opts || {});
|
||||
const to = document.getElementById('doc-email-to')?.value?.trim() || '';
|
||||
const subject = document.getElementById('doc-email-subject')?.value?.trim() || '';
|
||||
const textarea = document.getElementById('doc-editor-textarea');
|
||||
@@ -3251,32 +3343,43 @@ import * as Modals from './modalManager.js';
|
||||
if (btn) { btn.disabled = true; btn.innerHTML = '<svg width="11" height="11" 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>Drafting...'; }
|
||||
|
||||
try {
|
||||
// Empty-compose path: if there's no original body, send a placeholder
|
||||
// so the backend's "no body" guard doesn't fail. The user_hint carries
|
||||
// the user's compose intent; the model uses To/Subject + that hint.
|
||||
const bodyForApi = currentBody || (noteHint ? '(no prior email — compose a new message based on the To, Subject, and user instructions)' : currentBody);
|
||||
const fastFlag = mode === 'ai-reply-fast' ? true
|
||||
: mode === 'ai-reply-full' ? false
|
||||
: shouldUseFastAiReply();
|
||||
const res = await fetch(`${API_BASE}/api/email/ai-reply`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
to: to,
|
||||
subject: subject,
|
||||
original_body: currentBody,
|
||||
original_body: bodyForApi,
|
||||
model: currentModel,
|
||||
session_id: currentSessionId,
|
||||
message_id: inReplyTo,
|
||||
uid: sourceUid,
|
||||
folder: sourceFolder,
|
||||
fast: shouldUseFastAiReply(),
|
||||
fast: fastFlag,
|
||||
user_hint: noteHint || '',
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success && data.reply) {
|
||||
const cleanReply = cleanAiReplyText(data.reply);
|
||||
const lines = currentBody.split('\n');
|
||||
const quoteIdx = lines.findIndex(l => l.startsWith('On ') && l.includes(' wrote:'));
|
||||
let newBody = '';
|
||||
if (quoteIdx > 0) {
|
||||
newBody = cleanReply + '\n\n' + lines.slice(quoteIdx).join('\n');
|
||||
} else {
|
||||
newBody = cleanReply + (currentBody ? '\n\n' + currentBody : '');
|
||||
}
|
||||
let cleanReply = cleanAiReplyText(data.reply);
|
||||
// Strip any "On <date>, <name> wrote:" attribution + everything
|
||||
// after it from the AI's output — the model sometimes re-quotes
|
||||
// the original thread, and we already have the real quote in
|
||||
// currentBody. Without this, AI's invented quote stacked on top
|
||||
// of the real one and looked like the history had been "edited".
|
||||
cleanReply = cleanReply.replace(/\n*On\b[\s\S]*?\bwrote:[\s\S]*$/m, '').trim();
|
||||
// Never overwrite the existing draft (user's typed text + the
|
||||
// quoted history below it). Always prepend the AI suggestion so
|
||||
// the user can read it, copy parts, or delete it — but their
|
||||
// own work and the original quote are untouched.
|
||||
const newBody = currentBody ? cleanReply + '\n\n' + currentBody : cleanReply;
|
||||
await _streamEmailBodyText(textarea, newBody);
|
||||
if (uiModule) uiModule.showToast(`AI draft inserted (${data.model_used || 'AI'})`);
|
||||
} else {
|
||||
@@ -3285,7 +3388,7 @@ import * as Modals from './modalManager.js';
|
||||
} catch (e) {
|
||||
if (uiModule) uiModule.showError('Failed to generate AI reply');
|
||||
} finally {
|
||||
if (btn) { btn.disabled = false; btn.innerHTML = '<svg width="11" height="11" 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>AI Reply'; }
|
||||
if (btn) { btn.disabled = false; btn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" style="color:var(--accent, var(--red));flex-shrink:0;position:relative;top:-1px;"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg><span style="font-size:11px;margin-left:4px;">Reply</span>'; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3813,7 +3916,6 @@ import * as Modals from './modalManager.js';
|
||||
<button id="doc-export-pdf-btn" class="doc-action-icon-btn" title="Export PDF" style="display:none;opacity:0.7;gap:4px;"><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="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="12" y1="18" x2="12" y2="12"/><polyline points="9 15 12 18 15 15"/></svg> <span style="font-size:11px;">Export PDF</span></button>
|
||||
<button id="doc-pdf-view-btn" class="doc-action-icon-btn" title="Toggle PDF view" style="display:none;opacity:0.7;gap:4px;"><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="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"/></svg> <span style="font-size:11px;">PDF</span></button>
|
||||
<select id="doc-language-select" class="doc-language-select">
|
||||
<option value="">type</option>
|
||||
<option value="python">python</option>
|
||||
<option value="javascript">javascript</option>
|
||||
<option value="typescript">typescript</option>
|
||||
@@ -3851,22 +3953,24 @@ import * as Modals from './modalManager.js';
|
||||
</button>
|
||||
<div id="doc-email-fields" class="doc-email-fields">
|
||||
<div class="email-field" style="position:relative">
|
||||
<label>To</label>
|
||||
<span class="email-field-prefix">To</span>
|
||||
<input type="text" id="doc-email-to" placeholder="recipient@example.com" autocomplete="off" />
|
||||
<div id="doc-email-to-suggestions" class="email-autocomplete" style="display:none"></div>
|
||||
<button type="button" id="doc-email-show-cc" class="email-cc-toggle" title="Show Cc/Bcc">Cc</button>
|
||||
</div>
|
||||
<div class="email-field" id="doc-email-cc-row" style="display:none;position:relative">
|
||||
<label>Cc</label>
|
||||
<input type="text" id="doc-email-cc" placeholder="cc@example.com" autocomplete="off" />
|
||||
<span class="email-field-prefix">Cc</span>
|
||||
<input type="text" id="doc-email-cc" placeholder="cc@example.com, example2" autocomplete="off" />
|
||||
<div id="doc-email-cc-suggestions" class="email-autocomplete" style="display:none"></div>
|
||||
<button type="button" class="email-cc-close" data-cc-close title="Hide Cc/Bcc" aria-label="Hide Cc/Bcc"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||
</div>
|
||||
<div class="email-field" id="doc-email-bcc-row" style="display:none;position:relative">
|
||||
<label>Bcc</label>
|
||||
<span class="email-field-prefix">Bcc</span>
|
||||
<input type="text" id="doc-email-bcc" placeholder="bcc@example.com" autocomplete="off" />
|
||||
<div id="doc-email-bcc-suggestions" class="email-autocomplete" style="display:none"></div>
|
||||
<button type="button" class="email-cc-close" data-cc-close title="Hide Cc/Bcc" aria-label="Hide Cc/Bcc"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||
</div>
|
||||
<div class="email-field"><label>Subject</label><input type="text" id="doc-email-subject" placeholder="Subject" /></div>
|
||||
<div class="email-field" style="position:relative"><span class="email-field-prefix">Subject</span><input type="text" id="doc-email-subject" placeholder="" /></div>
|
||||
<div id="doc-email-attachments" class="email-attachments" style="display:none"></div>
|
||||
<div id="doc-email-compose-atts" class="email-compose-atts" style="display:none"></div>
|
||||
</div>
|
||||
@@ -3879,13 +3983,14 @@ import * as Modals from './modalManager.js';
|
||||
<div class="doc-md-toolbar" id="doc-md-toolbar" style="display:none">
|
||||
<div class="md-toolbar-items" id="md-toolbar-items">
|
||||
<span class="md-view-toggle" id="doc-md-view-toggle" style="display:none" role="group" aria-label="Edit or preview">
|
||||
<button type="button" class="md-view-opt" data-mdview="edit" title="Edit source"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></button>
|
||||
<button type="button" class="md-view-opt" data-mdview="preview" title="Preview"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
|
||||
<button type="button" class="md-view-opt" data-mdview="edit" title="Edit source (Ctrl+Alt+M to toggle)"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></button>
|
||||
<button type="button" class="md-view-opt" data-mdview="preview" title="Preview (Ctrl+Alt+M to toggle)"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
|
||||
</span>
|
||||
<span class="md-view-toggle" id="doc-render-view-toggle" style="display:none" role="group" aria-label="Code or run">
|
||||
<button type="button" class="md-view-opt" data-renderview="code" title="Edit code"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg></button>
|
||||
<button type="button" class="md-view-opt" data-renderview="run" title="Run / Preview"><svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" stroke="none"><polygon points="5 3 19 12 5 21 5 3"/></svg></button>
|
||||
</span>
|
||||
<button id="doc-email-ai-reply-btn" class="doc-action-icon-btn md-toolbar-email-only" type="button" title="Draft a reply with AI (Fast / Full + optional context)" style="display:none;align-items:center;gap:4px;"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" style="color:var(--accent, var(--red));flex-shrink:0;position:relative;top:-1px;"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg><span style="font-size:11px;">Reply</span></button>
|
||||
<button id="doc-fontsize-btn" class="doc-action-icon-btn" title="Font size" style="position:relative;width:28px;height:26px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.7;"><path d="M4 7V4h16v3"/><path d="M12 4v16"/><path d="M8 20h8"/></svg><span class="doc-fontsize-levels"><i data-sz="s">S</i><i data-sz="m">M</i><i data-sz="l">L</i></span></button>
|
||||
<button id="doc-diff-toggle-btn" class="doc-action-icon-btn" title="Compare changes" style="opacity:0.7;display:none;"><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="M12 3v18"/><path d="M5 12H2l5-5 5 5H9"/><path d="M19 12h3l-5 5-5-5h3"/></svg></button>
|
||||
<span class="md-toolbar-sep"></span>
|
||||
@@ -4395,6 +4500,24 @@ import * as Modals from './modalManager.js';
|
||||
}
|
||||
});
|
||||
}
|
||||
// Ctrl+Alt+M (and Cmd+Opt+M on mac) flips Edit ↔ Preview on a markdown
|
||||
// doc. Bound once globally; gated on the doc panel being open and the
|
||||
// active doc being markdown so it doesn't fire while the user is typing
|
||||
// in a non-markdown context.
|
||||
if (!window._docMdToggleBound) {
|
||||
window._docMdToggleBound = true;
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.altKey && !e.shiftKey && (e.key === 'm' || e.key === 'M' || e.code === 'KeyM')) {
|
||||
if (!isOpen) return;
|
||||
const doc = activeDocId && docs.get(activeDocId);
|
||||
const lang = (doc?.language || 'markdown').toLowerCase();
|
||||
if (lang !== 'markdown') return;
|
||||
e.preventDefault();
|
||||
toggleMarkdownPreview();
|
||||
_syncHeaderActions();
|
||||
}
|
||||
});
|
||||
}
|
||||
document.getElementById('doc-email-draft-btn')?.addEventListener('click', () => {
|
||||
document.getElementById('doc-email-more-menu').style.display = 'none';
|
||||
_saveDraft();
|
||||
@@ -4409,7 +4532,11 @@ import * as Modals from './modalManager.js';
|
||||
document.getElementById('doc-email-more-menu').style.display = 'none';
|
||||
_scheduleSend(anchor);
|
||||
});
|
||||
document.getElementById('doc-email-ai-reply-btn')?.addEventListener('click', _aiReply);
|
||||
document.getElementById('doc-email-ai-reply-btn')?.addEventListener('click', (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
_showDocAiReplyChoice(ev.currentTarget);
|
||||
});
|
||||
|
||||
const collapseBtn = document.getElementById('doc-email-collapse-btn');
|
||||
if (collapseBtn && !collapseBtn._emailCollapseWired) {
|
||||
@@ -4489,6 +4616,25 @@ import * as Modals from './modalManager.js';
|
||||
_syncEmailHeaderSummary();
|
||||
});
|
||||
|
||||
// Cc/Bcc close — X buttons inside the Cc and Bcc fields hide both
|
||||
// rows + clear their inputs + restore the Cc opener on the To row.
|
||||
document.querySelectorAll('[data-cc-close]').forEach(closeBtn => {
|
||||
closeBtn.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
const ccRow = document.getElementById('doc-email-cc-row');
|
||||
const bccRow = document.getElementById('doc-email-bcc-row');
|
||||
const ccInput = document.getElementById('doc-email-cc');
|
||||
const bccInput = document.getElementById('doc-email-bcc');
|
||||
if (ccRow) ccRow.style.display = 'none';
|
||||
if (bccRow) bccRow.style.display = 'none';
|
||||
if (ccInput) ccInput.value = '';
|
||||
if (bccInput) bccInput.value = '';
|
||||
const ccToggle = document.getElementById('doc-email-show-cc');
|
||||
if (ccToggle) ccToggle.style.display = '';
|
||||
_syncEmailHeaderSummary();
|
||||
});
|
||||
});
|
||||
|
||||
// Autocomplete for To / Cc / Bcc — typed fragment after the last
|
||||
// comma triggers contact search; Enter / Tab / click on a suggestion
|
||||
// appends "<email>, " so the user can keep typing more recipients.
|
||||
@@ -8527,6 +8673,19 @@ import * as Modals from './modalManager.js';
|
||||
// `body:has(.doc-editor-pane.doc-fullscreen) .doc-divider-collapse` slides
|
||||
// it into a forced-inside position). Hiding the divider here would hide
|
||||
// the chevron with it.
|
||||
|
||||
// Hide the tab bar during the layout shift so any in-flight smooth
|
||||
// scroll / reflow doesn't visibly "fly" the active tab across the
|
||||
// pane as it expands. Restored after the layout settles.
|
||||
const tabBar = document.getElementById('doc-tab-bar');
|
||||
if (tabBar) {
|
||||
tabBar.style.visibility = 'hidden';
|
||||
clearTimeout(tabBar._fsHideTimer);
|
||||
tabBar._fsHideTimer = setTimeout(() => {
|
||||
tabBar.style.visibility = '';
|
||||
}, 240);
|
||||
}
|
||||
|
||||
if (pane.classList.contains('doc-fullscreen')) {
|
||||
pane.classList.remove('doc-fullscreen');
|
||||
if (container) container.style.display = '';
|
||||
|
||||
+16
-20
@@ -22,8 +22,8 @@ const _replyIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
const _archiveIcon = '<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="3" width="20" height="5" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/></svg>';
|
||||
const _deleteIcon = '<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 _unreadIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor"/></svg>';
|
||||
const _starIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>';
|
||||
const _starFilledIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>';
|
||||
const _starIcon = '<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="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>';
|
||||
const _starFilledIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>';
|
||||
const _bellIcon = '<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="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>';
|
||||
const _icon = (svg) => `<span class="dropdown-icon">${svg}</span>`;
|
||||
const _replySeparator = '---------- Previous message ----------';
|
||||
@@ -74,6 +74,11 @@ window.addEventListener('email-answered', (e) => {
|
||||
item.classList.remove('email-unread');
|
||||
const check = item.querySelector('.email-done-check');
|
||||
if (check) check.classList.add('active');
|
||||
// Auto-mark from sending a reply — flash the row so the user sees the
|
||||
// state change without staring at it. Class self-removes after the
|
||||
// animation so it doesn't replay on re-renders.
|
||||
item.classList.add('email-auto-done-flash');
|
||||
setTimeout(() => item.classList.remove('email-auto-done-flash'), 1200);
|
||||
});
|
||||
});
|
||||
let _loading = false;
|
||||
@@ -113,19 +118,19 @@ export function init(documentModule) {
|
||||
} catch (_) {}
|
||||
if (opts.compose) { _composeNew(); return; }
|
||||
if (opts.email) {
|
||||
await _openEmail(opts.email, null, opts.emailData, opts.mode || 'reply');
|
||||
await _openEmail(opts.email, null, opts.emailData, opts.mode || 'reply', opts.noteHint || '');
|
||||
}
|
||||
},
|
||||
});
|
||||
_watchDocOpenToReDockEmail();
|
||||
}
|
||||
|
||||
export async function openReplyDraft(uid, folder = 'INBOX', mode = 'reply') {
|
||||
export async function openReplyDraft(uid, folder = 'INBOX', mode = 'reply', prefilledBody = '') {
|
||||
if (!uid) return;
|
||||
const previousFolder = _currentFolder;
|
||||
_currentFolder = folder || 'INBOX';
|
||||
try {
|
||||
await _openEmail({ uid: String(uid), subject: '' }, null, null, mode || 'reply');
|
||||
await _openEmail({ uid: String(uid), subject: '' }, null, null, mode || 'reply', '', prefilledBody || '');
|
||||
} finally {
|
||||
_currentFolder = previousFolder || _currentFolder;
|
||||
}
|
||||
@@ -525,11 +530,6 @@ function _createEmailItem(em) {
|
||||
</div>
|
||||
<div class="email-subject">${_esc(em.subject)}${unreadIcon}${attachIcon}${tagPills}${spamTag}</div>
|
||||
</div>
|
||||
<div class="email-menu-wrap">
|
||||
<button class="hamburger email-menu-btn" title="Actions">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Click sender name → filter list to that sender
|
||||
@@ -562,17 +562,10 @@ function _createEmailItem(em) {
|
||||
|
||||
// Click to open — do NOT close sidebar
|
||||
item.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.email-menu-wrap')) return;
|
||||
if (item.dataset.swipeBlock === '1') return;
|
||||
_openEmail(em, item);
|
||||
});
|
||||
|
||||
const menuWrap = item.querySelector('.email-menu-wrap');
|
||||
menuWrap.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
_showEmailMenu(em, menuWrap, item);
|
||||
});
|
||||
|
||||
// Swipe left to archive (mobile). Mirrors sidebar-layout.js swipe pattern.
|
||||
if ('ontouchstart' in window) {
|
||||
let startX = 0, startY = 0, dx = 0, dy = 0, swiping = false, swiped = false;
|
||||
@@ -580,7 +573,6 @@ function _createEmailItem(em) {
|
||||
const VERT_CANCEL = 30; // px vertical motion cancels swipe (treat as scroll)
|
||||
|
||||
item.addEventListener('touchstart', (e) => {
|
||||
if (e.target.closest('.email-menu-wrap')) return;
|
||||
const t = e.touches[0];
|
||||
startX = t.clientX; startY = t.clientY;
|
||||
dx = 0; dy = 0; swiping = true; swiped = false;
|
||||
@@ -638,10 +630,13 @@ function _createEmailItem(em) {
|
||||
return item;
|
||||
}
|
||||
|
||||
async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply') {
|
||||
async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', noteHint = '', prefilledBody = '') {
|
||||
const aiReplyMode = mode === 'ai-reply-fast' ? 'fast' : (mode === 'ai-reply-full' ? 'full' : '');
|
||||
const wantsAiReply = mode === 'ai-reply' || !!aiReplyMode;
|
||||
let aiSuggestedBody = null;
|
||||
// Body pre-fill from the agent's open_email_reply tool call takes the
|
||||
// same insertion slot as an AI-suggested body — both land just before
|
||||
// the quoted-original block.
|
||||
let aiSuggestedBody = (typeof prefilledBody === 'string' && prefilledBody.trim()) ? prefilledBody.trim() : null;
|
||||
if (wantsAiReply) {
|
||||
// Fall through to reply-all (not plain reply) so the generated AI
|
||||
// draft addresses everyone on the original thread. On single-
|
||||
@@ -698,6 +693,7 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply') {
|
||||
uid: String(em.uid || ''),
|
||||
folder: _currentFolder,
|
||||
fast: aiReplyMode ? aiReplyMode === 'fast' : _shouldUseFastAiReply(data),
|
||||
user_hint: (noteHint || '').trim() || undefined,
|
||||
}),
|
||||
});
|
||||
const result = await res.json();
|
||||
|
||||
+1470
-218
File diff suppressed because it is too large
Load Diff
+82
-4
@@ -18,6 +18,80 @@ let selectedIds = new Set();
|
||||
|
||||
const MEMORY_CATEGORIES = ['fact', 'identity', 'preference', 'contact', 'project', 'goal', 'task'];
|
||||
|
||||
// Sort-option icons for the custom Memory sort picker (and Skills picker
|
||||
// once it reuses the same markup). Each value maps to a 13px Feather-style
|
||||
// SVG so the icon visually distinguishes Newest / Oldest / A-Z / Most used.
|
||||
const _MEMORY_SORT_ICONS = {
|
||||
newest: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>',
|
||||
oldest: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><polyline points="3 3 3 8 8 8"/><polyline points="12 7 12 12 16 14"/></svg>',
|
||||
alpha: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 4h6"/><path d="M3 10h6"/><path d="M3 16h4"/><path d="M14 4l4 12"/><path d="M16 12h4"/><polyline points="17 18 21 14 17 10"/><line x1="21" y1="14" x2="13" y2="14"/></svg>',
|
||||
uses: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/></svg>',
|
||||
};
|
||||
|
||||
function _memorySortIcon(value) {
|
||||
return _MEMORY_SORT_ICONS[value] || _MEMORY_SORT_ICONS.newest;
|
||||
}
|
||||
|
||||
function _renderMemorySortPickerCurrent() {
|
||||
const sel = document.getElementById('memory-sort');
|
||||
const btn = document.getElementById('memory-sort-btn');
|
||||
if (!sel || !btn) return;
|
||||
const value = sel.value || 'newest';
|
||||
const opt = sel.querySelector(`option[value="${CSS.escape(value)}"]`);
|
||||
const label = opt ? opt.textContent : value;
|
||||
const iconWrap = btn.querySelector('.memory-sort-icon-cur');
|
||||
const labelEl = btn.querySelector('.memory-sort-label');
|
||||
if (iconWrap) iconWrap.innerHTML = _memorySortIcon(value);
|
||||
if (labelEl) labelEl.textContent = label;
|
||||
}
|
||||
|
||||
function _initMemorySortPicker() {
|
||||
const sel = document.getElementById('memory-sort');
|
||||
const picker = document.getElementById('memory-sort-picker');
|
||||
const btn = document.getElementById('memory-sort-btn');
|
||||
const menu = document.getElementById('memory-sort-menu');
|
||||
if (!sel || !picker || !btn || !menu || picker._wired) return;
|
||||
picker._wired = true;
|
||||
|
||||
const items = Array.from(sel.children)
|
||||
.filter(o => o.tagName === 'OPTION')
|
||||
.map(o => ({ value: o.value, label: o.textContent }));
|
||||
|
||||
menu.innerHTML = items.map(it => `
|
||||
<button type="button" role="option" class="memory-sort-item" data-value="${it.value}">
|
||||
<span class="memory-sort-item-icon">${_memorySortIcon(it.value)}</span>
|
||||
<span class="memory-sort-item-label">${it.label}</span>
|
||||
</button>
|
||||
`).join('');
|
||||
|
||||
const close = () => { menu.hidden = true; btn.setAttribute('aria-expanded', 'false'); };
|
||||
const open = () => { menu.hidden = false; btn.setAttribute('aria-expanded', 'true'); };
|
||||
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (menu.hidden) open(); else close();
|
||||
});
|
||||
menu.addEventListener('click', (e) => {
|
||||
const item = e.target.closest('.memory-sort-item');
|
||||
if (!item) return;
|
||||
sel.value = item.dataset.value;
|
||||
sel.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
_renderMemorySortPickerCurrent();
|
||||
close();
|
||||
});
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!menu.hidden && !picker.contains(e.target)) close();
|
||||
});
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && !menu.hidden) {
|
||||
e.stopPropagation();
|
||||
close();
|
||||
}
|
||||
}, { capture: true });
|
||||
|
||||
_renderMemorySortPickerCurrent();
|
||||
}
|
||||
|
||||
function _ensureNewMemoryCategorySelect() {
|
||||
const sel = document.getElementById('new-memory-category');
|
||||
if (!sel || sel.dataset.wired === '1') return;
|
||||
@@ -334,13 +408,16 @@ export async function loadMemories() {
|
||||
|
||||
// ---- Bulk select mode ----
|
||||
|
||||
const _SELECT_BTN_DOT_SVG = '<svg class="memory-select-btn-icon" 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:-2px;margin-right:3px;"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/></svg>';
|
||||
const _SELECT_BTN_X_SVG = '<svg class="memory-select-btn-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" style="vertical-align:-2px;margin-right:3px;"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
|
||||
|
||||
function enterSelectMode() {
|
||||
selectMode = true;
|
||||
selectedIds.clear();
|
||||
const bulkBar = document.getElementById('memory-bulk-bar');
|
||||
const selectBtn = document.getElementById('memory-select-btn');
|
||||
if (bulkBar) bulkBar.classList.remove('hidden');
|
||||
if (selectBtn) { selectBtn.classList.add('active'); selectBtn.textContent = 'Cancel'; }
|
||||
if (selectBtn) { selectBtn.classList.add('active'); selectBtn.innerHTML = _SELECT_BTN_X_SVG + 'Cancel'; }
|
||||
updateBulkCount();
|
||||
renderMemoryList();
|
||||
}
|
||||
@@ -352,7 +429,7 @@ function exitSelectMode() {
|
||||
const selectBtn = document.getElementById('memory-select-btn');
|
||||
const selectAll = document.getElementById('memory-select-all');
|
||||
if (bulkBar) bulkBar.classList.add('hidden');
|
||||
if (selectBtn) { selectBtn.classList.remove('active'); selectBtn.textContent = 'Select'; }
|
||||
if (selectBtn) { selectBtn.classList.remove('active'); selectBtn.innerHTML = _SELECT_BTN_DOT_SVG + 'Select'; }
|
||||
if (selectAll) selectAll.checked = false;
|
||||
renderMemoryList();
|
||||
}
|
||||
@@ -449,7 +526,7 @@ export async function tidyMemories() {
|
||||
const data = await res.json();
|
||||
if ((data.removed || 0) === 0) {
|
||||
if (tidySpinner) tidySpinner.destroy();
|
||||
if (tidyBtn) { tidyBtn.disabled = false; tidyBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;margin-right:2px;"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg> Tidy'; }
|
||||
if (tidyBtn) { tidyBtn.disabled = false; tidyBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;margin-right:2px;color:var(--accent, var(--red));"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg> Tidy'; }
|
||||
showToast('Already clean');
|
||||
return;
|
||||
}
|
||||
@@ -492,7 +569,7 @@ export async function tidyMemories() {
|
||||
tidyBtn.disabled = false;
|
||||
tidyBtn.style.border = '';
|
||||
tidyBtn.style.background = '';
|
||||
tidyBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;margin-right:2px;"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg> Tidy';
|
||||
tidyBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;margin-right:2px;color:var(--accent, var(--red));"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg> Tidy';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1387,6 +1464,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
renderMemoryList();
|
||||
});
|
||||
}
|
||||
_initMemorySortPicker();
|
||||
|
||||
const tidyBtn = document.getElementById('memory-tidy-btn');
|
||||
if (tidyBtn) tidyBtn.addEventListener('click', tidyMemories);
|
||||
|
||||
@@ -302,6 +302,7 @@ function _anchorLeftDock(content) {
|
||||
}
|
||||
}
|
||||
|
||||
export function collapseSidebarToRail() { return _collapseSidebarToRail(); }
|
||||
function _collapseSidebarToRail() {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const rail = document.getElementById('icon-rail');
|
||||
@@ -808,7 +809,10 @@ export function makeEdgeDockController(modal, side = 'right', dockClass) {
|
||||
handle.style.bottom = '0';
|
||||
handle.style.width = '10px';
|
||||
handle.style.cursor = 'col-resize';
|
||||
handle.style.background = 'linear-gradient(to right, transparent 0 3px, color-mix(in srgb, var(--accent, var(--red)) 35%, transparent) 3px 7px, transparent 7px 10px)';
|
||||
// Invisible at rest, accent stripe fades in on hover (see
|
||||
// .edge-dock-resize-handle CSS rule).
|
||||
handle.style.background = 'transparent';
|
||||
handle.style.transition = 'background 0.18s ease';
|
||||
handle.style.pointerEvents = 'auto';
|
||||
handle.style.touchAction = 'none';
|
||||
handle.style.display = 'none';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user