Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90d65ec654 | ||
|
|
7298102e93 | ||
|
|
538aac9122 | ||
|
|
05c215f685 | ||
|
|
872ebb94dd | ||
|
|
b302fa0b17 | ||
|
|
bd490efda5 | ||
|
|
9339e2db41 | ||
|
|
d7f89ee257 | ||
|
|
b1fa54a7b6 | ||
|
|
e8e455b779 | ||
|
|
6d656a355d | ||
|
|
ca55404f99 | ||
|
|
0241f6031b | ||
|
|
4244b9370c |
No files matched your search
+81
-1
@@ -3,7 +3,87 @@
|
|||||||
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
|
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
|
||||||
until 1.0, minor versions may contain breaking changes.
|
until 1.0, minor versions may contain breaking changes.
|
||||||
|
|
||||||
## [Unreleased]
|
## [v202611-00014] – 2026-11-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **New folder in the folder picker:** creating an automation or a chat, *New folder* creates a
|
||||||
|
subfolder of the shown folder and selects it.
|
||||||
|
|
||||||
|
## [v202611-00013] – 2026-11-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Automation editor:** *New automation* asks for name, project and a starting point (blank
|
||||||
|
example structure or a template), creates `.codecraft/jobs/<name>.md` and opens an editor:
|
||||||
|
the instructions in a Markdown editor, schedule and settings next to them, and an assistant
|
||||||
|
chat in which a model works out the instructions with you and edits the file (reloaded after
|
||||||
|
each answer). New automations start switched off.
|
||||||
|
- **Graphical schedule:** every few minutes, hourly, daily, on weekdays, monthly or a cron
|
||||||
|
expression, with the next runs in server time.
|
||||||
|
- **Chat list:** folders and projects can be dragged into any order or moved and pinned to the
|
||||||
|
top via their menu; the order is kept per user on all devices. A new message moves the chat
|
||||||
|
to the top of its folder or project, no longer the whole project.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Automation files hold only the instructions;** name, schedule, mode, model, approval mode
|
||||||
|
and time limit are settings of the automation. Existing job files are taken over on the first
|
||||||
|
start (settings into the database, the frontmatter removed from the file). Registering a
|
||||||
|
plain Markdown file creates a switched-off automation to be scheduled in the editor.
|
||||||
|
|
||||||
|
## [v202611-00012] – 2026-11-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Approval mode of automations in the list:** *Automations* shows the approval mode of each
|
||||||
|
automation as a selection; the change is written into the job file and applies from the next
|
||||||
|
run. "Allow everything" lets nightly runs work without waiting for approvals (they used to
|
||||||
|
wait for an answer via Telegram until the timeout).
|
||||||
|
- **Telegram per chat:** *⋯ → Send questions to Telegram* (shown with a paired bot) decides
|
||||||
|
whether approvals and questions of that chat and its subagents are sent to Telegram. New chats
|
||||||
|
from the web UI and automation runs start with it switched off (they stay in the web UI);
|
||||||
|
chats started from Telegram and existing chats keep reporting. The chat currently open in
|
||||||
|
Telegram always reports.
|
||||||
|
|
||||||
|
## [v202611-00011] – 2026-11-09
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Collapsible task list:** the model's task list above the composer folds to one line
|
||||||
|
(progress and current task) with a click on its heading; the choice is kept per device.
|
||||||
|
- **Square and short screens** (e.g. Clicks Communicator, phones in landscape): tighter top bar,
|
||||||
|
composer and new-chat wizard, so the chat keeps most of the height.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Blank page on phones after the update:** installed apps (PWA) checked their service worker
|
||||||
|
at its old URL, got the web page instead of a script and therefore kept the previous app
|
||||||
|
version forever. With the new image format of v202611-00010 that old version crashed after
|
||||||
|
showing the chat for a moment. Old service worker URLs now get the clean-up worker, which
|
||||||
|
removes the stale worker and reloads the current version. Missing scripts/styles answer
|
||||||
|
404 instead of the web page.
|
||||||
|
|
||||||
|
## [v202611-00010] – 2026-11-09
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Chats with many images load much faster:** images (screenshots, generated images, pasted
|
||||||
|
images) are stored separately instead of inside the messages and load on demand when they
|
||||||
|
scroll into view, cached by the browser. Large PNGs without transparency are stored as JPEG.
|
||||||
|
Older chats are converted once on the first start; the database usually shrinks a lot.
|
||||||
|
Example: a chat with 120 screenshots loads 1.5 MB instead of 66 MB.
|
||||||
|
- **Compressed responses:** the API and the web UI are sent with gzip/brotli (not images,
|
||||||
|
downloads or live streams).
|
||||||
|
|
||||||
|
## [v202611-00009] – 2026-11-09
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Sandbox on x86_64:** the start-up check of bubblewrap did not mount `/lib64`, so it failed
|
||||||
|
on amd64 hosts and shell commands of non-admin users were disabled.
|
||||||
|
|
||||||
|
## [v202611-00008] – 2026-11-09
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
|||||||
Generated
+67
@@ -52,6 +52,21 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alloc-no-stdlib"
|
||||||
|
version = "3.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c2fb6cfd47bf496ff64095c20eaba0c201404ee38714d4142fcfa1dc334fcc7a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alloc-stdlib"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b5c1865780388bfa186411ab5f247819487fc4864c6e9c3106611fa347586e1"
|
||||||
|
dependencies = [
|
||||||
|
"alloc-no-stdlib",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "allocator-api2"
|
name = "allocator-api2"
|
||||||
version = "0.2.21"
|
version = "0.2.21"
|
||||||
@@ -153,6 +168,18 @@ dependencies = [
|
|||||||
"password-hash",
|
"password-hash",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-compression"
|
||||||
|
version = "0.4.52"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6ad47a740fb906fffdd891e8b6d5fde2511600f7bfaebd68939fa08a14999459"
|
||||||
|
dependencies = [
|
||||||
|
"compression-codecs",
|
||||||
|
"compression-core",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-trait"
|
name = "async-trait"
|
||||||
version = "0.1.92"
|
version = "0.1.92"
|
||||||
@@ -363,6 +390,27 @@ dependencies = [
|
|||||||
"hybrid-array",
|
"hybrid-array",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "brotli"
|
||||||
|
version = "9.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "627b7cfb0578f0eafce087a90fcd40add1231900b69e4d4de2d456260a4f5d81"
|
||||||
|
dependencies = [
|
||||||
|
"alloc-no-stdlib",
|
||||||
|
"alloc-stdlib",
|
||||||
|
"brotli-decompressor",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "brotli-decompressor"
|
||||||
|
version = "6.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "75c74c9c51b9140663e196b5fb88bc94c334d7a497c8311cb759d8557874747f"
|
||||||
|
dependencies = [
|
||||||
|
"alloc-no-stdlib",
|
||||||
|
"alloc-stdlib",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bstr"
|
name = "bstr"
|
||||||
version = "1.13.1"
|
version = "1.13.1"
|
||||||
@@ -682,6 +730,24 @@ dependencies = [
|
|||||||
"static_assertions",
|
"static_assertions",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "compression-codecs"
|
||||||
|
version = "0.4.47"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "84f840cff84d10085260e460023934d0af699e71212b09b2a41452e8bd9cda43"
|
||||||
|
dependencies = [
|
||||||
|
"brotli",
|
||||||
|
"compression-core",
|
||||||
|
"flate2",
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "compression-core"
|
||||||
|
version = "0.4.34"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2ebdda89edc534bf8d5dd2857562633b6495557e5846552ade994b99ed13c36a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "const-oid"
|
name = "const-oid"
|
||||||
version = "0.10.2"
|
version = "0.10.2"
|
||||||
@@ -4035,6 +4101,7 @@ version = "0.7.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c"
|
checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"async-compression",
|
||||||
"bitflags 2.13.2",
|
"bitflags 2.13.2",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img alt="Version v202611-00007" src="https://img.shields.io/badge/version-v202611--00007-d69e2e?style=flat-square">
|
<img alt="Version v202611-00014" src="https://img.shields.io/badge/version-v202611--00014-d69e2e?style=flat-square">
|
||||||
<img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-689d6a?style=flat-square">
|
<img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-689d6a?style=flat-square">
|
||||||
<img alt="Rust backend" src="https://img.shields.io/badge/backend-Rust-b7410e?style=flat-square&logo=rust">
|
<img alt="Rust backend" src="https://img.shields.io/badge/backend-Rust-b7410e?style=flat-square&logo=rust">
|
||||||
<img alt="React PWA" src="https://img.shields.io/badge/frontend-React%20PWA-2d81ff?style=flat-square&logo=react">
|
<img alt="React PWA" src="https://img.shields.io/badge/frontend-React%20PWA-2d81ff?style=flat-square&logo=react">
|
||||||
@@ -202,11 +202,15 @@ platforms: `cargo install --locked --git https://git.familie-zink.org/zink/codec
|
|||||||
<img src="docs/images/automations.png" alt="Creating an automation from a template: schedule chips, cron expression, mode, permission mode, model and the task for the agent" width="100%">
|
<img src="docs/images/automations.png" alt="Creating an automation from a template: schedule chips, cron expression, mode, permission mode, model and the task for the agent" width="100%">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
An automation is a Markdown file with a cron schedule in its front matter
|
An automation is an agent that starts on a schedule. Its instructions are a Markdown file
|
||||||
(`.codecraft/jobs/<name>.md`). Each run is a normal chat session, so you can read and continue
|
(`.codecraft/jobs/<name>.md`, only the prompt); name, schedule, mode, model and limits are set
|
||||||
it; if it needs a permission, you get a push notification. Ask an agent in any chat to set one
|
in the UI, with a graphical schedule editor (every few minutes, hourly, daily, on weekdays,
|
||||||
up, start from a template (dependency check, security review of recent commits, test suite
|
monthly or a cron expression). *New automation* creates the file – blank with an example
|
||||||
health, daily summary, TODO report, documentation freshness) or write the file yourself.
|
structure or from a template (dependency check, security review of recent commits, test suite
|
||||||
|
health, daily summary, TODO report, documentation freshness) – and opens an editor with an
|
||||||
|
assistant chat in which a model works out the instructions with you and writes the file. You
|
||||||
|
can also ask an agent in any chat to set one up. Each run is a normal chat session, so you can
|
||||||
|
read and continue it.
|
||||||
|
|
||||||
**Webhooks** start an automation from outside, e.g. on every push: the request is checked with a
|
**Webhooks** start an automation from outside, e.g. on every push: the request is checked with a
|
||||||
shared secret or a Gitea/GitHub HMAC signature, and its JSON payload is handed to the agent.
|
shared secret or a Gitea/GitHub HMAC signature, and its JSON payload is handed to the agent.
|
||||||
|
|||||||
+2
-2
@@ -421,7 +421,7 @@ impl App {
|
|||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
if let (Some(mt), Ok(bytes)) = (mt, std::fs::read(&path)) {
|
if let (Some(mt), Ok(bytes)) = (mt, std::fs::read(&path)) {
|
||||||
self.images.push(ImageData { media_type: mt.into(), data: base64::engine::general_purpose::STANDARD.encode(bytes) });
|
self.images.push(ImageData { blob: None, size: None, media_type: mt.into(), data: base64::engine::general_purpose::STANDARD.encode(bytes) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -840,7 +840,7 @@ impl App {
|
|||||||
for (cmd, args) in tries {
|
for (cmd, args) in tries {
|
||||||
if let Ok(out) = std::process::Command::new(cmd).args(*args).stderr(std::process::Stdio::null()).output() {
|
if let Ok(out) = std::process::Command::new(cmd).args(*args).stderr(std::process::Stdio::null()).output() {
|
||||||
if out.status.success() && out.stdout.starts_with(b"\x89PNG") {
|
if out.status.success() && out.stdout.starts_with(b"\x89PNG") {
|
||||||
self.images.push(ImageData { media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(&out.stdout) });
|
self.images.push(ImageData { blob: None, size: None, media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(&out.stdout) });
|
||||||
self.status = format!("image {} attached", self.images.len());
|
self.status = format!("image {} attached", self.images.len());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -408,7 +408,7 @@ pub async fn run(ctx: &Ctx, name: &str, input: &Value) -> Result<Output> {
|
|||||||
return Ok(Output {
|
return Ok(Output {
|
||||||
content: format!("Image {} ({} bytes)", rel(&ctx.cwd, &path), bytes.len()),
|
content: format!("Image {} ({} bytes)", rel(&ctx.cwd, &path), bytes.len()),
|
||||||
is_error: false,
|
is_error: false,
|
||||||
images: vec![ImageData { media_type: mt.into(), data: base64::engine::general_purpose::STANDARD.encode(&bytes) }],
|
images: vec![ImageData { blob: None, size: None, media_type: mt.into(), data: base64::engine::general_purpose::STANDARD.encode(&bytes) }],
|
||||||
diff: None,
|
diff: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-2
@@ -10,11 +10,25 @@ pub enum Role {
|
|||||||
Assistant,
|
Assistant,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct ImageData {
|
pub struct ImageData {
|
||||||
pub media_type: String,
|
pub media_type: String,
|
||||||
/// Base64-encoded bytes.
|
/// Base64-encoded bytes. Empty when the image is stored separately (`blob`).
|
||||||
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||||
pub data: String,
|
pub data: String,
|
||||||
|
/// Server only: SHA-256 of the bytes in the image store; the UI loads
|
||||||
|
/// /api/blobs/<blob> and the server fills `data` in before a model request.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub blob: Option<String>,
|
||||||
|
/// Size in bytes of a stored image (shown in the UI without fetching it).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub size: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ImageData {
|
||||||
|
pub fn new(media_type: impl Into<String>, data: String) -> Self {
|
||||||
|
Self { media_type: media_type.into(), data, blob: None, size: None }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
@@ -169,6 +183,10 @@ pub struct Session {
|
|||||||
/// Instructions the user set for this chat; added to the system prompt of every turn.
|
/// Instructions the user set for this chat; added to the system prompt of every turn.
|
||||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||||
pub instructions: String,
|
pub instructions: String,
|
||||||
|
/// Approvals and questions of this chat (and its subagents) are not sent to Telegram,
|
||||||
|
/// unless it is the chat currently open in Telegram.
|
||||||
|
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||||
|
pub telegram_quiet: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Session {
|
impl Session {
|
||||||
|
|||||||
Executable
+119
@@ -0,0 +1,119 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Roll out a published CodeCraft image to a remote installation that was set up with
|
||||||
|
# scripts/move-server.sh (docker-compose.yml + .env in one folder on the target).
|
||||||
|
#
|
||||||
|
# scripts/release.sh # build and publish a new version (amd64 + arm64)
|
||||||
|
# scripts/deploy.sh # roll the newest tag out to the target
|
||||||
|
#
|
||||||
|
# Steps:
|
||||||
|
# 1. check that the image exists in the registry for the target's architecture,
|
||||||
|
# 2. wait until no agent or terminal runs on the target (unless --force),
|
||||||
|
# 3. set CODECRAFT_VERSION in the target's .env, docker compose pull && up -d
|
||||||
|
# (or start.sh on hosts without Compose),
|
||||||
|
# 4. run scripts/smoke-test.sh on the target; on failure the previous version is
|
||||||
|
# started again.
|
||||||
|
#
|
||||||
|
# Usage: scripts/deploy.sh [vYYYYMM-NNNNN] [--target user@host:/dir] [--force]
|
||||||
|
# [--wait MINUTES] [--dry-run]
|
||||||
|
# vYYYYMM-NNNNN version to deploy (default: the newest vYYYYMM-NNNNN git tag)
|
||||||
|
# --target installation folder on the target (default: DEPLOY_TARGET from .env)
|
||||||
|
# --force restart even if agents or terminals are running (they end)
|
||||||
|
# --wait N how long to wait for an idle server before giving up (default 15)
|
||||||
|
# --dry-run show the steps only
|
||||||
|
#
|
||||||
|
# Environment:
|
||||||
|
# REGISTRY image name without tag (default git.familie-zink.org/zink/codecraft)
|
||||||
|
# CONTAINER container name on the target (default codecraft)
|
||||||
|
# PORT port of CodeCraft on the target, for the smoke test (default: PORT
|
||||||
|
# from the target's .env, else 8787)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REGISTRY="${REGISTRY:-git.familie-zink.org/zink/codecraft}"
|
||||||
|
CONTAINER="${CONTAINER:-codecraft}"
|
||||||
|
|
||||||
|
tag="" target="" force=0 wait_min=15 dry=0
|
||||||
|
while (($#)); do
|
||||||
|
case "$1" in
|
||||||
|
--target) target="$2"; shift ;;
|
||||||
|
--force) force=1 ;;
|
||||||
|
--wait) wait_min="$2"; shift ;;
|
||||||
|
--dry-run) dry=1 ;;
|
||||||
|
-h|--help) sed -n '2,29p' "$0"; exit 0 ;;
|
||||||
|
-*) echo "unknown option $1" >&2; exit 2 ;;
|
||||||
|
*) tag="$1" ;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
|
||||||
|
cd "$(git rev-parse --show-toplevel)"
|
||||||
|
step() { printf '\n==> %s\n' "$*"; }
|
||||||
|
fail() { echo "error: $*" >&2; exit 1; }
|
||||||
|
q() { printf '%q' "$1"; }
|
||||||
|
env_get() { [[ -f .env ]] && sed -n "s/^$1=//p" .env | tail -1 | sed -E "s/^'(.*)'\$/\1/; s/^\"(.*)\"\$/\1/"; }
|
||||||
|
|
||||||
|
target="${target:-$(env_get DEPLOY_TARGET || true)}"
|
||||||
|
[[ "$target" == *:/* ]] || fail "no target – pass --target user@host:/dir or set DEPLOY_TARGET in .env"
|
||||||
|
host="${target%%:*}"
|
||||||
|
dir="${target#*:}"; dir="${dir%/}"
|
||||||
|
remote() { ssh -o BatchMode=yes "$host" "$1"; }
|
||||||
|
run_remote() { if (( dry )); then echo "[dry-run] on $host: $1"; else remote "$1"; fi; }
|
||||||
|
|
||||||
|
if [[ -z "$tag" ]]; then
|
||||||
|
git fetch --tags --quiet origin || true
|
||||||
|
tag=$(git tag -l 'v[0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9]' | sort | tail -1)
|
||||||
|
[[ -n "$tag" ]] || fail "no release tag found"
|
||||||
|
fi
|
||||||
|
[[ "$tag" =~ ^v[0-9]{6}-[0-9]{5}$ ]] || fail "version must look like vYYYYMM-NNNNN, got $tag"
|
||||||
|
|
||||||
|
step "Checking $host:$dir and the image $REGISTRY:$tag"
|
||||||
|
remote "test -f $(q "$dir/.env") && test -f $(q "$dir/docker-compose.yml")" \
|
||||||
|
|| fail "$dir on $host has no .env/docker-compose.yml – set it up with scripts/move-server.sh"
|
||||||
|
current=$(remote "sed -n 's/^CODECRAFT_VERSION=//p' $(q "$dir/.env") | tail -1")
|
||||||
|
case "$(remote 'uname -m')" in
|
||||||
|
x86_64) arch=amd64 ;; aarch64|arm64) arch=arm64 ;; *) arch="" ;;
|
||||||
|
esac
|
||||||
|
if [[ -n "$arch" ]]; then
|
||||||
|
# Captured first: `inspect | grep -q` fails under pipefail when grep exits early (SIGPIPE).
|
||||||
|
manifests=$(docker buildx imagetools inspect "$REGISTRY:$tag" 2>/dev/null || true)
|
||||||
|
grep -q "Platform:.*linux/$arch" <<<"$manifests" \
|
||||||
|
|| fail "$REGISTRY:$tag has no linux/$arch image – run scripts/release.sh first"
|
||||||
|
fi
|
||||||
|
echo "deploying $tag (running: ${current:-unknown}, target linux/${arch:-?})"
|
||||||
|
[[ "$current" == "$tag" ]] && echo "note: $tag is already configured – the container is recreated anyway"
|
||||||
|
|
||||||
|
# Busy = the container runs more than the server process (agents' shells, terminals, ...).
|
||||||
|
busy() { (( $(remote "docker top $(q "$CONTAINER") -o pid 2>/dev/null | tail -n +2 | wc -l") > 1 )); }
|
||||||
|
if (( ! force )) && remote "docker inspect -f '{{.State.Running}}' $(q "$CONTAINER") 2>/dev/null" | grep -q true; then
|
||||||
|
step "Waiting until no agent or terminal runs (up to $wait_min min, --force skips this)"
|
||||||
|
deadline=$(( $(date +%s) + wait_min * 60 ))
|
||||||
|
while busy; do
|
||||||
|
(( $(date +%s) < deadline )) || fail "still busy after $wait_min min – try later or use --force"
|
||||||
|
sleep 15
|
||||||
|
done
|
||||||
|
echo "idle"
|
||||||
|
fi
|
||||||
|
|
||||||
|
step "Updating to $tag"
|
||||||
|
run_remote "cd $(q "$dir") && sed -i 's/^CODECRAFT_VERSION=.*/CODECRAFT_VERSION=$tag/' .env \
|
||||||
|
&& (grep -q '^CODECRAFT_VERSION=' .env || echo 'CODECRAFT_VERSION=$tag' >> .env) \
|
||||||
|
&& docker pull -q $(q "$REGISTRY:$tag") >/dev/null \
|
||||||
|
&& if docker compose version >/dev/null 2>&1; then docker compose up -d; else ./start.sh; fi"
|
||||||
|
|
||||||
|
step "Smoke test"
|
||||||
|
port="${PORT:-$(remote "sed -n 's/^PORT=//p' $(q "$dir/.env") | tail -1")}"; port="${port:-8787}"
|
||||||
|
if (( dry )); then
|
||||||
|
echo "[dry-run] smoke test on $host: http://127.0.0.1:$port ${tag#v}"
|
||||||
|
else
|
||||||
|
ssh -o BatchMode=yes "$host" "cat > $(q "$dir/smoke-test.sh")" <scripts/smoke-test.sh
|
||||||
|
if ! remote "bash $(q "$dir/smoke-test.sh") http://127.0.0.1:$port ${tag#v}"; then
|
||||||
|
if [[ -n "$current" && "$current" != "$tag" ]]; then
|
||||||
|
echo "smoke test failed – going back to $current" >&2
|
||||||
|
remote "cd $(q "$dir") && sed -i 's/^CODECRAFT_VERSION=.*/CODECRAFT_VERSION=$current/' .env \
|
||||||
|
&& if docker compose version >/dev/null 2>&1; then docker compose up -d; else ./start.sh; fi"
|
||||||
|
fi
|
||||||
|
fail "deploy of $tag failed"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
step "Done"
|
||||||
|
echo "$host runs CodeCraft $tag."
|
||||||
+10
-5
@@ -37,12 +37,15 @@
|
|||||||
# --source-container C container to stop here (default codecraft)
|
# --source-container C container to stop here (default codecraft)
|
||||||
# --source-volume V data volume here (default codecraft-data)
|
# --source-volume V data volume here (default codecraft-data)
|
||||||
# --source-workspace D project folder here (default: WORKSPACE of .env)
|
# --source-workspace D project folder here (default: WORKSPACE of .env)
|
||||||
|
# --exclude PATTERN leave this out of the workspace copy (rsync pattern, relative to the
|
||||||
|
# workspace, e.g. /old-project/ or /app/target/); repeatable
|
||||||
# --no-stop do not stop the source (only for tests – the copy may be inconsistent)
|
# --no-stop do not stop the source (only for tests – the copy may be inconsistent)
|
||||||
# --dry-run show the steps only
|
# --dry-run show the steps only
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
target="" ws_target="" tools_dir="" docker_sock=0 uid="" gid="" port="" bind="" name="codecraft"
|
target="" ws_target="" tools_dir="" docker_sock=0 uid="" gid="" port="" bind="" name="codecraft"
|
||||||
src_container="codecraft" src_volume="codecraft-data" ws_source="" stop=1 dry=0
|
src_container="codecraft" src_volume="codecraft-data" ws_source="" stop=1 dry=0
|
||||||
|
excludes=()
|
||||||
while (($#)); do
|
while (($#)); do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--workspace) ws_target="$2"; shift ;;
|
--workspace) ws_target="$2"; shift ;;
|
||||||
@@ -56,9 +59,10 @@ while (($#)); do
|
|||||||
--source-container) src_container="$2"; shift ;;
|
--source-container) src_container="$2"; shift ;;
|
||||||
--source-volume) src_volume="$2"; shift ;;
|
--source-volume) src_volume="$2"; shift ;;
|
||||||
--source-workspace) ws_source="$2"; shift ;;
|
--source-workspace) ws_source="$2"; shift ;;
|
||||||
|
--exclude) excludes+=(--exclude "$2"); shift ;;
|
||||||
--no-stop) stop=0 ;;
|
--no-stop) stop=0 ;;
|
||||||
--dry-run) dry=1 ;;
|
--dry-run) dry=1 ;;
|
||||||
-h|--help) sed -n '2,36p' "$0"; exit 0 ;;
|
-h|--help) sed -n '2,38p' "$0"; exit 0 ;;
|
||||||
-*) echo "unknown option $1" >&2; exit 2 ;;
|
-*) echo "unknown option $1" >&2; exit 2 ;;
|
||||||
*) target="$1" ;;
|
*) target="$1" ;;
|
||||||
esac
|
esac
|
||||||
@@ -68,7 +72,8 @@ done
|
|||||||
|
|
||||||
cd "$(git rev-parse --show-toplevel)"
|
cd "$(git rev-parse --show-toplevel)"
|
||||||
[[ -f .env ]] || { echo "no .env in $(pwd)" >&2; exit 1; }
|
[[ -f .env ]] || { echo "no .env in $(pwd)" >&2; exit 1; }
|
||||||
env_get() { sed -n "s/^$1=//p" .env | tail -1; }
|
# Value of a .env variable, without surrounding quotes.
|
||||||
|
env_get() { sed -n "s/^$1=//p" .env | tail -1 | sed -E "s/^'(.*)'\$/\1/; s/^\"(.*)\"\$/\1/"; }
|
||||||
|
|
||||||
host="${target%%:*}"
|
host="${target%%:*}"
|
||||||
dir="${target#*:}"
|
dir="${target#*:}"
|
||||||
@@ -93,9 +98,9 @@ remote() {
|
|||||||
}
|
}
|
||||||
q() { printf '%q' "$1"; }
|
q() { printf '%q' "$1"; }
|
||||||
sync_workspace() {
|
sync_workspace() {
|
||||||
if (( dry )); then echo "[dry-run] rsync $ws_source/ -> $host:$ws_target/"; return; fi
|
if (( dry )); then echo "[dry-run] rsync ${excludes[*]} $ws_source/ -> $host:$ws_target/"; return; fi
|
||||||
if [[ "$host" == local ]]; then rsync -a --numeric-ids "$ws_source/" "$ws_target/"
|
if [[ "$host" == local ]]; then rsync -a --numeric-ids "${excludes[@]}" "$ws_source/" "$ws_target/"
|
||||||
else rsync -a --numeric-ids -e "ssh -o BatchMode=yes" "$ws_source/" "$host:$ws_target/"; fi
|
else rsync -a --numeric-ids "${excludes[@]}" -e "ssh -o BatchMode=yes" "$ws_source/" "$host:$ws_target/"; fi
|
||||||
}
|
}
|
||||||
put_file() { # put_file <remote path> < content
|
put_file() { # put_file <remote path> < content
|
||||||
if (( dry )); then echo "[dry-run] write $host:$1"; cat >/dev/null; return; fi
|
if (( dry )); then echo "[dry-run] write $host:$1"; cat >/dev/null; return; fi
|
||||||
|
|||||||
+1
-1
@@ -38,7 +38,7 @@ tokio-tungstenite = "0.29"
|
|||||||
tokio-util = "0.7"
|
tokio-util = "0.7"
|
||||||
toml = "1"
|
toml = "1"
|
||||||
toml_edit = "0.25"
|
toml_edit = "0.25"
|
||||||
tower-http = { version = "0.7", features = ["fs", "cors", "trace"] }
|
tower-http = { version = "0.7", features = ["fs", "cors", "trace", "compression-gzip", "compression-br"] }
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||||
uuid = { version = "1", features = ["v4", "v7"] }
|
uuid = { version = "1", features = ["v4", "v7"] }
|
||||||
|
|||||||
+245
-14
@@ -80,6 +80,7 @@ pub fn router(app: AppState) -> Router {
|
|||||||
.route("/sessions/{id}/worktree", get(worktree_status).post(worktree_action))
|
.route("/sessions/{id}/worktree", get(worktree_status).post(worktree_action))
|
||||||
.route("/sessions/{id}/tabs", get(session_tabs).put(put_file_tabs))
|
.route("/sessions/{id}/tabs", get(session_tabs).put(put_file_tabs))
|
||||||
.route("/sessions/{id}/export", get(export_session))
|
.route("/sessions/{id}/export", get(export_session))
|
||||||
|
.route("/blobs/{hash}", get(get_blob))
|
||||||
.route("/sessions/{id}", get(get_session).patch(update_session).delete(delete_session))
|
.route("/sessions/{id}", get(get_session).patch(update_session).delete(delete_session))
|
||||||
.route("/sessions/{id}/prompt", post(prompt))
|
.route("/sessions/{id}/prompt", post(prompt))
|
||||||
.route("/sessions/{id}/abort", post(abort))
|
.route("/sessions/{id}/abort", post(abort))
|
||||||
@@ -136,6 +137,7 @@ pub fn router(app: AppState) -> Router {
|
|||||||
.route("/images/{id}/file", get(image_file))
|
.route("/images/{id}/file", get(image_file))
|
||||||
.route("/account/git", get(get_git_settings).put(put_git_settings))
|
.route("/account/git", get(get_git_settings).put(put_git_settings))
|
||||||
.route("/account/prefs", get(crate::cli_api::get_prefs).put(crate::cli_api::put_prefs))
|
.route("/account/prefs", get(crate::cli_api::get_prefs).put(crate::cli_api::put_prefs))
|
||||||
|
.route("/account/sidebar", get(crate::cli_api::get_sidebar).put(crate::cli_api::put_sidebar))
|
||||||
.route("/account/memory", get(crate::cli_api::get_memory).put(crate::cli_api::put_memory))
|
.route("/account/memory", get(crate::cli_api::get_memory).put(crate::cli_api::put_memory))
|
||||||
.route("/account/modes", get(get_personal_modes).put(put_personal_modes))
|
.route("/account/modes", get(get_personal_modes).put(put_personal_modes))
|
||||||
.route("/account/telegram", get(get_telegram).put(put_telegram).delete(delete_telegram))
|
.route("/account/telegram", get(get_telegram).put(put_telegram).delete(delete_telegram))
|
||||||
@@ -149,7 +151,9 @@ pub fn router(app: AppState) -> Router {
|
|||||||
.route("/account/tokens/{id}", delete(auth::delete_api_token))
|
.route("/account/tokens/{id}", delete(auth::delete_api_token))
|
||||||
.route("/jobs", get(list_jobs).post(register_job))
|
.route("/jobs", get(list_jobs).post(register_job))
|
||||||
.route("/jobs/create", post(create_job))
|
.route("/jobs/create", post(create_job))
|
||||||
.route("/jobs/{id}", axum::routing::patch(update_job).delete(delete_job))
|
.route("/jobs/schedule", get(schedule_preview))
|
||||||
|
.route("/jobs/{id}", get(get_job).patch(update_job).delete(delete_job))
|
||||||
|
.route("/jobs/{id}/assistant", post(job_assistant))
|
||||||
.route("/jobs/{id}/run", post(run_job))
|
.route("/jobs/{id}/run", post(run_job))
|
||||||
.route("/jobs/{id}/webhook", post(job_webhook_settings))
|
.route("/jobs/{id}/webhook", post(job_webhook_settings))
|
||||||
.route("/terminals", get(list_terminals).post(open_terminal))
|
.route("/terminals", get(list_terminals).post(open_terminal))
|
||||||
@@ -171,8 +175,7 @@ pub fn router(app: AppState) -> Router {
|
|||||||
.find(|p| p.join("index.html").exists())
|
.find(|p| p.join("index.html").exists())
|
||||||
.unwrap_or_else(|| PathBuf::from("./web/dist"))
|
.unwrap_or_else(|| PathBuf::from("./web/dist"))
|
||||||
});
|
});
|
||||||
let index = web_dir.join("index.html");
|
let static_files = tower_http::services::ServeDir::new(&web_dir).fallback(missing_static_file(web_dir.clone()));
|
||||||
let static_files = tower_http::services::ServeDir::new(&web_dir).fallback(tower_http::services::ServeFile::new(index));
|
|
||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
.nest("/api", api)
|
.nest("/api", api)
|
||||||
@@ -181,9 +184,54 @@ pub fn router(app: AppState) -> Router {
|
|||||||
.route("/cli/install.sh", get(cli_install_script))
|
.route("/cli/install.sh", get(cli_install_script))
|
||||||
.route("/cli/download/{target}", get(cli_download))
|
.route("/cli/download/{target}", get(cli_download))
|
||||||
.fallback_service(static_files)
|
.fallback_service(static_files)
|
||||||
|
.layer(compression())
|
||||||
.with_state(app)
|
.with_state(app)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Paths that are not a file of the web build. Usually a client-side route → index.html,
|
||||||
|
/// except for:
|
||||||
|
/// - `/sw-<build>.js` of an earlier build: installed apps check their service worker at
|
||||||
|
/// that URL for updates. Answering with index.html (not a script) makes the update
|
||||||
|
/// fail, so the old worker kept serving the old app shell forever – which broke when
|
||||||
|
/// the API changed. They get the kill switch /sw.js instead: it removes the old worker
|
||||||
|
/// and reloads, and the page then registers the current worker.
|
||||||
|
/// - other missing scripts/styles (old chunks): 404 instead of HTML.
|
||||||
|
fn missing_static_file(web_dir: PathBuf) -> axum::routing::MethodRouter {
|
||||||
|
use axum::http::{header, StatusCode};
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
axum::routing::any(move |uri: axum::http::Uri| {
|
||||||
|
let web_dir = web_dir.clone();
|
||||||
|
async move {
|
||||||
|
let path = uri.path();
|
||||||
|
let name = path.trim_start_matches('/');
|
||||||
|
if name.starts_with("sw-") && name.ends_with(".js") && !name.contains('/') {
|
||||||
|
return match tokio::fs::read(web_dir.join("sw.js")).await {
|
||||||
|
Ok(js) => ([(header::CONTENT_TYPE, "text/javascript"), (header::CACHE_CONTROL, "no-cache, no-store")], js).into_response(),
|
||||||
|
Err(_) => StatusCode::NOT_FOUND.into_response(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if [".js", ".mjs", ".css", ".map"].iter().any(|ext| path.ends_with(ext)) {
|
||||||
|
return StatusCode::NOT_FOUND.into_response();
|
||||||
|
}
|
||||||
|
match tokio::fs::read(web_dir.join("index.html")).await {
|
||||||
|
Ok(html) => ([(header::CONTENT_TYPE, "text/html; charset=utf-8"), (header::CACHE_CONTROL, "no-cache")], html).into_response(),
|
||||||
|
Err(_) => StatusCode::NOT_FOUND.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// gzip/brotli for JSON, HTML, JS and CSS. Images and SSE streams are left alone (default
|
||||||
|
/// predicate), and so are binary downloads (already compressed or large).
|
||||||
|
fn compression() -> tower_http::compression::CompressionLayer<impl tower_http::compression::Predicate + Clone> {
|
||||||
|
use tower_http::compression::predicate::{DefaultPredicate, NotForContentType, Predicate};
|
||||||
|
let predicate = DefaultPredicate::new()
|
||||||
|
.and(NotForContentType::const_new("application/octet-stream"))
|
||||||
|
.and(NotForContentType::const_new("application/zip"))
|
||||||
|
.and(NotForContentType::const_new("application/gzip"));
|
||||||
|
tower_http::compression::CompressionLayer::new().compress_when(predicate)
|
||||||
|
}
|
||||||
|
|
||||||
fn cli_dir() -> PathBuf {
|
fn cli_dir() -> PathBuf {
|
||||||
std::env::var("CODECRAFT_CLI_DIR").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from("/app/cli"))
|
std::env::var("CODECRAFT_CLI_DIR").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from("/app/cli"))
|
||||||
}
|
}
|
||||||
@@ -575,6 +623,8 @@ async fn create_session(Extension(user): Extension<User>, State(app): State<AppS
|
|||||||
archived: false,
|
archived: false,
|
||||||
folder: None,
|
folder: None,
|
||||||
instructions: String::new(),
|
instructions: String::new(),
|
||||||
|
// New chats keep their approvals and questions out of Telegram (⋯ menu turns it on).
|
||||||
|
telegram_quiet: true,
|
||||||
};
|
};
|
||||||
let s = app.save_session(s)?;
|
let s = app.save_session(s)?;
|
||||||
Ok(Json(json!(s)))
|
Ok(Json(json!(s)))
|
||||||
@@ -744,7 +794,8 @@ struct ExportQ {
|
|||||||
/// Download a session as Markdown (default) or JSON.
|
/// Download a session as Markdown (default) or JSON.
|
||||||
async fn export_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Query(q): Query<ExportQ>) -> Result<axum::response::Response, ApiError> {
|
async fn export_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Query(q): Query<ExportQ>) -> Result<axum::response::Response, ApiError> {
|
||||||
let s = readable(&app, &user, &id)?;
|
let s = readable(&app, &user, &id)?;
|
||||||
let messages = app.store.messages(&id)?;
|
// JSON exports are self-contained: images inline instead of store references.
|
||||||
|
let messages = if q.format == "json" { app.store.messages_for_model(&id)? } else { app.store.messages(&id)? };
|
||||||
let base: String = s.title.chars().map(|c| if c.is_alphanumeric() || c == '-' { c } else { '_' }).collect::<String>().trim_matches('_').to_string();
|
let base: String = s.title.chars().map(|c| if c.is_alphanumeric() || c == '-' { c } else { '_' }).collect::<String>().trim_matches('_').to_string();
|
||||||
let base = if base.is_empty() { format!("session-{}", &id[..8.min(id.len())]) } else { base };
|
let base = if base.is_empty() { format!("session-{}", &id[..8.min(id.len())]) } else { base };
|
||||||
let (body, mime, ext) = if q.format == "json" {
|
let (body, mime, ext) = if q.format == "json" {
|
||||||
@@ -785,6 +836,8 @@ struct UpdateSession {
|
|||||||
folder: Option<String>,
|
folder: Option<String>,
|
||||||
/// Instructions for this chat ("" clears them).
|
/// Instructions for this chat ("" clears them).
|
||||||
instructions: Option<String>,
|
instructions: Option<String>,
|
||||||
|
/// Approvals and questions of this chat are not sent to Telegram.
|
||||||
|
telegram_quiet: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn update_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<UpdateSession>) -> ApiResult {
|
async fn update_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<UpdateSession>) -> ApiResult {
|
||||||
@@ -833,6 +886,9 @@ async fn update_session(Extension(user): Extension<User>, State(app): State<AppS
|
|||||||
if let Some(i) = body.instructions {
|
if let Some(i) = body.instructions {
|
||||||
s.instructions = i.trim().to_string();
|
s.instructions = i.trim().to_string();
|
||||||
}
|
}
|
||||||
|
if let Some(q) = body.telegram_quiet {
|
||||||
|
s.telegram_quiet = q;
|
||||||
|
}
|
||||||
})?;
|
})?;
|
||||||
Ok(Json(json!(s)))
|
Ok(Json(json!(s)))
|
||||||
}
|
}
|
||||||
@@ -879,9 +935,33 @@ async fn delete_session(Extension(user): Extension<User>, State(app): State<AppS
|
|||||||
crate::shells::kill_session(&app, &id);
|
crate::shells::kill_session(&app, &id);
|
||||||
app.terminals.kill_session(&id);
|
app.terminals.kill_session(&id);
|
||||||
app.store.delete_session(&id)?;
|
app.store.delete_session(&id)?;
|
||||||
|
// Images only this session used.
|
||||||
|
let a = app.clone();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
if let Err(e) = a.store.gc_blobs() {
|
||||||
|
tracing::warn!("image cleanup: {e:#}");
|
||||||
|
}
|
||||||
|
});
|
||||||
Ok(Json(json!({"ok": true})))
|
Ok(Json(json!({"ok": true})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An image of a chat message (see blobs.rs). Addressed by the SHA-256 of its bytes,
|
||||||
|
/// so it never changes and the browser may keep it.
|
||||||
|
async fn get_blob(Extension(_user): Extension<User>, State(app): State<AppState>, Path(hash): Path<String>) -> Result<axum::response::Response, ApiError> {
|
||||||
|
if !crate::blobs::is_hash(&hash) {
|
||||||
|
return Err(not_found("image"));
|
||||||
|
}
|
||||||
|
let (mime, bytes) = app.store.blob(&hash)?.ok_or_else(|| not_found("image"))?;
|
||||||
|
Ok((
|
||||||
|
[
|
||||||
|
(axum::http::header::CONTENT_TYPE, mime),
|
||||||
|
(axum::http::header::CACHE_CONTROL, "private, max-age=31536000, immutable".to_string()),
|
||||||
|
],
|
||||||
|
bytes,
|
||||||
|
)
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct PromptBody {
|
struct PromptBody {
|
||||||
text: String,
|
text: String,
|
||||||
@@ -1544,15 +1624,24 @@ async fn list_jobs(Extension(user): Extension<User>, State(app): State<AppState>
|
|||||||
Ok(Json(json!(jobs)))
|
Ok(Json(json!(jobs)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_job(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||||
|
let job = own_job(&app, &user, &id)?;
|
||||||
|
Ok(Json(crate::jobs::describe(&job)))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct RegisterJob {
|
struct RegisterJob {
|
||||||
path: String,
|
path: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Register an existing Markdown file (older files: settings from their frontmatter).
|
||||||
async fn register_job(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<RegisterJob>) -> ApiResult {
|
async fn register_job(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<RegisterJob>) -> ApiResult {
|
||||||
let p = crate::files::checked_path(&app, &user, &b.path, true)?;
|
let p = crate::files::checked_path(&app, &user, &b.path, true)?;
|
||||||
crate::jobs::parse(&p).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
|
if !p.is_file() {
|
||||||
|
return Err(ApiError(StatusCode::BAD_REQUEST, format!("{} is not a file", p.display())));
|
||||||
|
}
|
||||||
let job = app.store.job_register(&user.id, &p.display().to_string())?;
|
let job = app.store.job_register(&user.id, &p.display().to_string())?;
|
||||||
|
let job = crate::jobs::adopt(&app.store, job).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
|
||||||
app.audit(Some(&user), "job.register", &job.path, "");
|
app.audit(Some(&user), "job.register", &job.path, "");
|
||||||
Ok(Json(crate::jobs::describe(&job)))
|
Ok(Json(crate::jobs::describe(&job)))
|
||||||
}
|
}
|
||||||
@@ -1561,22 +1650,38 @@ async fn register_job(Extension(user): Extension<User>, State(app): State<AppSta
|
|||||||
struct CreateJob {
|
struct CreateJob {
|
||||||
/// Project folder; the file goes to <cwd>/.codecraft/jobs/<name>.md.
|
/// Project folder; the file goes to <cwd>/.codecraft/jobs/<name>.md.
|
||||||
cwd: String,
|
cwd: String,
|
||||||
#[serde(flatten)]
|
name: String,
|
||||||
job: crate::jobs::NewJob,
|
/// Initial content of the instructions file (example or template).
|
||||||
|
prompt: String,
|
||||||
|
#[serde(default)]
|
||||||
|
schedule: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
agent: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
permission_mode: Option<String>,
|
||||||
|
/// Default false: the user switches it on once prompt and schedule are ready.
|
||||||
|
#[serde(default)]
|
||||||
|
enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_job(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<CreateJob>) -> ApiResult {
|
async fn create_job(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<CreateJob>) -> ApiResult {
|
||||||
let dir = crate::files::checked_path(&app, &user, &b.cwd, true)?;
|
let dir = crate::files::checked_path(&app, &user, &b.cwd, true)?;
|
||||||
if b.job.name.trim().is_empty() || b.job.prompt.trim().is_empty() {
|
if b.prompt.trim().is_empty() {
|
||||||
return Err(ApiError(StatusCode::BAD_REQUEST, "name and prompt are required".into()));
|
return Err(ApiError(StatusCode::BAD_REQUEST, "the instructions are empty".into()));
|
||||||
}
|
}
|
||||||
if !crate::permission::MODES.contains(&b.job.permission_mode.as_str()) {
|
let mut settings = crate::jobs::JobSettings::new(&b.name);
|
||||||
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid permission mode".into()));
|
if let Some(x) = b.schedule {
|
||||||
|
settings.schedule = x;
|
||||||
|
}
|
||||||
|
if let Some(x) = b.agent {
|
||||||
|
settings.agent = x;
|
||||||
|
}
|
||||||
|
if let Some(x) = b.permission_mode {
|
||||||
|
settings.permission_mode = x;
|
||||||
}
|
}
|
||||||
// Inside the checked folder: the file name is a slug of the name.
|
// Inside the checked folder: the file name is a slug of the name.
|
||||||
let path = crate::jobs::new_job_path(&dir, &b.job.name);
|
let path = crate::jobs::new_job_path(&dir, &b.name);
|
||||||
crate::jobs::write_file(&path, &b.job).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
|
let job = crate::jobs::create(&app.store, &user.id, &path, settings, &b.prompt, b.enabled).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
|
||||||
let job = app.store.job_register(&user.id, &path.display().to_string())?;
|
|
||||||
app.audit(Some(&user), "job.create", &job.path, "");
|
app.audit(Some(&user), "job.create", &job.path, "");
|
||||||
Ok(Json(crate::jobs::describe(&job)))
|
Ok(Json(crate::jobs::describe(&job)))
|
||||||
}
|
}
|
||||||
@@ -1584,10 +1689,46 @@ async fn create_job(Extension(user): Extension<User>, State(app): State<AppState
|
|||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct UpdateJob {
|
struct UpdateJob {
|
||||||
enabled: Option<bool>,
|
enabled: Option<bool>,
|
||||||
|
name: Option<String>,
|
||||||
|
schedule: Option<String>,
|
||||||
|
agent: Option<String>,
|
||||||
|
/// "" = the server's default model.
|
||||||
|
model: Option<String>,
|
||||||
|
permission_mode: Option<String>,
|
||||||
|
timeout_minutes: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn update_job(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(b): Json<UpdateJob>) -> ApiResult {
|
async fn update_job(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(b): Json<UpdateJob>) -> ApiResult {
|
||||||
let job = own_job(&app, &user, &id)?;
|
let job = own_job(&app, &user, &id)?;
|
||||||
|
let job = crate::jobs::adopt(&app.store, job).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
|
||||||
|
let changes = b.name.is_some() || b.schedule.is_some() || b.agent.is_some() || b.model.is_some() || b.permission_mode.is_some() || b.timeout_minutes.is_some();
|
||||||
|
if changes {
|
||||||
|
let mut st = job.settings.clone().unwrap_or_else(|| crate::jobs::JobSettings::new("automation"));
|
||||||
|
if let Some(x) = b.name {
|
||||||
|
st.name = x;
|
||||||
|
}
|
||||||
|
if let Some(x) = b.schedule {
|
||||||
|
st.schedule = x;
|
||||||
|
}
|
||||||
|
if let Some(x) = b.agent {
|
||||||
|
st.agent = x;
|
||||||
|
}
|
||||||
|
if let Some(x) = b.model {
|
||||||
|
if !x.is_empty() {
|
||||||
|
app.registry().resolve(&x)?;
|
||||||
|
}
|
||||||
|
st.model = Some(x);
|
||||||
|
}
|
||||||
|
if let Some(x) = b.permission_mode {
|
||||||
|
st.permission_mode = x;
|
||||||
|
}
|
||||||
|
if let Some(x) = b.timeout_minutes {
|
||||||
|
st.timeout_minutes = x;
|
||||||
|
}
|
||||||
|
st.validate().map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
|
||||||
|
app.store.job_set_settings(&job.id, &st)?;
|
||||||
|
app.audit(Some(&user), "job.settings", &job.path, &st.schedule);
|
||||||
|
}
|
||||||
if let Some(e) = b.enabled {
|
if let Some(e) = b.enabled {
|
||||||
app.store.job_set_enabled(&job.id, e)?;
|
app.store.job_set_enabled(&job.id, e)?;
|
||||||
}
|
}
|
||||||
@@ -1595,6 +1736,96 @@ async fn update_job(Extension(user): Extension<User>, State(app): State<AppState
|
|||||||
Ok(Json(crate::jobs::describe(&job)))
|
Ok(Json(crate::jobs::describe(&job)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Chat in which the user works out the instructions with a model (created once, reused
|
||||||
|
/// while it exists). The model edits the job file directly.
|
||||||
|
async fn job_assistant(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||||
|
let job = own_job(&app, &user, &id)?;
|
||||||
|
let job = crate::jobs::adopt(&app.store, job).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
|
||||||
|
let mut st = job.settings.clone().ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "automation without settings".into()))?;
|
||||||
|
if let Some(sid) = st.assistant_session.as_deref() {
|
||||||
|
if app.store.get_session(sid)?.is_some_and(|s| s.owner_id.as_deref() == Some(user.id.as_str())) {
|
||||||
|
return Ok(Json(json!({"session_id": sid})));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let path = std::path::Path::new(&job.path);
|
||||||
|
let cwd = crate::jobs::cwd_of(&job.path, st.cwd.as_deref());
|
||||||
|
let cfg = app.config();
|
||||||
|
let model = st
|
||||||
|
.model
|
||||||
|
.clone()
|
||||||
|
.or_else(|| Some(cfg.server.default_model.clone()).filter(|m| !m.is_empty()))
|
||||||
|
.or_else(|| app.registry().first_model())
|
||||||
|
.ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "no model configured – ask an admin to add a provider".into()))?;
|
||||||
|
let now = now_ms();
|
||||||
|
let instructions = format!(
|
||||||
|
"You help the user write the instructions of the automation \"{name}\". They live in the Markdown file {file} \
|
||||||
|
and are executed unattended by an agent (mode {agent}) on a schedule the user sets in the UI. That run starts a \
|
||||||
|
fresh session and sees only this file – no conversation, nobody to ask.\n\
|
||||||
|
- Work on that file directly with your edit tools and keep it up to date after every round; the user sees it next to this chat.\n\
|
||||||
|
- Find out what the automation should achieve, then shape the file into clear, self-contained instructions: \
|
||||||
|
goal, steps, expected result (e.g. a short report), limits (what must not be done).\n\
|
||||||
|
- Write only the instructions: no frontmatter, no schedule or settings in the file.\n\
|
||||||
|
- Do not carry out the automation's task yourself and do not create other files. Use the language the user writes in.",
|
||||||
|
name = st.name,
|
||||||
|
file = path.display(),
|
||||||
|
agent = st.agent,
|
||||||
|
);
|
||||||
|
let s = Session {
|
||||||
|
id: new_id(),
|
||||||
|
title: format!("{} – {}", st.name, if crate::telegram::german(&app, &user.id) { "Anweisungen" } else { "instructions" }),
|
||||||
|
cwd: cwd.display().to_string(),
|
||||||
|
model,
|
||||||
|
agent: "build".into(),
|
||||||
|
permission_mode: "auto".into(),
|
||||||
|
parent_id: None,
|
||||||
|
owner_id: Some(user.id.clone()),
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
todos: Value::Null,
|
||||||
|
last_usage: None,
|
||||||
|
clean_room: None,
|
||||||
|
phase: None,
|
||||||
|
worktree: None,
|
||||||
|
pinned: false,
|
||||||
|
archived: false,
|
||||||
|
folder: None,
|
||||||
|
instructions,
|
||||||
|
telegram_quiet: true,
|
||||||
|
};
|
||||||
|
let s = app.save_session(s)?;
|
||||||
|
st.assistant_session = Some(s.id.clone());
|
||||||
|
app.store.job_set_settings(&job.id, &st)?;
|
||||||
|
Ok(Json(json!({"session_id": s.id})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ScheduleQ {
|
||||||
|
expr: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check a cron expression and list its next runs (for the schedule editor).
|
||||||
|
async fn schedule_preview(Query(q): Query<ScheduleQ>) -> ApiResult {
|
||||||
|
let expr = q.expr.trim();
|
||||||
|
if let Err(e) = crate::jobs::check_schedule(expr) {
|
||||||
|
return Ok(Json(json!({"ok": false, "error": format!("{e:#}")})));
|
||||||
|
}
|
||||||
|
let mut next = Vec::new();
|
||||||
|
let mut t = now_ms();
|
||||||
|
for _ in 0..5 {
|
||||||
|
match crate::jobs::next_run(expr, t) {
|
||||||
|
Some(n) => {
|
||||||
|
next.push(n);
|
||||||
|
t = n + 1000;
|
||||||
|
}
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Wall-clock times of the server: cron works in its time zone, the browser may be elsewhere.
|
||||||
|
let local: Vec<String> = next.iter().filter_map(|n| chrono::DateTime::from_timestamp_millis(*n)).map(|t| t.with_timezone(&chrono::Local).format("%Y-%m-%dT%H:%M").to_string()).collect();
|
||||||
|
let tz = std::env::var("TZ").ok().filter(|z| !z.is_empty()).unwrap_or_else(|| chrono::Local::now().format("UTC%:z").to_string());
|
||||||
|
Ok(Json(json!({"ok": true, "next": next, "next_local": local, "tz": tz})))
|
||||||
|
}
|
||||||
|
|
||||||
async fn delete_job(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
async fn delete_job(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||||
let job = own_job(&app, &user, &id)?;
|
let job = own_job(&app, &user, &id)?;
|
||||||
app.store.job_delete(&job.id)?;
|
app.store.job_delete(&job.id)?;
|
||||||
|
|||||||
@@ -131,9 +131,32 @@ impl App {
|
|||||||
});
|
});
|
||||||
crate::auth::bootstrap(&app)?;
|
crate::auth::bootstrap(&app)?;
|
||||||
app.clone().start_mcp();
|
app.clone().start_mcp();
|
||||||
|
app.clone().start_image_migration();
|
||||||
Ok(app)
|
Ok(app)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Move inline images of older chats to the image store, drop unused images and
|
||||||
|
/// shrink the database file (in the background; chats load meanwhile as before).
|
||||||
|
fn start_image_migration(self: Arc<Self>) {
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
match self.store.migrate_inline_images() {
|
||||||
|
Ok(0) => {}
|
||||||
|
Ok(n) => tracing::info!("moved {n} images of older chats to the image store"),
|
||||||
|
Err(e) => tracing::warn!("moving images to the image store: {e:#}"),
|
||||||
|
}
|
||||||
|
match self.store.gc_blobs() {
|
||||||
|
Ok(0) => {}
|
||||||
|
Ok(n) => tracing::info!("removed {n} unused images"),
|
||||||
|
Err(e) => tracing::warn!("image cleanup: {e:#}"),
|
||||||
|
}
|
||||||
|
match self.store.vacuum_if_worthwhile() {
|
||||||
|
Ok(true) => tracing::info!("database compacted"),
|
||||||
|
Ok(false) => {}
|
||||||
|
Err(e) => tracing::warn!("database compaction: {e:#}"),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
fn start_mcp(self: Arc<Self>) {
|
fn start_mcp(self: Arc<Self>) {
|
||||||
let servers = self.config().mcp.clone();
|
let servers = self.config().mcp.clone();
|
||||||
let mcp = self.mcp.clone();
|
let mcp = self.mcp.clone();
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
//! Images of chat messages are stored in the `blobs` table instead of as base64 inside the
|
||||||
|
//! message JSON. A chat then loads without its images; the UI fetches them on demand from
|
||||||
|
//! /api/blobs/<hash> (cached by the browser), and the runner puts the bytes back in before
|
||||||
|
//! a model request. Large PNGs without transparency are stored as JPEG.
|
||||||
|
|
||||||
|
use crate::types::{ImageData, Message, Part};
|
||||||
|
use base64::Engine;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
const B64: base64::engine::GeneralPurpose = base64::engine::general_purpose::STANDARD;
|
||||||
|
|
||||||
|
/// PNGs above this size are re-encoded as JPEG when that is smaller and loses no transparency.
|
||||||
|
const RECOMPRESS_ABOVE: usize = 100 * 1024;
|
||||||
|
const JPEG_QUALITY: u8 = 85;
|
||||||
|
|
||||||
|
/// An image to put into the store.
|
||||||
|
pub struct Blob {
|
||||||
|
pub hash: String,
|
||||||
|
pub media_type: String,
|
||||||
|
pub bytes: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Call `f` for every image of a message (pasted images and tool results).
|
||||||
|
pub fn for_each_image(m: &mut Message, mut f: impl FnMut(&mut ImageData)) {
|
||||||
|
for part in &mut m.parts {
|
||||||
|
match part {
|
||||||
|
Part::Image { image } => f(image),
|
||||||
|
Part::ToolResult { images, .. } => images.iter_mut().for_each(&mut f),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move the inline images of `m` out: each becomes a reference (`blob`, `size`, no
|
||||||
|
/// `data`). Returns the blobs to store. Images that already are references stay as they are.
|
||||||
|
pub fn externalize(m: &mut Message) -> Vec<Blob> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for_each_image(m, |img| {
|
||||||
|
if img.data.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Ok(raw) = B64.decode(img.data.as_bytes()) else { return };
|
||||||
|
let (media_type, bytes) = compact(&img.media_type, raw);
|
||||||
|
let hash = hash(&bytes);
|
||||||
|
img.media_type = media_type.clone();
|
||||||
|
img.data.clear();
|
||||||
|
img.blob = Some(hash.clone());
|
||||||
|
img.size = Some(bytes.len() as u64);
|
||||||
|
out.push(Blob { hash, media_type, bytes });
|
||||||
|
});
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `s` looks like a blob hash (64 lowercase hex digits).
|
||||||
|
pub fn is_hash(s: &str) -> bool {
|
||||||
|
s.len() == 64 && s.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hash(bytes: &[u8]) -> String {
|
||||||
|
Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-encode a large opaque PNG as JPEG if that is smaller; anything else is kept.
|
||||||
|
fn compact(media_type: &str, raw: Vec<u8>) -> (String, Vec<u8>) {
|
||||||
|
if media_type != "image/png" || raw.len() <= RECOMPRESS_ABOVE {
|
||||||
|
return (media_type.to_string(), raw);
|
||||||
|
}
|
||||||
|
let Ok(img) = image::load_from_memory_with_format(&raw, image::ImageFormat::Png) else {
|
||||||
|
return (media_type.to_string(), raw);
|
||||||
|
};
|
||||||
|
if img.color().has_alpha() && img.to_rgba8().pixels().any(|p| p.0[3] != 255) {
|
||||||
|
return (media_type.to_string(), raw);
|
||||||
|
}
|
||||||
|
let mut jpeg = Vec::new();
|
||||||
|
let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg, JPEG_QUALITY);
|
||||||
|
if img.to_rgb8().write_with_encoder(enc).is_err() || jpeg.len() >= raw.len() {
|
||||||
|
return (media_type.to_string(), raw);
|
||||||
|
}
|
||||||
|
("image/jpeg".to_string(), jpeg)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::types::Role;
|
||||||
|
|
||||||
|
fn png(w: u32, h: u32, alpha: u8) -> Vec<u8> {
|
||||||
|
// Noise, so the PNG is large and JPEG wins.
|
||||||
|
let img = image::RgbaImage::from_fn(w, h, |x, y| {
|
||||||
|
let v = (x.wrapping_mul(7919) ^ y.wrapping_mul(104_729)).wrapping_mul(2_654_435_761) >> 24;
|
||||||
|
image::Rgba([v as u8, (v >> 1) as u8, (v >> 2) as u8, alpha])
|
||||||
|
});
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png).unwrap();
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
|
||||||
|
fn message(img: ImageData) -> Message {
|
||||||
|
let mut m = Message { id: "m1".into(), role: Role::User, parts: vec![], model: None, usage: None, created_at: 0 };
|
||||||
|
m.parts.push(Part::ToolResult { call_id: "c".into(), name: "browser".into(), content: String::new(), images: vec![img], is_error: false, meta: None });
|
||||||
|
m
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn large_opaque_png_becomes_a_jpeg_reference() {
|
||||||
|
let raw = png(400, 400, 255);
|
||||||
|
let mut m = message(ImageData::new("image/png", B64.encode(&raw)));
|
||||||
|
let blobs = externalize(&mut m);
|
||||||
|
assert_eq!(blobs.len(), 1);
|
||||||
|
assert_eq!(blobs[0].media_type, "image/jpeg");
|
||||||
|
assert!(blobs[0].bytes.len() < raw.len());
|
||||||
|
let Part::ToolResult { images, .. } = &m.parts[0] else { panic!() };
|
||||||
|
assert!(images[0].data.is_empty());
|
||||||
|
assert_eq!(images[0].blob.as_deref(), Some(blobs[0].hash.as_str()));
|
||||||
|
assert_eq!(images[0].size, Some(blobs[0].bytes.len() as u64));
|
||||||
|
assert!(is_hash(&blobs[0].hash));
|
||||||
|
// Already a reference: nothing to do.
|
||||||
|
assert!(externalize(&mut m).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transparent_and_small_pngs_stay_png() {
|
||||||
|
let mut m = message(ImageData::new("image/png", B64.encode(png(400, 400, 128))));
|
||||||
|
assert_eq!(externalize(&mut m)[0].media_type, "image/png");
|
||||||
|
let mut m = message(ImageData::new("image/png", B64.encode(png(8, 8, 255))));
|
||||||
|
assert_eq!(externalize(&mut m)[0].media_type, "image/png");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serialized_reference_has_no_data() {
|
||||||
|
let img = ImageData { media_type: "image/jpeg".into(), data: String::new(), blob: Some("ab".into()), size: Some(3) };
|
||||||
|
assert_eq!(serde_json::to_value(&img).unwrap(), serde_json::json!({"media_type": "image/jpeg", "blob": "ab", "size": 3}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -231,6 +231,23 @@ pub async fn put_prefs(Extension(user): Extension<User>, State(app): State<AppSt
|
|||||||
Ok(Json(b))
|
Ok(Json(b))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Order and pinning of the groups (folders, projects) in the chat list – per user, so
|
||||||
|
/// every device shows the same order: {"order": [group ids], "pinned": [group ids]}.
|
||||||
|
pub async fn get_sidebar(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
||||||
|
let v: Value = app.store.user_setting(&user.id, "sidebar")?;
|
||||||
|
Ok(Json(if v.is_null() { json!({"order": [], "pinned": []}) } else { v }))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn put_sidebar(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<Value>) -> ApiResult {
|
||||||
|
let ids = |k: &str| b[k].as_array().is_some_and(|a| a.iter().all(|x| x.as_str().is_some_and(|s| s.len() <= 1000)));
|
||||||
|
if !ids("order") || !ids("pinned") || b.to_string().len() > 100_000 {
|
||||||
|
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid chat list layout".into()));
|
||||||
|
}
|
||||||
|
let v = json!({"order": b["order"], "pinned": b["pinned"]});
|
||||||
|
app.store.set_user_setting(&user.id, "sidebar", &v)?;
|
||||||
|
Ok(Json(v))
|
||||||
|
}
|
||||||
|
|
||||||
/// Personal memory (applies to all projects, web and CLI).
|
/// Personal memory (applies to all projects, web and CLI).
|
||||||
pub async fn get_memory(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
pub async fn get_memory(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
||||||
let m: String = app.store.user_setting(&user.id, "memory")?;
|
let m: String = app.store.user_setting(&user.id, "memory")?;
|
||||||
|
|||||||
+1
-1
@@ -129,7 +129,7 @@ async fn render_page(pdf: &str, page: u32) -> Result<ImageData> {
|
|||||||
let img = img.resize(1568, 1568, image::imageops::FilterType::Triangle);
|
let img = img.resize(1568, 1568, image::imageops::FilterType::Triangle);
|
||||||
let mut buf = Vec::new();
|
let mut buf = Vec::new();
|
||||||
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)?;
|
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)?;
|
||||||
Ok(ImageData { media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(buf) })
|
Ok(ImageData { blob: None, size: None, media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(buf) })
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spreadsheet(path: &Path, sheet: Option<&str>) -> Result<String> {
|
fn spreadsheet(path: &Path, sheet: Option<&str>) -> Result<String> {
|
||||||
|
|||||||
@@ -258,7 +258,7 @@ fn preview_for_model(path: &Path) -> Option<ImageData> {
|
|||||||
let img = img.resize(1024, 1024, image::imageops::FilterType::Triangle);
|
let img = img.resize(1024, 1024, image::imageops::FilterType::Triangle);
|
||||||
let mut buf = Vec::new();
|
let mut buf = Vec::new();
|
||||||
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png).ok()?;
|
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png).ok()?;
|
||||||
Some(ImageData { media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(buf) })
|
Some(ImageData { blob: None, size: None, media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(buf) })
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct GenerateImage;
|
pub struct GenerateImage;
|
||||||
|
|||||||
+231
-63
@@ -1,20 +1,23 @@
|
|||||||
//! Scheduled agents ("automations"): a Markdown file with a cron schedule in
|
//! Scheduled agents ("automations"). The instructions are a Markdown file (only the
|
||||||
//! its frontmatter starts an agent session at the given times.
|
//! prompt, e.g. `<project>/.codecraft/jobs/<name>.md`); name, schedule, mode, model and
|
||||||
|
//! limits are settings of the automation in the database, edited in the UI.
|
||||||
|
//!
|
||||||
|
//! Older job files carried the settings as frontmatter:
|
||||||
//!
|
//!
|
||||||
//! ```markdown
|
//! ```markdown
|
||||||
//! ---
|
//! ---
|
||||||
//! name: Nightly dependency check
|
//! name: Nightly dependency check
|
||||||
//! schedule: "0 3 * * *" # cron (minute hour day month weekday), server time zone
|
//! schedule: "0 3 * * *" # cron (minute hour day month weekday), server time zone
|
||||||
//! agent: build # mode (default build)
|
//! agent: build
|
||||||
//! model: provider/model # optional, default: server default model
|
//! permission_mode: auto
|
||||||
//! permission_mode: auto # default auto; "bypass" runs without asking
|
//! timeout_minutes: 60
|
||||||
//! timeout_minutes: 60 # optional, default 60
|
|
||||||
//! ---
|
//! ---
|
||||||
//! Check for outdated dependencies and open a summary …
|
//! Check for outdated dependencies and open a summary …
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! The file is re-read on every run, so edits apply to the next run. Each run
|
//! They are adopted once (settings into the database, the frontmatter removed from the
|
||||||
//! is a normal chat session owned by the job's owner.
|
//! file). The file is re-read on every run, so edits apply to the next run. Each run is
|
||||||
|
//! a normal chat session owned by the job's owner.
|
||||||
|
|
||||||
use crate::app::App;
|
use crate::app::App;
|
||||||
use crate::tools::{Tool, ToolCtx, ToolOutput, arg_str};
|
use crate::tools::{Tool, ToolCtx, ToolOutput, arg_str};
|
||||||
@@ -40,6 +43,70 @@ pub struct Job {
|
|||||||
pub created_at: i64,
|
pub created_at: i64,
|
||||||
/// Webhook enabled (the secret – also the HMAC key – is only shown once when created).
|
/// Webhook enabled (the secret – also the HMAC key – is only shown once when created).
|
||||||
pub webhook: bool,
|
pub webhook: bool,
|
||||||
|
/// None: not adopted yet (settings still in the file's frontmatter).
|
||||||
|
#[serde(skip)]
|
||||||
|
pub settings: Option<JobSettings>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Settings of an automation (database; the file holds only the prompt).
|
||||||
|
#[derive(Debug, Clone, Serialize, serde::Deserialize, PartialEq)]
|
||||||
|
pub struct JobSettings {
|
||||||
|
pub name: String,
|
||||||
|
/// 5-field cron in the server's time zone.
|
||||||
|
pub schedule: String,
|
||||||
|
#[serde(default = "default_agent")]
|
||||||
|
pub agent: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub model: Option<String>,
|
||||||
|
#[serde(default = "default_mode")]
|
||||||
|
pub permission_mode: String,
|
||||||
|
#[serde(default = "default_timeout")]
|
||||||
|
pub timeout_minutes: u64,
|
||||||
|
/// Working directory (absolute or relative to the file); default: the project of the file.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub cwd: Option<String>,
|
||||||
|
/// Chat in which the user works out the prompt with a model.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub assistant_session: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JobSettings {
|
||||||
|
pub fn new(name: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
name: name.trim().to_string(),
|
||||||
|
schedule: "0 3 * * *".into(),
|
||||||
|
agent: default_agent(),
|
||||||
|
model: None,
|
||||||
|
permission_mode: default_mode(),
|
||||||
|
timeout_minutes: default_timeout(),
|
||||||
|
cwd: None,
|
||||||
|
assistant_session: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate(&mut self) -> Result<()> {
|
||||||
|
self.name = self.name.trim().chars().take(120).collect();
|
||||||
|
if self.name.is_empty() {
|
||||||
|
bail!("the automation needs a name");
|
||||||
|
}
|
||||||
|
self.schedule = self.schedule.trim().to_string();
|
||||||
|
check_schedule(&self.schedule)?;
|
||||||
|
if !crate::permission::MODES.contains(&self.permission_mode.as_str()) {
|
||||||
|
bail!("invalid permission_mode '{}'", self.permission_mode);
|
||||||
|
}
|
||||||
|
self.model = self.model.take().map(|m| m.trim().to_string()).filter(|m| !m.is_empty());
|
||||||
|
self.timeout_minutes = self.timeout_minutes.clamp(1, 24 * 60);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Valid 5-field cron expression?
|
||||||
|
pub fn check_schedule(schedule: &str) -> Result<()> {
|
||||||
|
if schedule.split_whitespace().count() != 5 {
|
||||||
|
bail!("invalid schedule '{schedule}': expected 5 fields (minute hour day month weekday)");
|
||||||
|
}
|
||||||
|
croner::Cron::from_str(schedule).map_err(|e| anyhow!("invalid schedule '{schedule}': {e}"))?;
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parsed job file.
|
/// Parsed job file.
|
||||||
@@ -52,15 +119,89 @@ pub struct JobSpec {
|
|||||||
pub permission_mode: String,
|
pub permission_mode: String,
|
||||||
pub timeout_minutes: u64,
|
pub timeout_minutes: u64,
|
||||||
pub cwd: Option<String>,
|
pub cwd: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub assistant_session: Option<String>,
|
||||||
pub prompt: String,
|
pub prompt: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl JobSpec {
|
||||||
|
fn from_settings(s: &JobSettings, prompt: String) -> Self {
|
||||||
|
Self {
|
||||||
|
name: s.name.clone(),
|
||||||
|
schedule: s.schedule.clone(),
|
||||||
|
agent: s.agent.clone(),
|
||||||
|
model: s.model.clone(),
|
||||||
|
permission_mode: s.permission_mode.clone(),
|
||||||
|
timeout_minutes: s.timeout_minutes,
|
||||||
|
cwd: s.cwd.clone(),
|
||||||
|
assistant_session: s.assistant_session.clone(),
|
||||||
|
prompt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn settings(&self) -> JobSettings {
|
||||||
|
JobSettings {
|
||||||
|
name: self.name.clone(),
|
||||||
|
schedule: self.schedule.clone(),
|
||||||
|
agent: self.agent.clone(),
|
||||||
|
model: self.model.clone(),
|
||||||
|
permission_mode: self.permission_mode.clone(),
|
||||||
|
timeout_minutes: self.timeout_minutes,
|
||||||
|
cwd: self.cwd.clone(),
|
||||||
|
assistant_session: self.assistant_session.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Settings and prompt of a job: settings from the database, the prompt from its file
|
||||||
|
/// (a leftover frontmatter is ignored); not yet adopted jobs from the frontmatter.
|
||||||
|
pub fn spec(job: &Job) -> Result<JobSpec> {
|
||||||
|
let path = Path::new(&job.path);
|
||||||
|
let Some(settings) = &job.settings else { return parse(path) };
|
||||||
|
let text = std::fs::read_to_string(path).with_context(|| format!("cannot read {}", path.display()))?;
|
||||||
|
let (_, body) = crate::prompts::frontmatter(&text);
|
||||||
|
if body.trim().is_empty() {
|
||||||
|
bail!("{}: the instructions are empty", path.display());
|
||||||
|
}
|
||||||
|
Ok(JobSpec::from_settings(settings, body.trim().to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move the frontmatter of an older job file into the database and remove it from the
|
||||||
|
/// file. Files without frontmatter get default settings (named after the file) and are
|
||||||
|
/// switched off until the user sets a schedule. Returns the updated job.
|
||||||
|
pub fn adopt(store: &crate::store::Store, job: Job) -> Result<Job> {
|
||||||
|
if job.settings.is_some() {
|
||||||
|
return Ok(job);
|
||||||
|
}
|
||||||
|
let path = Path::new(&job.path);
|
||||||
|
let text = std::fs::read_to_string(path).with_context(|| format!("cannot read {}", path.display()))?;
|
||||||
|
let (meta, body) = crate::prompts::frontmatter(&text);
|
||||||
|
let settings = if meta.is_empty() {
|
||||||
|
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("automation").replace(['-', '_'], " ");
|
||||||
|
store.job_set_enabled(&job.id, false)?;
|
||||||
|
JobSettings::new(&stem)
|
||||||
|
} else {
|
||||||
|
let spec = parse(path)?;
|
||||||
|
// Prompt only from now on.
|
||||||
|
std::fs::write(path, format!("{}\n", body.trim()))?;
|
||||||
|
spec.settings()
|
||||||
|
};
|
||||||
|
store.job_set_settings(&job.id, &settings)?;
|
||||||
|
store.job(&job.id)?.ok_or_else(|| anyhow!("automation {} vanished", job.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Settings from the frontmatter of an older job file.
|
||||||
pub fn parse(path: &Path) -> Result<JobSpec> {
|
pub fn parse(path: &Path) -> Result<JobSpec> {
|
||||||
let text = std::fs::read_to_string(path).with_context(|| format!("cannot read {}", path.display()))?;
|
let text = std::fs::read_to_string(path).with_context(|| format!("cannot read {}", path.display()))?;
|
||||||
let (meta, body) = crate::prompts::frontmatter(&text);
|
let (meta, body) = crate::prompts::frontmatter(&text);
|
||||||
let unquote = |s: &String| s.trim().trim_matches('"').trim_matches('\'').to_string();
|
// Values may carry a trailing comment (`auto # default`).
|
||||||
|
let unquote = |s: &String| {
|
||||||
|
let v = s.trim();
|
||||||
|
let v = if v.starts_with('"') || v.starts_with('\'') { v } else { v.split(" #").next().unwrap_or(v).trim() };
|
||||||
|
v.trim_matches('"').trim_matches('\'').to_string()
|
||||||
|
};
|
||||||
let schedule = meta.get("schedule").map(unquote).ok_or_else(|| anyhow!("{}: frontmatter needs `schedule: \"<cron>\"`", path.display()))?;
|
let schedule = meta.get("schedule").map(unquote).ok_or_else(|| anyhow!("{}: frontmatter needs `schedule: \"<cron>\"`", path.display()))?;
|
||||||
croner::Cron::from_str(&schedule).map_err(|e| anyhow!("invalid schedule '{schedule}': {e}"))?;
|
check_schedule(&schedule)?;
|
||||||
let permission_mode = meta.get("permission_mode").map(unquote).unwrap_or_else(|| "auto".into());
|
let permission_mode = meta.get("permission_mode").map(unquote).unwrap_or_else(|| "auto".into());
|
||||||
if !crate::permission::MODES.contains(&permission_mode.as_str()) {
|
if !crate::permission::MODES.contains(&permission_mode.as_str()) {
|
||||||
bail!("invalid permission_mode '{permission_mode}'");
|
bail!("invalid permission_mode '{permission_mode}'");
|
||||||
@@ -76,6 +217,7 @@ pub fn parse(path: &Path) -> Result<JobSpec> {
|
|||||||
permission_mode,
|
permission_mode,
|
||||||
timeout_minutes: meta.get("timeout_minutes").and_then(|v| unquote(v).parse().ok()).unwrap_or(60).clamp(1, 24 * 60),
|
timeout_minutes: meta.get("timeout_minutes").and_then(|v| unquote(v).parse().ok()).unwrap_or(60).clamp(1, 24 * 60),
|
||||||
cwd: meta.get("cwd").map(unquote).filter(|c| !c.is_empty()),
|
cwd: meta.get("cwd").map(unquote).filter(|c| !c.is_empty()),
|
||||||
|
assistant_session: None,
|
||||||
prompt: body.trim().to_string(),
|
prompt: body.trim().to_string(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -89,7 +231,13 @@ pub fn next_run(schedule: &str, after_ms: i64) -> Option<i64> {
|
|||||||
|
|
||||||
/// Working directory of a job: `cwd` from the file, else the project containing `.codecraft/jobs/`.
|
/// Working directory of a job: `cwd` from the file, else the project containing `.codecraft/jobs/`.
|
||||||
fn job_cwd(path: &Path, spec: &JobSpec) -> PathBuf {
|
fn job_cwd(path: &Path, spec: &JobSpec) -> PathBuf {
|
||||||
if let Some(c) = &spec.cwd {
|
cwd_of(&path.display().to_string(), spec.cwd.as_deref())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Working directory for a job file and its optional `cwd` setting.
|
||||||
|
pub fn cwd_of(file: &str, cwd: Option<&str>) -> PathBuf {
|
||||||
|
let path = Path::new(file);
|
||||||
|
if let Some(c) = cwd {
|
||||||
let p = PathBuf::from(c);
|
let p = PathBuf::from(c);
|
||||||
return if p.is_absolute() { p } else { path.parent().unwrap_or(Path::new("/")).join(p) };
|
return if p.is_absolute() { p } else { path.parent().unwrap_or(Path::new("/")).join(p) };
|
||||||
}
|
}
|
||||||
@@ -106,13 +254,19 @@ fn job_cwd(path: &Path, spec: &JobSpec) -> PathBuf {
|
|||||||
/// Job with its parsed file and next run, for the UI and the tool.
|
/// Job with its parsed file and next run, for the UI and the tool.
|
||||||
pub fn describe(job: &Job) -> Value {
|
pub fn describe(job: &Job) -> Value {
|
||||||
let mut v = json!(job);
|
let mut v = json!(job);
|
||||||
match parse(Path::new(&job.path)) {
|
match spec(job) {
|
||||||
Ok(spec) => {
|
Ok(spec) => {
|
||||||
v["spec"] = json!(spec);
|
v["spec"] = json!(spec);
|
||||||
let base = job.last_run.unwrap_or(job.created_at).max(now_ms() - 60_000);
|
let base = job.last_run.unwrap_or(job.created_at).max(now_ms() - 60_000);
|
||||||
v["next_run"] = json!(if job.enabled { next_run(&spec.schedule, base) } else { None });
|
v["next_run"] = json!(if job.enabled { next_run(&spec.schedule, base) } else { None });
|
||||||
}
|
}
|
||||||
Err(e) => v["error"] = json!(format!("{e:#}")),
|
Err(e) => {
|
||||||
|
v["error"] = json!(format!("{e:#}"));
|
||||||
|
// The settings stay editable when the file is missing or empty.
|
||||||
|
if let Some(st) = &job.settings {
|
||||||
|
v["spec"] = json!(JobSpec::from_settings(st, String::new()));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
v
|
v
|
||||||
}
|
}
|
||||||
@@ -125,7 +279,7 @@ pub async fn run(app: &Arc<App>, job: &Job, trigger: &str) -> Result<String> {
|
|||||||
/// Start a run; `payload` (webhook body) is appended to the instructions.
|
/// Start a run; `payload` (webhook body) is appended to the instructions.
|
||||||
pub async fn run_with(app: &Arc<App>, job: &Job, trigger: &str, payload: Option<String>) -> Result<String> {
|
pub async fn run_with(app: &Arc<App>, job: &Job, trigger: &str, payload: Option<String>) -> Result<String> {
|
||||||
let path = PathBuf::from(&job.path);
|
let path = PathBuf::from(&job.path);
|
||||||
let spec = match parse(&path) {
|
let spec = match spec(job) {
|
||||||
Ok(s) => s,
|
Ok(s) => s,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
app.store.job_finished(&job.id, None, &format!("error: {e:#}"))?;
|
app.store.job_finished(&job.id, None, &format!("error: {e:#}"))?;
|
||||||
@@ -165,6 +319,8 @@ pub async fn run_with(app: &Arc<App>, job: &Job, trigger: &str, payload: Option<
|
|||||||
archived: false,
|
archived: false,
|
||||||
folder: None,
|
folder: None,
|
||||||
instructions: String::new(),
|
instructions: String::new(),
|
||||||
|
// New chats keep their approvals and questions out of Telegram (⋯ menu turns it on).
|
||||||
|
telegram_quiet: true,
|
||||||
};
|
};
|
||||||
let session = app.save_session(session)?;
|
let session = app.save_session(session)?;
|
||||||
app.store.job_started(&job.id, &session.id, now)?;
|
app.store.job_started(&job.id, &session.id, now)?;
|
||||||
@@ -202,12 +358,20 @@ pub async fn run_with(app: &Arc<App>, job: &Job, trigger: &str, payload: Option<
|
|||||||
/// Background scheduler: checks every 30 s which jobs are due.
|
/// Background scheduler: checks every 30 s which jobs are due.
|
||||||
pub fn spawn_scheduler(app: Arc<App>) {
|
pub fn spawn_scheduler(app: Arc<App>) {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
|
// Older job files: settings from the frontmatter into the database.
|
||||||
|
for job in app.store.jobs(None).unwrap_or_default().into_iter().filter(|j| j.settings.is_none()) {
|
||||||
|
let path = job.path.clone();
|
||||||
|
match adopt(&app.store, job) {
|
||||||
|
Ok(_) => tracing::info!("automation {path}: settings moved from the file into the database"),
|
||||||
|
Err(e) => tracing::warn!("automation {path}: {e:#}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
loop {
|
loop {
|
||||||
tokio::time::sleep(Duration::from_secs(30)).await;
|
tokio::time::sleep(Duration::from_secs(30)).await;
|
||||||
let Ok(jobs) = app.store.jobs(None) else { continue };
|
let Ok(jobs) = app.store.jobs(None) else { continue };
|
||||||
let now = now_ms();
|
let now = now_ms();
|
||||||
for job in jobs.into_iter().filter(|j| j.enabled) {
|
for job in jobs.into_iter().filter(|j| j.enabled) {
|
||||||
let Ok(spec) = parse(Path::new(&job.path)) else { continue };
|
let Ok(spec) = spec(&job) else { continue };
|
||||||
// Due if a scheduled time passed since the last run (or since creation).
|
// Due if a scheduled time passed since the last run (or since creation).
|
||||||
let base = job.last_run.unwrap_or(job.created_at);
|
let base = job.last_run.unwrap_or(job.created_at);
|
||||||
let Some(next) = next_run(&spec.schedule, base) else { continue };
|
let Some(next) = next_run(&spec.schedule, base) else { continue };
|
||||||
@@ -234,22 +398,6 @@ pub fn spawn_scheduler(app: Arc<App>) {
|
|||||||
/// Tool: create, list, run and delete automations.
|
/// Tool: create, list, run and delete automations.
|
||||||
pub struct Schedule;
|
pub struct Schedule;
|
||||||
|
|
||||||
/// Fields of a job file written by the agent tool or the "from template" dialog.
|
|
||||||
#[derive(Debug, serde::Deserialize)]
|
|
||||||
pub struct NewJob {
|
|
||||||
pub name: String,
|
|
||||||
pub schedule: String,
|
|
||||||
#[serde(default = "default_agent")]
|
|
||||||
pub agent: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub model: Option<String>,
|
|
||||||
#[serde(default = "default_mode")]
|
|
||||||
pub permission_mode: String,
|
|
||||||
#[serde(default = "default_timeout")]
|
|
||||||
pub timeout_minutes: u64,
|
|
||||||
pub prompt: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_agent() -> String {
|
fn default_agent() -> String {
|
||||||
"build".into()
|
"build".into()
|
||||||
}
|
}
|
||||||
@@ -260,28 +408,20 @@ fn default_timeout() -> u64 {
|
|||||||
60
|
60
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write (and validate) a job file.
|
/// Write the instructions file, register it and store the settings.
|
||||||
pub fn write_file(path: &Path, j: &NewJob) -> Result<JobSpec> {
|
pub fn create(store: &crate::store::Store, owner: &str, path: &Path, mut settings: JobSettings, prompt: &str, enabled: bool) -> Result<Job> {
|
||||||
croner::Cron::from_str(&j.schedule).map_err(|e| anyhow!("invalid schedule '{}': {e}", j.schedule))?;
|
settings.validate()?;
|
||||||
let q = |s: &str| format!("\"{}\"", s.replace('"', "'").replace('\n', " "));
|
if path.exists() {
|
||||||
let plain = |s: &str| s.chars().filter(|c| c.is_alphanumeric() || "-_/.:".contains(*c)).collect::<String>();
|
bail!("{} already exists", path.display());
|
||||||
let mut fm = format!("---\nname: {}\nschedule: {}\nagent: {}\n", q(&j.name), q(&j.schedule), plain(&j.agent));
|
|
||||||
if let Some(m) = j.model.as_deref().filter(|m| !m.is_empty()) {
|
|
||||||
fm.push_str(&format!("model: {}\n", plain(m)));
|
|
||||||
}
|
}
|
||||||
fm.push_str(&format!("permission_mode: {}\n", plain(&j.permission_mode)));
|
|
||||||
fm.push_str(&format!("timeout_minutes: {}\n---\n", j.timeout_minutes.clamp(1, 24 * 60)));
|
|
||||||
if let Some(dir) = path.parent() {
|
if let Some(dir) = path.parent() {
|
||||||
std::fs::create_dir_all(dir)?;
|
std::fs::create_dir_all(dir)?;
|
||||||
}
|
}
|
||||||
std::fs::write(path, format!("{fm}{}\n", j.prompt.trim()))?;
|
std::fs::write(path, format!("{}\n", prompt.trim()))?;
|
||||||
match parse(path) {
|
let job = store.job_register(owner, &path.display().to_string())?;
|
||||||
Ok(spec) => Ok(spec),
|
store.job_set_settings(&job.id, &settings)?;
|
||||||
Err(e) => {
|
store.job_set_enabled(&job.id, enabled)?;
|
||||||
let _ = std::fs::remove_file(path);
|
store.job(&job.id)?.ok_or_else(|| anyhow!("automation {} vanished", job.id))
|
||||||
Err(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Free file name for a new job in `<project>/.codecraft/jobs/`.
|
/// Free file name for a new job in `<project>/.codecraft/jobs/`.
|
||||||
@@ -303,10 +443,11 @@ impl Tool for Schedule {
|
|||||||
"schedule"
|
"schedule"
|
||||||
}
|
}
|
||||||
fn description(&self) -> String {
|
fn description(&self) -> String {
|
||||||
"Manage automations: agents that run on a cron schedule from a Markdown job file \
|
"Manage automations: agents that run on a cron schedule. The instructions are a Markdown file \
|
||||||
(.codecraft/jobs/<name>.md in the working directory). Actions: \
|
(.codecraft/jobs/<name>.md in the working directory, only the prompt); name, schedule, mode, model and \
|
||||||
`create` (name, schedule, prompt, optional agent/model/permission_mode/timeout_minutes) writes the job file and registers it; \
|
limits are settings of the automation. Actions: \
|
||||||
`register` (path) registers an existing job file; `list`; `run` (id) starts a run now; \
|
`create` (name, schedule, prompt, optional agent/model/permission_mode/timeout_minutes) writes the file and registers it; \
|
||||||
|
`register` (path) registers an existing Markdown file (switched off until a schedule is set in the UI); `list`; `run` (id) starts a run now; \
|
||||||
`enable`/`disable` (id); `delete` (id, delete_file). Schedules are 5-field cron expressions \
|
`enable`/`disable` (id); `delete` (id, delete_file). Schedules are 5-field cron expressions \
|
||||||
in the server's time zone, e.g. \"0 7 * * 1-5\" = weekdays 07:00, \"*/30 * * * *\" = every 30 minutes. \
|
in the server's time zone, e.g. \"0 7 * * 1-5\" = weekdays 07:00, \"*/30 * * * *\" = every 30 minutes. \
|
||||||
The prompt must be self-contained: the run starts a fresh session without this conversation."
|
The prompt must be self-contained: the run starts a fresh session without this conversation."
|
||||||
@@ -349,19 +490,15 @@ impl Tool for Schedule {
|
|||||||
let prompt = arg_str(&input, "prompt")?;
|
let prompt = arg_str(&input, "prompt")?;
|
||||||
let rel = format!(".codecraft/jobs/{}.md", slug(name));
|
let rel = format!(".codecraft/jobs/{}.md", slug(name));
|
||||||
let path = ctx.resolve(&rel)?;
|
let path = ctx.resolve(&rel)?;
|
||||||
write_file(
|
let settings = JobSettings {
|
||||||
&path,
|
|
||||||
&NewJob {
|
|
||||||
name: name.into(),
|
|
||||||
schedule: schedule.into(),
|
schedule: schedule.into(),
|
||||||
agent: input["agent"].as_str().unwrap_or("build").into(),
|
agent: input["agent"].as_str().unwrap_or("build").into(),
|
||||||
model: input["model"].as_str().map(String::from),
|
model: input["model"].as_str().map(String::from),
|
||||||
permission_mode: input["permission_mode"].as_str().unwrap_or("auto").into(),
|
permission_mode: input["permission_mode"].as_str().unwrap_or("auto").into(),
|
||||||
timeout_minutes: input["timeout_minutes"].as_u64().unwrap_or(60),
|
timeout_minutes: input["timeout_minutes"].as_u64().unwrap_or(60),
|
||||||
prompt: prompt.into(),
|
..JobSettings::new(name)
|
||||||
},
|
};
|
||||||
)?;
|
let job = create(&ctx.app.store, &owner, &path, settings, prompt, true)?;
|
||||||
let job = ctx.app.store.job_register(&owner, &path.display().to_string())?;
|
|
||||||
let d = describe(&job);
|
let d = describe(&job);
|
||||||
Ok(ToolOutput::text(format!(
|
Ok(ToolOutput::text(format!(
|
||||||
"Automation '{name}' created: {} (id {}). Schedule {schedule}, next run {}. The user can manage it on the Automations page.",
|
"Automation '{name}' created: {} (id {}). Schedule {schedule}, next run {}. The user can manage it on the Automations page.",
|
||||||
@@ -373,8 +510,10 @@ impl Tool for Schedule {
|
|||||||
}
|
}
|
||||||
"register" => {
|
"register" => {
|
||||||
let path = ctx.resolve(arg_str(&input, "path")?)?;
|
let path = ctx.resolve(arg_str(&input, "path")?)?;
|
||||||
parse(&path)?;
|
if !path.is_file() {
|
||||||
let job = ctx.app.store.job_register(&owner, &path.display().to_string())?;
|
bail!("{} is not a file", path.display());
|
||||||
|
}
|
||||||
|
let job = adopt(&ctx.app.store, ctx.app.store.job_register(&owner, &path.display().to_string())?)?;
|
||||||
Ok(ToolOutput::text(format!("Registered {} as automation {}.", path.display(), job.id)).with_meta(describe(&job)))
|
Ok(ToolOutput::text(format!("Registered {} as automation {}.", path.display(), job.id)).with_meta(describe(&job)))
|
||||||
}
|
}
|
||||||
"list" => {
|
"list" => {
|
||||||
@@ -448,6 +587,35 @@ mod tests {
|
|||||||
assert_eq!(job_cwd(&p, &s), dir);
|
assert_eq!(job_cwd(&p, &s), dir);
|
||||||
std::fs::remove_dir_all(&dir).unwrap();
|
std::fs::remove_dir_all(&dir).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn adopts_frontmatter_and_creates_prompt_only_files() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("cc-job-adopt-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(dir.join(".codecraft/jobs")).unwrap();
|
||||||
|
let store = crate::store::Store::open(&dir.join("t.db")).unwrap();
|
||||||
|
// Older file: settings move into the database, the file keeps only the prompt.
|
||||||
|
let p = dir.join(".codecraft/jobs/old.md");
|
||||||
|
std::fs::write(&p, "---\nname: \"Old\"\nschedule: \"0 4 * * *\"\npermission_mode: bypass # comment\n---\nDo old things.\n").unwrap();
|
||||||
|
let job = adopt(&store, store.job_register("u", &p.display().to_string()).unwrap()).unwrap();
|
||||||
|
let st = job.settings.clone().unwrap();
|
||||||
|
assert_eq!((st.name.as_str(), st.schedule.as_str(), st.permission_mode.as_str()), ("Old", "0 4 * * *", "bypass"));
|
||||||
|
assert_eq!(std::fs::read_to_string(&p).unwrap(), "Do old things.\n");
|
||||||
|
assert_eq!(spec(&job).unwrap().prompt, "Do old things.");
|
||||||
|
assert!(job.enabled);
|
||||||
|
// Plain Markdown: default settings, switched off.
|
||||||
|
let q = dir.join(".codecraft/jobs/weekly-report.md");
|
||||||
|
std::fs::write(&q, "Write a report.\n").unwrap();
|
||||||
|
let job = adopt(&store, store.job_register("u", &q.display().to_string()).unwrap()).unwrap();
|
||||||
|
assert_eq!(job.settings.as_ref().unwrap().name, "weekly report");
|
||||||
|
assert!(!job.enabled);
|
||||||
|
// New automation.
|
||||||
|
let r = dir.join(".codecraft/jobs/new.md");
|
||||||
|
assert!(create(&store, "u", &r, JobSettings { schedule: "nope".into(), ..JobSettings::new("N") }, "x", false).is_err());
|
||||||
|
let job = create(&store, "u", &r, JobSettings::new("New"), "# New\n\nGoal", false).unwrap();
|
||||||
|
assert_eq!(spec(&job).unwrap().prompt, "# New\n\nGoal");
|
||||||
|
assert!(check_schedule("0 3 * *").is_err() && check_schedule("*/15 8-18 * * 1-5").is_ok());
|
||||||
|
std::fs::remove_dir_all(&dir).unwrap();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- incoming webhooks -------------------------------------------------------------
|
// --- incoming webhooks -------------------------------------------------------------
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ mod api;
|
|||||||
mod app;
|
mod app;
|
||||||
mod auth;
|
mod auth;
|
||||||
mod backup;
|
mod backup;
|
||||||
|
mod blobs;
|
||||||
mod cli_api;
|
mod cli_api;
|
||||||
mod budget;
|
mod budget;
|
||||||
mod config;
|
mod config;
|
||||||
|
|||||||
@@ -212,6 +212,8 @@ impl McpServer {
|
|||||||
match c["type"].as_str().unwrap_or_default() {
|
match c["type"].as_str().unwrap_or_default() {
|
||||||
"text" => texts.push(c["text"].as_str().unwrap_or_default().to_string()),
|
"text" => texts.push(c["text"].as_str().unwrap_or_default().to_string()),
|
||||||
"image" => out.images.push(ImageData {
|
"image" => out.images.push(ImageData {
|
||||||
|
blob: None,
|
||||||
|
size: None,
|
||||||
media_type: c["mimeType"].as_str().unwrap_or("image/png").into(),
|
media_type: c["mimeType"].as_str().unwrap_or("image/png").into(),
|
||||||
data: c["data"].as_str().unwrap_or_default().into(),
|
data: c["data"].as_str().unwrap_or_default().into(),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -291,7 +291,7 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
|
|||||||
|
|
||||||
let owner = session.owner_id.as_deref().and_then(|id| app.store.user(id).ok().flatten());
|
let owner = session.owner_id.as_deref().and_then(|id| app.store.user(id).ok().flatten());
|
||||||
let tools = available_tools(&app, &agent, owner.as_ref());
|
let tools = available_tools(&app, &agent, owner.as_ref());
|
||||||
let history = active_history(app.store.messages(session_id)?);
|
let history = active_history(app.store.messages_for_model(session_id)?);
|
||||||
let req = ChatRequest {
|
let req = ChatRequest {
|
||||||
system: prompts::with_chat_instructions(prompts::system_prompt(&app, &agent, &root, &cwd, &model_spec, &session.permission_mode, owner.as_ref()), &session.instructions),
|
system: prompts::with_chat_instructions(prompts::system_prompt(&app, &agent, &root, &cwd, &model_spec, &session.permission_mode, owner.as_ref()), &session.instructions),
|
||||||
messages: history,
|
messages: history,
|
||||||
@@ -705,6 +705,7 @@ fn create_child(app: &Arc<App>, ctx: &ToolCtx, agent_name: &str, description: &s
|
|||||||
archived: false,
|
archived: false,
|
||||||
folder: None,
|
folder: None,
|
||||||
instructions: String::new(),
|
instructions: String::new(),
|
||||||
|
telegram_quiet: false,
|
||||||
};
|
};
|
||||||
let child = app.save_session(child)?;
|
let child = app.save_session(child)?;
|
||||||
let msg = Message::new(Role::User, vec![Part::Text { text: prompt }]);
|
let msg = Message::new(Role::User, vec![Part::Text { text: prompt }]);
|
||||||
@@ -1126,7 +1127,7 @@ async fn review_turn(app: &Arc<App>, session: &Session, agent: &prompts::Agent,
|
|||||||
model.effort = Some("medium".into());
|
model.effort = Some("medium".into());
|
||||||
app.emit(Event::Waiting { session_id: session.id.clone(), reason: Some(format!("review:{round}/{rounds}")) });
|
app.emit(Event::Waiting { session_id: session.id.clone(), reason: Some(format!("review:{round}/{rounds}")) });
|
||||||
let result = async {
|
let result = async {
|
||||||
let history = active_history(app.store.messages(&session.id)?);
|
let history = active_history(app.store.messages_for_model(&session.id)?);
|
||||||
// The user's request: the last user message with text that is not feedback/tool output.
|
// The user's request: the last user message with text that is not feedback/tool output.
|
||||||
let req_idx = history
|
let req_idx = history
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1251,7 +1252,7 @@ pub async fn compact(app: Arc<App>, session_id: &str, cancel: CancellationToken,
|
|||||||
|
|
||||||
async fn compact_inner(app: Arc<App>, session_id: &str, cancel: CancellationToken, auto: Option<(u64, u64)>) -> Result<()> {
|
async fn compact_inner(app: Arc<App>, session_id: &str, cancel: CancellationToken, auto: Option<(u64, u64)>) -> Result<()> {
|
||||||
let session = app.session(session_id)?;
|
let session = app.session(session_id)?;
|
||||||
let history = active_history(app.store.messages(session_id)?);
|
let history = active_history(app.store.messages_for_model(session_id)?);
|
||||||
if history.is_empty() {
|
if history.is_empty() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ pub async fn probe() -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
let res = match Command::new("bwrap")
|
let res = match Command::new("bwrap")
|
||||||
.args(["--ro-bind", "/usr", "/usr", "--ro-bind", "/bin", "/bin", "--ro-bind", "/lib", "/lib", "--dev", "/dev", "--proc", "/proc"])
|
.args(["--ro-bind", "/usr", "/usr", "--ro-bind", "/bin", "/bin", "--ro-bind", "/lib", "/lib", "--dev", "/dev", "--proc", "/proc"])
|
||||||
|
// The dynamic loader lives in /lib64 on x86_64 (/lib on arm64).
|
||||||
|
.args(["--ro-bind-try", "/lib64", "/lib64", "--ro-bind-try", "/lib32", "/lib32"])
|
||||||
.args(["--unshare-all", "--die-with-parent", "--clearenv", "/bin/true"])
|
.args(["--unshare-all", "--die-with-parent", "--clearenv", "/bin/true"])
|
||||||
.env_clear()
|
.env_clear()
|
||||||
.output()
|
.output()
|
||||||
|
|||||||
+139
-5
@@ -1,6 +1,6 @@
|
|||||||
//! SQLite persistence for sessions and messages.
|
//! SQLite persistence for sessions and messages.
|
||||||
|
|
||||||
use crate::types::{Message, Part, Session, now_ms};
|
use crate::types::{ImageData, Message, Part, Session, now_ms};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
@@ -90,6 +90,13 @@ impl Store {
|
|||||||
created_at INTEGER NOT NULL
|
created_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS images_user ON images(user_id, created_at);
|
CREATE INDEX IF NOT EXISTS images_user ON images(user_id, created_at);
|
||||||
|
-- Images of messages, referenced by hash from the message JSON (see blobs.rs).
|
||||||
|
CREATE TABLE IF NOT EXISTS blobs (
|
||||||
|
hash TEXT PRIMARY KEY,
|
||||||
|
media_type TEXT NOT NULL,
|
||||||
|
data BLOB NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
CREATE TABLE IF NOT EXISTS queue (
|
CREATE TABLE IF NOT EXISTS queue (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||||
@@ -162,6 +169,7 @@ impl Store {
|
|||||||
("usage", "user_id", "ALTER TABLE usage ADD COLUMN user_id TEXT"),
|
("usage", "user_id", "ALTER TABLE usage ADD COLUMN user_id TEXT"),
|
||||||
("users", "monthly_quota", "ALTER TABLE users ADD COLUMN monthly_quota REAL"),
|
("users", "monthly_quota", "ALTER TABLE users ADD COLUMN monthly_quota REAL"),
|
||||||
("jobs", "webhook_secret", "ALTER TABLE jobs ADD COLUMN webhook_secret TEXT"),
|
("jobs", "webhook_secret", "ALTER TABLE jobs ADD COLUMN webhook_secret TEXT"),
|
||||||
|
("jobs", "settings", "ALTER TABLE jobs ADD COLUMN settings TEXT"),
|
||||||
("usage", "allowance", "ALTER TABLE usage ADD COLUMN allowance REAL"),
|
("usage", "allowance", "ALTER TABLE usage ADD COLUMN allowance REAL"),
|
||||||
] {
|
] {
|
||||||
let exists: bool = conn
|
let exists: bool = conn
|
||||||
@@ -274,12 +282,21 @@ impl Store {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn add_message(&self, session_id: &str, m: &Message) -> Result<()> {
|
pub fn add_message(&self, session_id: &str, m: &Message) -> Result<()> {
|
||||||
|
// Images go to the blob table (encoded before taking the lock).
|
||||||
|
let mut m = m.clone();
|
||||||
|
let blobs = crate::blobs::externalize(&mut m);
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
|
for b in &blobs {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO blobs (hash, media_type, data, created_at) VALUES (?1, ?2, ?3, ?4)",
|
||||||
|
params![b.hash, b.media_type, b.bytes, now_ms()],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO messages (id, session_id, seq, data) VALUES (?1, ?2,
|
"INSERT INTO messages (id, session_id, seq, data) VALUES (?1, ?2,
|
||||||
(SELECT COALESCE(MAX(seq), 0) + 1 FROM messages WHERE session_id = ?2), ?3)
|
(SELECT COALESCE(MAX(seq), 0) + 1 FROM messages WHERE session_id = ?2), ?3)
|
||||||
ON CONFLICT(id) DO UPDATE SET data = excluded.data",
|
ON CONFLICT(id) DO UPDATE SET data = excluded.data",
|
||||||
params![m.id, session_id, serde_json::to_string(m)?],
|
params![m.id, session_id, serde_json::to_string(&m)?],
|
||||||
)?;
|
)?;
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE sessions SET updated_at = ?2 WHERE id = ?1",
|
"UPDATE sessions SET updated_at = ?2 WHERE id = ?1",
|
||||||
@@ -300,6 +317,103 @@ impl Store {
|
|||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Messages with the bytes of their images filled in – for model requests and exports.
|
||||||
|
pub fn messages_for_model(&self, session_id: &str) -> Result<Vec<Message>> {
|
||||||
|
let mut out = self.messages(session_id)?;
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let mut cache: std::collections::HashMap<String, String> = std::collections::HashMap::new();
|
||||||
|
for m in &mut out {
|
||||||
|
crate::blobs::for_each_image(m, |img| fill_image(&conn, &mut cache, img));
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fill in the bytes of one image reference (no-op for inline images).
|
||||||
|
pub fn resolve_image(&self, img: &ImageData) -> ImageData {
|
||||||
|
let mut img = img.clone();
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
fill_image(&conn, &mut std::collections::HashMap::new(), &mut img);
|
||||||
|
img
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One stored image: (media type, bytes).
|
||||||
|
pub fn blob(&self, hash: &str) -> Result<Option<(String, Vec<u8>)>> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
Ok(conn
|
||||||
|
.query_row("SELECT media_type, data FROM blobs WHERE hash = ?1", [hash], |r| Ok((r.get(0)?, r.get(1)?)))
|
||||||
|
.optional()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move inline images of older messages to the blob table (once, after the update
|
||||||
|
/// that introduced it). Does not touch `updated_at` of the sessions.
|
||||||
|
pub fn migrate_inline_images(&self) -> Result<usize> {
|
||||||
|
let ids: Vec<String> = {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let mut stmt = conn.prepare("SELECT id FROM messages WHERE data LIKE '%\"media_type\"%\"data\":\"%'")?;
|
||||||
|
let rows = stmt.query_map([], |r| r.get(0))?.collect::<rusqlite::Result<Vec<String>>>()?;
|
||||||
|
rows
|
||||||
|
};
|
||||||
|
let mut moved = 0;
|
||||||
|
for id in ids {
|
||||||
|
let data: Option<String> = {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
conn.query_row("SELECT data FROM messages WHERE id = ?1", [&id], |r| r.get(0)).optional()?
|
||||||
|
};
|
||||||
|
let Some(data) = data else { continue };
|
||||||
|
let Ok(mut m) = serde_json::from_str::<Message>(&data) else { continue };
|
||||||
|
let blobs = crate::blobs::externalize(&mut m);
|
||||||
|
if blobs.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
for b in &blobs {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO blobs (hash, media_type, data, created_at) VALUES (?1, ?2, ?3, ?4)",
|
||||||
|
params![b.hash, b.media_type, b.bytes, now_ms()],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
conn.execute("UPDATE messages SET data = ?2 WHERE id = ?1", params![id, serde_json::to_string(&m)?])?;
|
||||||
|
moved += blobs.len();
|
||||||
|
}
|
||||||
|
Ok(moved)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete images no message refers to any more (deleted or truncated sessions).
|
||||||
|
pub fn gc_blobs(&self) -> Result<usize> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let mut used = std::collections::HashSet::new();
|
||||||
|
let mut stmt = conn.prepare("SELECT data FROM messages WHERE data LIKE '%\"blob\":\"%'")?;
|
||||||
|
let mut rows = stmt.query([])?;
|
||||||
|
while let Some(row) = rows.next()? {
|
||||||
|
let data: String = row.get(0)?;
|
||||||
|
for (i, _) in data.match_indices("\"blob\":\"") {
|
||||||
|
if let Some(h) = data.get(i + 8..i + 72) {
|
||||||
|
used.insert(h.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drop(rows);
|
||||||
|
drop(stmt);
|
||||||
|
let all: Vec<String> = conn.prepare("SELECT hash FROM blobs")?.query_map([], |r| r.get(0))?.collect::<rusqlite::Result<_>>()?;
|
||||||
|
let mut removed = 0;
|
||||||
|
for h in all.iter().filter(|h| !used.contains(*h)) {
|
||||||
|
removed += conn.execute("DELETE FROM blobs WHERE hash = ?1", [h])?;
|
||||||
|
}
|
||||||
|
Ok(removed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Give the space of deleted data back to the file system when it is worth it.
|
||||||
|
pub fn vacuum_if_worthwhile(&self) -> Result<bool> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let free: i64 = conn.query_row("PRAGMA freelist_count", [], |r| r.get(0))?;
|
||||||
|
let page: i64 = conn.query_row("PRAGMA page_size", [], |r| r.get(0))?;
|
||||||
|
if free * page < 32 * 1024 * 1024 {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
conn.execute_batch("VACUUM")?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
/// Remove all messages after (and including) the given one – used for "edit & resend".
|
/// Remove all messages after (and including) the given one – used for "edit & resend".
|
||||||
pub fn truncate_from(&self, session_id: &str, message_id: &str) -> Result<()> {
|
pub fn truncate_from(&self, session_id: &str, message_id: &str) -> Result<()> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
@@ -312,6 +426,20 @@ impl Store {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Put the bytes of a stored image into `img.data`.
|
||||||
|
fn fill_image(conn: &Connection, cache: &mut std::collections::HashMap<String, String>, img: &mut ImageData) {
|
||||||
|
use base64::Engine;
|
||||||
|
let Some(hash) = img.blob.clone().filter(|_| img.data.is_empty()) else { return };
|
||||||
|
if !cache.contains_key(&hash) {
|
||||||
|
let Ok(bytes) = conn.query_row("SELECT data FROM blobs WHERE hash = ?1", [&hash], |r| r.get::<_, Vec<u8>>(0)) else {
|
||||||
|
tracing::warn!("image {hash} is missing in the store");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
cache.insert(hash.clone(), base64::engine::general_purpose::STANDARD.encode(bytes));
|
||||||
|
}
|
||||||
|
img.data = cache[&hash].clone();
|
||||||
|
}
|
||||||
|
|
||||||
/// One model request, as recorded for statistics.
|
/// One model request, as recorded for statistics.
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct UsageRecord {
|
pub struct UsageRecord {
|
||||||
@@ -1008,13 +1136,14 @@ impl Store {
|
|||||||
last_status: r.get(6)?,
|
last_status: r.get(6)?,
|
||||||
created_at: r.get(7)?,
|
created_at: r.get(7)?,
|
||||||
webhook: r.get::<_, Option<String>>(8)?.is_some(),
|
webhook: r.get::<_, Option<String>>(8)?.is_some(),
|
||||||
|
settings: r.get::<_, Option<String>>(9)?.and_then(|j| serde_json::from_str(&j).ok()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn jobs(&self, owner: Option<&str>) -> Result<Vec<crate::jobs::Job>> {
|
pub fn jobs(&self, owner: Option<&str>) -> Result<Vec<crate::jobs::Job>> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT id, owner_id, path, enabled, last_run, last_session_id, last_status, created_at, webhook_secret FROM jobs
|
"SELECT id, owner_id, path, enabled, last_run, last_session_id, last_status, created_at, webhook_secret, settings FROM jobs
|
||||||
WHERE ?1 IS NULL OR owner_id = ?1 ORDER BY created_at",
|
WHERE ?1 IS NULL OR owner_id = ?1 ORDER BY created_at",
|
||||||
)?;
|
)?;
|
||||||
let rows = stmt.query_map([owner], Self::job_row)?;
|
let rows = stmt.query_map([owner], Self::job_row)?;
|
||||||
@@ -1024,7 +1153,7 @@ impl Store {
|
|||||||
pub fn job(&self, id: &str) -> Result<Option<crate::jobs::Job>> {
|
pub fn job(&self, id: &str) -> Result<Option<crate::jobs::Job>> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
Ok(conn
|
Ok(conn
|
||||||
.query_row("SELECT id, owner_id, path, enabled, last_run, last_session_id, last_status, created_at, webhook_secret FROM jobs WHERE id = ?1", [id], Self::job_row)
|
.query_row("SELECT id, owner_id, path, enabled, last_run, last_session_id, last_status, created_at, webhook_secret, settings FROM jobs WHERE id = ?1", [id], Self::job_row)
|
||||||
.optional()?)
|
.optional()?)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1040,7 +1169,7 @@ impl Store {
|
|||||||
}
|
}
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
Ok(conn.query_row(
|
Ok(conn.query_row(
|
||||||
"SELECT id, owner_id, path, enabled, last_run, last_session_id, last_status, created_at, webhook_secret FROM jobs WHERE path = ?1",
|
"SELECT id, owner_id, path, enabled, last_run, last_session_id, last_status, created_at, webhook_secret, settings FROM jobs WHERE path = ?1",
|
||||||
[path],
|
[path],
|
||||||
Self::job_row,
|
Self::job_row,
|
||||||
)?)
|
)?)
|
||||||
@@ -1073,6 +1202,11 @@ impl Store {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Set (Some) or remove (None) the webhook secret of a job.
|
/// Set (Some) or remove (None) the webhook secret of a job.
|
||||||
|
pub fn job_set_settings(&self, id: &str, settings: &crate::jobs::JobSettings) -> Result<()> {
|
||||||
|
self.conn.lock().unwrap().execute("UPDATE jobs SET settings = ?2 WHERE id = ?1", rusqlite::params![id, serde_json::to_string(settings)?])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn job_set_webhook(&self, id: &str, secret: Option<&str>) -> Result<()> {
|
pub fn job_set_webhook(&self, id: &str, secret: Option<&str>) -> Result<()> {
|
||||||
self.conn.lock().unwrap().execute("UPDATE jobs SET webhook_secret = ?2 WHERE id = ?1", rusqlite::params![id, secret])?;
|
self.conn.lock().unwrap().execute("UPDATE jobs SET webhook_secret = ?2 WHERE id = ?1", rusqlite::params![id, secret])?;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
+19
-5
@@ -260,7 +260,8 @@ fn user(app: &App, user_id: &str) -> Result<User> {
|
|||||||
app.store.user(user_id)?.filter(|u| !u.disabled).ok_or_else(|| anyhow!("account disabled"))
|
app.store.user(user_id)?.filter(|u| !u.disabled).ok_or_else(|| anyhow!("account disabled"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn german(app: &App, user_id: &str) -> bool {
|
/// The user reads German (language preference of the web UI).
|
||||||
|
pub fn german(app: &App, user_id: &str) -> bool {
|
||||||
let prefs: Value = app.store.user_setting(user_id, "prefs").unwrap_or_default();
|
let prefs: Value = app.store.user_setting(user_id, "prefs").unwrap_or_default();
|
||||||
prefs["lang"].as_str().unwrap_or("en").starts_with("de")
|
prefs["lang"].as_str().unwrap_or("en").starts_with("de")
|
||||||
}
|
}
|
||||||
@@ -354,13 +355,13 @@ async fn handle_update(app: &Arc<App>, user_id: &str, bot: &Bot, u: &Value) -> R
|
|||||||
let mut notes = Vec::new();
|
let mut notes = Vec::new();
|
||||||
if let Some(photo) = m["photo"].as_array().and_then(|p| p.last()) {
|
if let Some(photo) = m["photo"].as_array().and_then(|p| p.last()) {
|
||||||
let (bytes, _) = bot.download(photo["file_id"].as_str().unwrap_or("")).await?;
|
let (bytes, _) = bot.download(photo["file_id"].as_str().unwrap_or("")).await?;
|
||||||
images.push(ImageData { media_type: "image/jpeg".into(), data: base64::engine::general_purpose::STANDARD.encode(bytes) });
|
images.push(ImageData { blob: None, size: None, media_type: "image/jpeg".into(), data: base64::engine::general_purpose::STANDARD.encode(bytes) });
|
||||||
}
|
}
|
||||||
if let Some(doc) = m.get("document") {
|
if let Some(doc) = m.get("document") {
|
||||||
let mime = doc["mime_type"].as_str().unwrap_or("");
|
let mime = doc["mime_type"].as_str().unwrap_or("");
|
||||||
let (bytes, path) = bot.download(doc["file_id"].as_str().unwrap_or("")).await?;
|
let (bytes, path) = bot.download(doc["file_id"].as_str().unwrap_or("")).await?;
|
||||||
if mime.starts_with("image/") && bytes.len() < 10 * 1024 * 1024 {
|
if mime.starts_with("image/") && bytes.len() < 10 * 1024 * 1024 {
|
||||||
images.push(ImageData { media_type: mime.into(), data: base64::engine::general_purpose::STANDARD.encode(bytes) });
|
images.push(ImageData { blob: None, size: None, media_type: mime.into(), data: base64::engine::general_purpose::STANDARD.encode(bytes) });
|
||||||
} else {
|
} else {
|
||||||
let name = doc["file_name"].as_str().map(String::from).unwrap_or_else(|| path.rsplit('/').next().unwrap_or("file").to_string());
|
let name = doc["file_name"].as_str().map(String::from).unwrap_or_else(|| path.rsplit('/').next().unwrap_or("file").to_string());
|
||||||
let rel = save_upload(&session, &name, &bytes)?;
|
let rel = save_upload(&session, &name, &bytes)?;
|
||||||
@@ -538,6 +539,8 @@ async fn new_session(app: &Arc<App>, u: &User, bot: &Bot, chat: i64, arg: &str,
|
|||||||
archived: false,
|
archived: false,
|
||||||
folder: None,
|
folder: None,
|
||||||
instructions: String::new(),
|
instructions: String::new(),
|
||||||
|
// Started from Telegram: keeps reporting there after switching to another chat.
|
||||||
|
telegram_quiet: false,
|
||||||
})?;
|
})?;
|
||||||
update(app, &u.id, |st| st.session = Some(s.id.clone()));
|
update(app, &u.id, |st| st.session = Some(s.id.clone()));
|
||||||
bot.send(chat, &format!("{} <b>{}</b> · {} · {}", tr(de, "Neue Sitzung", "New session"), esc(&s.cwd), esc(&s.agent), esc(&s.model)), None).await?;
|
bot.send(chat, &format!("{} <b>{}</b> · {} · {}", tr(de, "Neue Sitzung", "New session"), esc(&s.cwd), esc(&s.agent), esc(&s.model)), None).await?;
|
||||||
@@ -869,6 +872,17 @@ async fn listen(app: Arc<App>) {
|
|||||||
let Some(chat) = st.chat_id else { continue };
|
let Some(chat) = st.chat_id else { continue };
|
||||||
let de = german(&app, &owner);
|
let de = german(&app, &owner);
|
||||||
let is_current = st.session.as_deref() == Some(session_id.as_str());
|
let is_current = st.session.as_deref() == Some(session_id.as_str());
|
||||||
|
// Chats with "not to Telegram": their approvals and questions stay in the web UI
|
||||||
|
// (subagents follow their top-level chat; the chat open in Telegram always reports).
|
||||||
|
if !is_current && matches!(ev, Event::PermissionRequest { .. } | Event::QuestionRequest { .. }) {
|
||||||
|
let root = app.root_session_id(&session_id);
|
||||||
|
let quiet = if st.session.as_deref() == Some(root.as_str()) {
|
||||||
|
false
|
||||||
|
} else if root == session_id { session.telegram_quiet } else { app.store.get_session(&root).ok().flatten().is_some_and(|r| r.telegram_quiet) };
|
||||||
|
if quiet {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
if let Err(e) = on_event(&app, &bot, chat, de, &session, is_current, &ev, &mut progress).await {
|
if let Err(e) = on_event(&app, &bot, chat, de, &session, is_current, &ev, &mut progress).await {
|
||||||
tracing::debug!("telegram send: {e:#}");
|
tracing::debug!("telegram send: {e:#}");
|
||||||
}
|
}
|
||||||
@@ -876,7 +890,7 @@ async fn listen(app: Arc<App>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
async fn on_event(_app: &App, bot: &Bot, chat: i64, de: bool, session: &Session, is_current: bool, ev: &Event, progress: &mut HashMap<String, Progress>) -> Result<()> {
|
async fn on_event(app: &App, bot: &Bot, chat: i64, de: bool, session: &Session, is_current: bool, ev: &Event, progress: &mut HashMap<String, Progress>) -> Result<()> {
|
||||||
let sid = session.id.clone();
|
let sid = session.id.clone();
|
||||||
match ev {
|
match ev {
|
||||||
// Approvals and questions of every own session: the agent waits for them.
|
// Approvals and questions of every own session: the agent waits for them.
|
||||||
@@ -947,7 +961,7 @@ async fn on_event(_app: &App, bot: &Bot, chat: i64, de: bool, session: &Session,
|
|||||||
for part in &message.parts {
|
for part in &message.parts {
|
||||||
if let Part::ToolResult { images, name, .. } = part {
|
if let Part::ToolResult { images, name, .. } = part {
|
||||||
for img in images {
|
for img in images {
|
||||||
let _ = bot.send_photo(chat, img, Some(name)).await;
|
let _ = bot.send_photo(chat, &app.store.resolve_image(img), Some(name)).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ impl Tool for OpenScad {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Ok(bytes) = std::fs::read(&out) {
|
if let Ok(bytes) = std::fs::read(&out) {
|
||||||
images.push(ImageData { media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(&bytes) });
|
images.push(ImageData { blob: None, size: None, media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(&bytes) });
|
||||||
files.push(out);
|
files.push(out);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ pub(crate) fn png_for_model(img: &image::DynamicImage) -> Result<ImageData> {
|
|||||||
let mut buf = Vec::new();
|
let mut buf = Vec::new();
|
||||||
img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Png)?;
|
img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Png)?;
|
||||||
Ok(ImageData {
|
Ok(ImageData {
|
||||||
|
blob: None,
|
||||||
|
size: None,
|
||||||
media_type: "image/png".into(),
|
media_type: "image/png".into(),
|
||||||
data: base64::engine::general_purpose::STANDARD.encode(&buf),
|
data: base64::engine::general_purpose::STANDARD.encode(&buf),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -107,6 +107,8 @@ impl Tool for Read {
|
|||||||
return Ok(ToolOutput {
|
return Ok(ToolOutput {
|
||||||
content: format!("Image {} ({} bytes)", rel(ctx, &path), bytes.len()),
|
content: format!("Image {} ({} bytes)", rel(ctx, &path), bytes.len()),
|
||||||
images: vec![ImageData {
|
images: vec![ImageData {
|
||||||
|
blob: None,
|
||||||
|
size: None,
|
||||||
media_type: mt.into(),
|
media_type: mt.into(),
|
||||||
data: base64::engine::general_purpose::STANDARD.encode(&bytes),
|
data: base64::engine::general_purpose::STANDARD.encode(&bytes),
|
||||||
}],
|
}],
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { Settings } from "./components/Settings";
|
|||||||
import { Stats } from "./components/Stats";
|
import { Stats } from "./components/Stats";
|
||||||
import { Images } from "./components/Images";
|
import { Images } from "./components/Images";
|
||||||
import { Jobs } from "./components/Jobs";
|
import { Jobs } from "./components/Jobs";
|
||||||
|
import { JobEditor } from "./components/JobEditor";
|
||||||
import { Spinner, useTitleActivity } from "./components/Activity";
|
import { Spinner, useTitleActivity } from "./components/Activity";
|
||||||
import { DialogHost } from "./dialogs";
|
import { DialogHost } from "./dialogs";
|
||||||
import { startPrefsSync } from "./prefsSync";
|
import { startPrefsSync } from "./prefsSync";
|
||||||
@@ -95,6 +96,7 @@ export function App() {
|
|||||||
else if (section === "settings") content = <Settings tab={arg} onMenu={() => setMenuOpen(true)} />;
|
else if (section === "settings") content = <Settings tab={arg} onMenu={() => setMenuOpen(true)} />;
|
||||||
else if (section === "stats") content = <Stats onMenu={() => setMenuOpen(true)} />;
|
else if (section === "stats") content = <Stats onMenu={() => setMenuOpen(true)} />;
|
||||||
else if (section === "images") content = <Images onMenu={() => setMenuOpen(true)} />;
|
else if (section === "images") content = <Images onMenu={() => setMenuOpen(true)} />;
|
||||||
|
else if (section === "jobs" && arg) content = <JobEditor key={arg} id={arg} onMenu={() => setMenuOpen(true)} />;
|
||||||
else if (section === "jobs") content = <Jobs onMenu={() => setMenuOpen(true)} />;
|
else if (section === "jobs") content = <Jobs onMenu={() => setMenuOpen(true)} />;
|
||||||
else if (section === "terminal")
|
else if (section === "terminal")
|
||||||
content = (
|
content = (
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { takePrefill } from "../share";
|
import { takePrefill } from "../share";
|
||||||
import { Icon } from "./Icon";
|
import { Icon } from "./Icon";
|
||||||
import { base64Size, FileChip, FileFigure, fileType } from "./FileFigure";
|
import { FileChip, FileFigure, fileType, imageSize, imageSrc } from "./FileFigure";
|
||||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import { del, get, patch, post } from "../api";
|
import { del, get, patch, post } from "../api";
|
||||||
import { useModels } from "../hooks";
|
import { useFetch, useModels } from "../hooks";
|
||||||
import { fmtCompact, fmtCost, fmtNumber, fmtPercent, useT, type Key } from "../i18n";
|
import { fmtCompact, fmtCost, fmtNumber, fmtPercent, useT, type Key } from "../i18n";
|
||||||
import { navigate } from "../router";
|
import { navigate } from "../router";
|
||||||
import { dismissError, loadSession, loadSessions, useStore } from "../store";
|
import { dismissError, loadSession, loadSessions, useStore } from "../store";
|
||||||
import type { Aggregate, ImageData, Message, Part, Session } from "../types";
|
import type { Aggregate, ImageData, Message, Part, Session, Todo } from "../types";
|
||||||
import { ActivityLine, Dots, Spinner, ThinkIcon, WorkBar } from "./Activity";
|
import { ActivityLine, Dots, Spinner, ThinkIcon, WorkBar } from "./Activity";
|
||||||
import { useReasoningMode } from "../prefs";
|
import { useReasoningMode, useTodosCollapsed } from "../prefs";
|
||||||
import { Composer } from "./Composer";
|
import { Composer } from "./Composer";
|
||||||
import { ShareDialog } from "./ShareDialog";
|
import { ShareDialog } from "./ShareDialog";
|
||||||
import { RenameDialog } from "./RenameDialog";
|
import { RenameDialog } from "./RenameDialog";
|
||||||
@@ -152,6 +152,8 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
|||||||
const isAdmin = useStore((s) => s.me?.role === "admin");
|
const isAdmin = useStore((s) => s.me?.role === "admin");
|
||||||
const tabs = useChatTabs(sessionId, session?.cwd ?? "");
|
const tabs = useChatTabs(sessionId, session?.cwd ?? "");
|
||||||
const [sharing, setSharing] = useState(false);
|
const [sharing, setSharing] = useState(false);
|
||||||
|
// The Telegram switch in the ⋯ menu only matters with a paired bot.
|
||||||
|
const telegramPaired = useFetch<{ paired: boolean }>("/account/telegram").data?.paired ?? false;
|
||||||
const [editingInstructions, setEditingInstructions] = useState(false);
|
const [editingInstructions, setEditingInstructions] = useState(false);
|
||||||
const models = useModels();
|
const models = useModels();
|
||||||
const [loadError, setLoadError] = useState<string>();
|
const [loadError, setLoadError] = useState<string>();
|
||||||
@@ -352,6 +354,20 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
|||||||
>
|
>
|
||||||
<Icon name="edit" /> {t("instructions.title")}
|
<Icon name="edit" /> {t("instructions.title")}
|
||||||
</a>
|
</a>
|
||||||
|
{telegramPaired && !session.parent_id && (
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
role="menuitemcheckbox"
|
||||||
|
aria-checked={!session.telegram_quiet}
|
||||||
|
title={t("chat.telegramHint")}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void update({ telegram_quiet: !session.telegram_quiet });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name={session.telegram_quiet ? "box-off" : "box-on"} /> {t("chat.telegram")}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
<a href={`/api/sessions/${sessionId}/export?format=md`} download>
|
<a href={`/api/sessions/${sessionId}/export?format=md`} download>
|
||||||
<Icon name="download" /> {t("chat.exportMd")}
|
<Icon name="download" /> {t("chat.exportMd")}
|
||||||
</a>
|
</a>
|
||||||
@@ -471,8 +487,8 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
|||||||
{imgs.length > 0 && (
|
{imgs.length > 0 && (
|
||||||
<div className="user-files">
|
<div className="user-files">
|
||||||
{imgs.map((p, i) => (
|
{imgs.map((p, i) => (
|
||||||
<FileFigure key={i} name={`image-${i + 1}.${fileType(undefined, p.image.media_type).toLowerCase()}`} mime={p.image.media_type} size={base64Size(p.image.data)}>
|
<FileFigure key={i} name={`image-${i + 1}.${fileType(undefined, p.image.media_type).toLowerCase()}`} mime={p.image.media_type} size={imageSize(p.image)}>
|
||||||
<img src={`data:${p.image.media_type};base64,${p.image.data}`} alt="" />
|
<img src={imageSrc(p.image)} alt="" loading="lazy" decoding="async" />
|
||||||
</FileFigure>
|
</FileFigure>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -503,10 +519,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
|||||||
<ActivityLine sessionId={sessionId} draft={draft} pendingTool={pendingTool} waiting={waiting} permissionPending={permissions.length > 0 || questions.length > 0} />
|
<ActivityLine sessionId={sessionId} draft={draft} pendingTool={pendingTool} waiting={waiting} permissionPending={permissions.length > 0 || questions.length > 0} />
|
||||||
)}
|
)}
|
||||||
{openTodos.length > 0 && (busy || permissions.length > 0 || questions.length > 0) && (
|
{openTodos.length > 0 && (busy || permissions.length > 0 || questions.length > 0) && (
|
||||||
<div className="todos">
|
<TodoPanel todos={todos} />
|
||||||
<div style={{ color: "var(--muted)", fontSize: 12 }}>{t("tool.todos")}</div>
|
|
||||||
<TodoList todos={todos} />
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{permissions.map((p) => (
|
{permissions.map((p) => (
|
||||||
<PermissionCard key={p.id} req={p} currentId={sessionId} />
|
<PermissionCard key={p.id} req={p} currentId={sessionId} />
|
||||||
@@ -633,3 +646,24 @@ function PhaseBar({ phase, deep }: { phase?: string; deep: boolean }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Tasks of the model above the composer; folds to one line (current task, progress). */
|
||||||
|
function TodoPanel({ todos }: { todos: Todo[] }) {
|
||||||
|
const t = useT();
|
||||||
|
const [collapsed, setCollapsed] = useTodosCollapsed();
|
||||||
|
const done = todos.filter((x) => x.status === "completed").length;
|
||||||
|
const current = todos.find((x) => x.status === "in_progress");
|
||||||
|
return (
|
||||||
|
<div className={`todos ${collapsed ? "collapsed" : ""}`}>
|
||||||
|
<button className="todos-head" onClick={() => setCollapsed(!collapsed)} aria-expanded={!collapsed}>
|
||||||
|
<Icon name={collapsed ? "chevron-right" : "chevron-down"} />
|
||||||
|
<span>{t("tool.todos")}</span>
|
||||||
|
<span className="todos-count">
|
||||||
|
{done}/{todos.length}
|
||||||
|
</span>
|
||||||
|
{collapsed && current && <span className="todos-current">{current.content}</span>}
|
||||||
|
</button>
|
||||||
|
{!collapsed && <TodoList todos={todos} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { TemplatePicker } from "./TemplatePicker";
|
import { TemplatePicker } from "./TemplatePicker";
|
||||||
import { Icon } from "./Icon";
|
import { Icon } from "./Icon";
|
||||||
import { formatSize } from "./FileFigure";
|
import { formatSize, imageSrc } from "./FileFigure";
|
||||||
import { uploadFile } from "../api";
|
import { uploadFile } from "../api";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useAgents, useFetch, useModels } from "../hooks";
|
import { useAgents, useFetch, useModels } from "../hooks";
|
||||||
@@ -257,7 +257,7 @@ export function Composer({
|
|||||||
)}
|
)}
|
||||||
{images.map((img, i) => (
|
{images.map((img, i) => (
|
||||||
<div className="att" key={i}>
|
<div className="att" key={i}>
|
||||||
<img src={`data:${img.media_type};base64,${img.data}`} alt="" />
|
<img src={imageSrc(img)} alt="" />
|
||||||
<button onClick={() => setImages((cur) => cur.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
|
<button onClick={() => setImages((cur) => cur.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
|
||||||
<Icon name="close" />
|
<Icon name="close" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { get } from "../api";
|
||||||
|
import { getLang, useT } from "../i18n";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Graphical editor for a 5-field cron expression (minute hour day month weekday,
|
||||||
|
* server time zone): every N minutes, hourly, daily, on weekdays, monthly – or a
|
||||||
|
* custom expression. Shows the next runs as computed by the server.
|
||||||
|
*/
|
||||||
|
type Mode = "minutes" | "hourly" | "daily" | "weekly" | "monthly" | "custom";
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
mode: Mode;
|
||||||
|
every: number;
|
||||||
|
minute: number;
|
||||||
|
time: string; // HH:MM
|
||||||
|
days: number[]; // cron weekdays, 0 = Sunday
|
||||||
|
dom: number;
|
||||||
|
custom: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MINUTE_STEPS = [5, 10, 15, 20, 30];
|
||||||
|
/** Monday first; cron numbers (0 = Sunday). */
|
||||||
|
const WEEK = [1, 2, 3, 4, 5, 6, 0];
|
||||||
|
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
const hhmm = (h: string, m: string) => `${pad(Number(h))}:${pad(Number(m))}`;
|
||||||
|
|
||||||
|
function expandDays(field: string): number[] | null {
|
||||||
|
const out = new Set<number>();
|
||||||
|
for (const part of field.split(",")) {
|
||||||
|
const m = part.match(/^(\d)(?:-(\d))?$/);
|
||||||
|
if (!m) return null;
|
||||||
|
const a = Number(m[1]);
|
||||||
|
const b = m[2] ? Number(m[2]) : a;
|
||||||
|
if (a > 7 || b > 7 || b < a) return null;
|
||||||
|
for (let d = a; d <= b; d++) out.add(d % 7);
|
||||||
|
}
|
||||||
|
return [...out];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCron(expr: string): State {
|
||||||
|
const base: State = { mode: "custom", every: 15, minute: 0, time: "03:00", days: [1, 2, 3, 4, 5], dom: 1, custom: expr.trim() };
|
||||||
|
const f = expr.trim().split(/\s+/);
|
||||||
|
if (f.length !== 5) return base;
|
||||||
|
const [mi, h, dom, mon, dow] = f;
|
||||||
|
const num = (s: string, max: number) => /^\d+$/.test(s) && Number(s) <= max;
|
||||||
|
if (mon !== "*") return base;
|
||||||
|
let m;
|
||||||
|
if ((m = mi.match(/^\*\/(\d+)$/)) && h === "*" && dom === "*" && dow === "*" && MINUTE_STEPS.includes(Number(m[1]))) return { ...base, mode: "minutes", every: Number(m[1]) };
|
||||||
|
if (num(mi, 59) && h === "*" && dom === "*" && dow === "*") return { ...base, mode: "hourly", minute: Number(mi) };
|
||||||
|
if (!num(mi, 59) || !num(h, 23)) return base;
|
||||||
|
const time = hhmm(h, mi);
|
||||||
|
if (dom === "*" && dow === "*") return { ...base, mode: "daily", time };
|
||||||
|
if (dom === "*") {
|
||||||
|
const days = expandDays(dow);
|
||||||
|
return days?.length ? { ...base, mode: "weekly", time, days } : base;
|
||||||
|
}
|
||||||
|
if (num(dom, 31) && Number(dom) >= 1 && dow === "*") return { ...base, mode: "monthly", time, dom: Number(dom) };
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCron(s: State): string {
|
||||||
|
const [h, m] = s.time.split(":").map(Number);
|
||||||
|
switch (s.mode) {
|
||||||
|
case "minutes":
|
||||||
|
return `*/${s.every} * * * *`;
|
||||||
|
case "hourly":
|
||||||
|
return `${s.minute} * * * *`;
|
||||||
|
case "daily":
|
||||||
|
return `${m} ${h} * * *`;
|
||||||
|
case "weekly": {
|
||||||
|
const days = [...s.days].sort((a, b) => a - b);
|
||||||
|
return `${m} ${h} * * ${days.length ? days.join(",") : "*"}`;
|
||||||
|
}
|
||||||
|
case "monthly":
|
||||||
|
return `${m} ${h} ${s.dom} * *`;
|
||||||
|
default:
|
||||||
|
return s.custom.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dayName = (d: number, style: "short" | "long") => {
|
||||||
|
// 2023-01-01 was a Sunday.
|
||||||
|
return new Date(2023, 0, 1 + d).toLocaleDateString(getLang(), { weekday: style });
|
||||||
|
};
|
||||||
|
|
||||||
|
/** "2026-11-11T06:30" (server wall time) for display, without time zone conversion. */
|
||||||
|
const fmtWall = (s: string) => {
|
||||||
|
const [d, hm] = s.split("T");
|
||||||
|
const [y, mo, da] = d.split("-").map(Number);
|
||||||
|
const date = new Date(y, mo - 1, da).toLocaleDateString(getLang(), { weekday: "short", day: "numeric", month: "short" });
|
||||||
|
return `${date}, ${hm}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CronBuilder({ value, onChange }: { value: string; onChange: (cron: string) => void }) {
|
||||||
|
const t = useT();
|
||||||
|
const [s, setS] = useState<State>(() => parseCron(value));
|
||||||
|
const [preview, setPreview] = useState<{ ok: boolean; next_local?: string[]; error?: string; tz?: string }>();
|
||||||
|
|
||||||
|
// A value from outside (another automation, reset) that the builder did not produce.
|
||||||
|
useEffect(() => {
|
||||||
|
if (value.trim() !== buildCron(s)) setS(parseCron(value));
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const expr = value.trim();
|
||||||
|
if (!expr) return setPreview(undefined);
|
||||||
|
let alive = true;
|
||||||
|
const h = setTimeout(() => {
|
||||||
|
get<typeof preview>(`/jobs/schedule?expr=${encodeURIComponent(expr)}`)
|
||||||
|
.then((p) => alive && setPreview(p))
|
||||||
|
.catch(() => alive && setPreview(undefined));
|
||||||
|
}, 250);
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
clearTimeout(h);
|
||||||
|
};
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const set = (patch: Partial<State>) => {
|
||||||
|
const next = { ...s, ...patch };
|
||||||
|
// Switching to "custom" starts from the current expression.
|
||||||
|
if (patch.mode === "custom" && s.mode !== "custom") next.custom = buildCron(s);
|
||||||
|
setS(next);
|
||||||
|
onChange(buildCron(next));
|
||||||
|
};
|
||||||
|
|
||||||
|
const modes: Mode[] = ["minutes", "hourly", "daily", "weekly", "monthly", "custom"];
|
||||||
|
return (
|
||||||
|
<div className="cron-builder">
|
||||||
|
<div className="cron-modes" role="radiogroup" aria-label={t("cron.label")}>
|
||||||
|
{modes.map((m) => (
|
||||||
|
<button key={m} type="button" role="radio" aria-checked={s.mode === m} className={`chip-btn ${s.mode === m ? "active" : ""}`} onClick={() => set({ mode: m })}>
|
||||||
|
{t(`cron.mode.${m}` as const)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="cron-fields">
|
||||||
|
{s.mode === "minutes" && (
|
||||||
|
<label>
|
||||||
|
{t("cron.every")}
|
||||||
|
<select value={s.every} onChange={(e) => set({ every: Number(e.target.value) })}>
|
||||||
|
{MINUTE_STEPS.map((n) => (
|
||||||
|
<option key={n} value={n}>
|
||||||
|
{n}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{t("cron.minutes")}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{s.mode === "hourly" && (
|
||||||
|
<label>
|
||||||
|
{t("cron.atMinute")}
|
||||||
|
<input type="number" min={0} max={59} value={s.minute} onChange={(e) => set({ minute: Math.min(59, Math.max(0, Number(e.target.value) || 0)) })} />
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{s.mode === "weekly" && (
|
||||||
|
<div className="cron-days" role="group" aria-label={t("cron.days")}>
|
||||||
|
{WEEK.map((d) => (
|
||||||
|
<button
|
||||||
|
key={d}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={s.days.includes(d)}
|
||||||
|
className={`chip-btn ${s.days.includes(d) ? "active" : ""}`}
|
||||||
|
title={dayName(d, "long")}
|
||||||
|
onClick={() => set({ days: s.days.includes(d) ? s.days.filter((x) => x !== d) : [...s.days, d] })}
|
||||||
|
>
|
||||||
|
{dayName(d, "short")}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button type="button" className="link-btn" onClick={() => set({ days: [1, 2, 3, 4, 5] })}>
|
||||||
|
{t("cron.workdays")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{s.mode === "monthly" && (
|
||||||
|
<label>
|
||||||
|
{t("cron.onDay")}
|
||||||
|
<select value={s.dom} onChange={(e) => set({ dom: Number(e.target.value) })}>
|
||||||
|
{Array.from({ length: 31 }, (_, i) => i + 1).map((d) => (
|
||||||
|
<option key={d} value={d}>
|
||||||
|
{d}.
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{(s.mode === "daily" || s.mode === "weekly" || s.mode === "monthly") && (
|
||||||
|
<label>
|
||||||
|
{t("cron.at")}
|
||||||
|
<input type="time" value={s.time} onChange={(e) => e.target.value && set({ time: e.target.value })} />
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{s.mode === "custom" && (
|
||||||
|
<label className="cron-custom">
|
||||||
|
<input className="mono" value={s.custom} placeholder="0 3 * * *" spellCheck={false} onChange={(e) => set({ custom: e.target.value })} />
|
||||||
|
<small className="hint">{t("cron.customHint")}</small>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="cron-preview">
|
||||||
|
<code>{value || "–"}</code>
|
||||||
|
{preview?.ok === false && <span className="err">{preview.error}</span>}
|
||||||
|
{preview?.ok && preview.next_local && preview.next_local.length > 0 && (
|
||||||
|
<span>
|
||||||
|
{t("cron.nextRuns")} {preview.next_local.slice(0, 3).map(fmtWall).join(" · ")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{preview?.tz && s.mode !== "minutes" && <span className="hint">{t("cron.serverTz", { tz: preview.tz })}</span>}
|
||||||
|
{s.mode === "monthly" && s.dom > 28 && <span className="hint">{t("cron.shortMonths")}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Icon } from "./Icon";
|
import { Icon } from "./Icon";
|
||||||
import { useEffect, useState, type ReactNode } from "react";
|
import { useEffect, useState, type ReactNode } from "react";
|
||||||
|
import type { ImageData } from "../types";
|
||||||
|
|
||||||
/** Human-readable file size. */
|
/** Human-readable file size. */
|
||||||
export function formatSize(n: number) {
|
export function formatSize(n: number) {
|
||||||
@@ -32,6 +33,16 @@ export function basename(p: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Bytes of a base64 payload. */
|
/** Bytes of a base64 payload. */
|
||||||
|
/** URL of an image of a message: inline (data URL) or from the image store. */
|
||||||
|
export function imageSrc(img: ImageData) {
|
||||||
|
return img.data ? `data:${img.media_type};base64,${img.data}` : `/api/blobs/${img.blob}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Size in bytes of an image of a message. */
|
||||||
|
export function imageSize(img: ImageData) {
|
||||||
|
return img.data ? base64Size(img.data) : img.size;
|
||||||
|
}
|
||||||
|
|
||||||
export function base64Size(b64: string) {
|
export function base64Size(b64: string) {
|
||||||
const pad = b64.endsWith("==") ? 2 : b64.endsWith("=") ? 1 : 0;
|
const pad = b64.endsWith("==") ? 2 : b64.endsWith("=") ? 1 : 0;
|
||||||
return Math.floor((b64.length * 3) / 4) - pad;
|
return Math.floor((b64.length * 3) / 4) - pad;
|
||||||
|
|||||||
@@ -0,0 +1,298 @@
|
|||||||
|
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { ApiError, get, patch, post, put } from "../api";
|
||||||
|
import { confirmDialog, toast } from "../dialogs";
|
||||||
|
import { fmtRelative, useT } from "../i18n";
|
||||||
|
import { navigate } from "../router";
|
||||||
|
import { useStore } from "../store";
|
||||||
|
import { Spinner } from "./Activity";
|
||||||
|
import { ChatView } from "./ChatView";
|
||||||
|
import { CronBuilder } from "./CronBuilder";
|
||||||
|
import { Icon } from "./Icon";
|
||||||
|
import type { JobInfo } from "./Jobs";
|
||||||
|
import { ModelSelect } from "./ModelSelect";
|
||||||
|
|
||||||
|
const CodeEditor = lazy(() => import("./CodeEditor").then((m) => ({ default: m.CodeEditor })));
|
||||||
|
|
||||||
|
interface Settings {
|
||||||
|
name: string;
|
||||||
|
schedule: string;
|
||||||
|
agent: string;
|
||||||
|
model: string;
|
||||||
|
permission_mode: string;
|
||||||
|
timeout_minutes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const settingsOf = (j: JobInfo): Settings => ({
|
||||||
|
name: j.spec?.name ?? "",
|
||||||
|
schedule: j.spec?.schedule ?? "0 3 * * *",
|
||||||
|
agent: j.spec?.agent ?? "build",
|
||||||
|
model: j.spec?.model ?? "",
|
||||||
|
permission_mode: j.spec?.permission_mode ?? "auto",
|
||||||
|
timeout_minutes: j.spec?.timeout_minutes ?? 60,
|
||||||
|
});
|
||||||
|
|
||||||
|
type Tab = "prompt" | "settings" | "assistant";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One automation: its instructions (Markdown file) in an editor, schedule and settings,
|
||||||
|
* and a chat with a model that helps to write the instructions (it edits the file).
|
||||||
|
*/
|
||||||
|
export function JobEditor({ id, onMenu }: { id: string; onMenu: () => void }) {
|
||||||
|
const t = useT();
|
||||||
|
const [job, setJob] = useState<JobInfo>();
|
||||||
|
const [settings, setSettings] = useState<Settings>();
|
||||||
|
const [saved, setSaved] = useState<Settings>();
|
||||||
|
const [text, setText] = useState<string>();
|
||||||
|
const [savedText, setSavedText] = useState<string>();
|
||||||
|
const [mtime, setMtime] = useState<number>();
|
||||||
|
const [changedOnDisk, setChangedOnDisk] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [assistant, setAssistant] = useState<string>();
|
||||||
|
const [tab, setTab] = useState<Tab>("prompt");
|
||||||
|
|
||||||
|
const loadJob = useCallback(async () => {
|
||||||
|
const j = await get<JobInfo>(`/jobs/${id}`);
|
||||||
|
setJob(j);
|
||||||
|
const s = settingsOf(j);
|
||||||
|
setSettings((cur) => (cur && saved && JSON.stringify(cur) !== JSON.stringify(saved) ? cur : s));
|
||||||
|
setSaved(s);
|
||||||
|
setAssistant(j.spec?.assistant_session ?? undefined);
|
||||||
|
return j;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const loadFile = useCallback(async (path: string) => {
|
||||||
|
const f = await get<{ content?: string; mtime?: number }>(`/files/read?path=${encodeURIComponent(path)}`);
|
||||||
|
setText(f.content ?? "");
|
||||||
|
setSavedText(f.content ?? "");
|
||||||
|
setMtime(f.mtime);
|
||||||
|
setChangedOnDisk(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadJob()
|
||||||
|
.then((j) => loadFile(j.path))
|
||||||
|
.catch((e) => toast(e.message));
|
||||||
|
}, [loadJob, loadFile]);
|
||||||
|
|
||||||
|
const fileDirty = text !== undefined && text !== savedText;
|
||||||
|
const settingsDirty = !!settings && !!saved && JSON.stringify(settings) !== JSON.stringify(saved);
|
||||||
|
const dirty = fileDirty || settingsDirty;
|
||||||
|
|
||||||
|
// The assistant edits the file: reload it when a turn ends (unless there are unsaved edits).
|
||||||
|
const busy = useStore((s) => (assistant ? !!s.busy[assistant] : false));
|
||||||
|
const wasBusy = useRef(false);
|
||||||
|
const dirtyRef = useRef(fileDirty);
|
||||||
|
dirtyRef.current = fileDirty;
|
||||||
|
useEffect(() => {
|
||||||
|
if (wasBusy.current && !busy && job) {
|
||||||
|
get<{ mtime?: number }>(`/files/stat?path=${encodeURIComponent(job.path)}`)
|
||||||
|
.then((st) => {
|
||||||
|
if (st.mtime && mtime && st.mtime <= mtime) return;
|
||||||
|
if (dirtyRef.current) setChangedOnDisk(true);
|
||||||
|
else void loadFile(job.path);
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
|
wasBusy.current = busy;
|
||||||
|
}, [busy, job, mtime, loadFile]);
|
||||||
|
|
||||||
|
// Unsaved changes: ask before leaving the page.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dirty) return;
|
||||||
|
const warn = (e: BeforeUnloadEvent) => e.preventDefault();
|
||||||
|
window.addEventListener("beforeunload", warn);
|
||||||
|
return () => window.removeEventListener("beforeunload", warn);
|
||||||
|
}, [dirty]);
|
||||||
|
|
||||||
|
const save = async (force = false) => {
|
||||||
|
if (!job || !settings) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
if (settingsDirty) {
|
||||||
|
const j = await patch<JobInfo>(`/jobs/${id}`, settings);
|
||||||
|
setJob(j);
|
||||||
|
const s = settingsOf(j);
|
||||||
|
setSettings(s);
|
||||||
|
setSaved(s);
|
||||||
|
}
|
||||||
|
if (fileDirty) {
|
||||||
|
const r = await put<{ mtime?: number }>("/files/write", { path: job.path, content: text, expected_mtime: mtime, force });
|
||||||
|
setSavedText(text);
|
||||||
|
setMtime(r.mtime);
|
||||||
|
setChangedOnDisk(false);
|
||||||
|
void loadJob();
|
||||||
|
}
|
||||||
|
toast(t("jobEditor.saved"), "success");
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e instanceof ApiError && e.status === 409) {
|
||||||
|
if (await confirmDialog({ message: t("jobEditor.conflict"), confirmLabel: t("jobEditor.overwrite"), danger: true })) return save(true);
|
||||||
|
} else toast(e.message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setEnabled = async (enabled: boolean) => {
|
||||||
|
try {
|
||||||
|
setJob(await patch<JobInfo>(`/jobs/${id}`, { enabled }));
|
||||||
|
} catch (e: any) {
|
||||||
|
toast(e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runNow = async () => {
|
||||||
|
if (dirty && !(await confirmDialog({ message: t("jobEditor.runUnsaved") }))) return;
|
||||||
|
try {
|
||||||
|
const r = await post<{ session_id: string }>(`/jobs/${id}/run`, {});
|
||||||
|
navigate(`/s/${r.session_id}`);
|
||||||
|
} catch (e: any) {
|
||||||
|
toast(e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startAssistant = async () => {
|
||||||
|
try {
|
||||||
|
const r = await post<{ session_id: string }>(`/jobs/${id}/assistant`, {});
|
||||||
|
setAssistant(r.session_id);
|
||||||
|
setTab("assistant");
|
||||||
|
} catch (e: any) {
|
||||||
|
toast(e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const back = async () => {
|
||||||
|
if (dirty && !(await confirmDialog({ message: t("jobEditor.discard"), danger: true }))) return;
|
||||||
|
navigate("/jobs");
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!job || !settings) return <div className="empty"><Spinner /></div>;
|
||||||
|
const up = (p: Partial<Settings>) => setSettings({ ...settings, ...p });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="topbar">
|
||||||
|
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
|
||||||
|
<Icon name="menu" />
|
||||||
|
</button>
|
||||||
|
<button className="small ghost" onClick={back} title={t("jobEditor.back")} aria-label={t("jobEditor.back")}>
|
||||||
|
<Icon name="arrow-left" />
|
||||||
|
</button>
|
||||||
|
<div className="grow">
|
||||||
|
<h1>{settings.name || t("nav.jobs")}</h1>
|
||||||
|
<div className="path mono">{job.path}</div>
|
||||||
|
</div>
|
||||||
|
<label className="switch" title={t(job.enabled ? "jobs.disable" : "jobs.enable")}>
|
||||||
|
<input type="checkbox" checked={job.enabled} onChange={(e) => void setEnabled(e.target.checked)} />
|
||||||
|
<span>{t(job.enabled ? "jobs.on" : "jobs.off")}</span>
|
||||||
|
</label>
|
||||||
|
<button className="small ghost" onClick={runNow} title={t("jobs.runNow")}>
|
||||||
|
<Icon name="play" /> <span className="hide-mobile">{t("jobs.runNow")}</span>
|
||||||
|
</button>
|
||||||
|
<button className="small primary" disabled={!dirty || saving} onClick={() => void save()}>
|
||||||
|
{saving ? <Spinner small /> : <Icon name="check" />} {t("common.save")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="job-tabs" role="tablist">
|
||||||
|
{(["prompt", "settings", "assistant"] as Tab[]).map((x) => (
|
||||||
|
<button key={x} role="tab" aria-selected={tab === x} className={tab === x ? "active" : ""} onClick={() => setTab(x)}>
|
||||||
|
{t(`jobEditor.tab.${x}` as const)}
|
||||||
|
{x === "prompt" && fileDirty && " •"}
|
||||||
|
{x === "settings" && settingsDirty && " •"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className={`job-editor tab-${tab}`}>
|
||||||
|
<div className="job-editor-main">
|
||||||
|
<section className="job-editor-settings panel">
|
||||||
|
<div className="job-editor-row">
|
||||||
|
<label className="field" style={{ flex: 2 }}>
|
||||||
|
<span>{t("jobs.tplName")}</span>
|
||||||
|
<input value={settings.name} onChange={(e) => up({ name: e.target.value })} />
|
||||||
|
</label>
|
||||||
|
<div className="job-editor-status">
|
||||||
|
<span>
|
||||||
|
{t("jobs.next")}: {job.enabled && job.next_run ? new Date(job.next_run).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }) : "–"}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{t("jobs.last")}: {job.last_run ? fmtRelative(job.last_run) : "–"}
|
||||||
|
{job.last_session_id && (
|
||||||
|
<button className="link-btn" style={{ marginLeft: 6 }} onClick={() => navigate(`/s/${job.last_session_id}`)}>
|
||||||
|
{t("jobs.openRun")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<span>{t("jobs.schedule")}</span>
|
||||||
|
<CronBuilder value={settings.schedule} onChange={(schedule) => up({ schedule })} />
|
||||||
|
</div>
|
||||||
|
<div className="job-editor-row">
|
||||||
|
<label className="field">
|
||||||
|
<span>{t("jobs.tplMode")}</span>
|
||||||
|
<select value={settings.agent} onChange={(e) => up({ agent: e.target.value })}>
|
||||||
|
<option value="build">build</option>
|
||||||
|
<option value="plan">plan</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t("jobs.permissionMode")}</span>
|
||||||
|
<select value={settings.permission_mode} title={t("jobs.permissionModeHint")} onChange={(e) => up({ permission_mode: e.target.value })}>
|
||||||
|
<option value="default">{t("permissionMode.default")}</option>
|
||||||
|
<option value="auto">{t("permissionMode.auto")}</option>
|
||||||
|
<option value="accept_edits">{t("permissionMode.accept_edits")}</option>
|
||||||
|
<option value="bypass">{t("permissionMode.bypass")}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t("composer.model")}</span>
|
||||||
|
<ModelSelect value={settings.model} onChange={(model) => up({ model })} emptyLabel={t("jobEditor.defaultModel")} />
|
||||||
|
</label>
|
||||||
|
<label className="field" style={{ maxWidth: 140 }}>
|
||||||
|
<span>{t("jobEditor.timeout")}</span>
|
||||||
|
<input type="number" min={1} max={1440} value={settings.timeout_minutes} onChange={(e) => up({ timeout_minutes: Math.max(1, Number(e.target.value) || 60) })} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{settings.permission_mode !== "bypass" && <div className="hint">{t("jobEditor.bypassHint")}</div>}
|
||||||
|
</section>
|
||||||
|
<section className="job-editor-prompt">
|
||||||
|
<div className="job-editor-prompt-head">
|
||||||
|
<b>{t("jobEditor.instructions")}</b>
|
||||||
|
<span className="hint">{t("jobEditor.instructionsHint")}</span>
|
||||||
|
</div>
|
||||||
|
{changedOnDisk && (
|
||||||
|
<div className="notice">
|
||||||
|
{t("jobEditor.changedOnDisk")}{" "}
|
||||||
|
<button className="small" onClick={() => void loadFile(job.path)}>
|
||||||
|
{t("jobEditor.reload")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{job.error && !job.error.includes("empty") && <div className="notice err">{job.error}</div>}
|
||||||
|
{text === undefined ? (
|
||||||
|
<div className="empty"><Spinner /></div>
|
||||||
|
) : (
|
||||||
|
<Suspense fallback={<div className="empty"><Spinner /></div>}>
|
||||||
|
<CodeEditor value={text} filename={job.path} onChange={setText} onSave={() => void save()} />
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<aside className="job-editor-chat">
|
||||||
|
{assistant ? (
|
||||||
|
<ChatView key={assistant} sessionId={assistant} onMenu={onMenu} />
|
||||||
|
) : (
|
||||||
|
<div className="job-assistant-start">
|
||||||
|
<Icon name="sparkle" />
|
||||||
|
<p>{t("jobEditor.assistantIntro")}</p>
|
||||||
|
<button className="primary" onClick={startAssistant}>
|
||||||
|
{t("jobEditor.assistantStart")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useFetch } from "../hooks";
|
|
||||||
import { post } from "../api";
|
|
||||||
import { getLang, useT } from "../i18n";
|
|
||||||
import { JOB_TEMPLATES, SCHEDULES, type JobTemplate } from "../jobTemplates";
|
|
||||||
import { Icon } from "./Icon";
|
|
||||||
import { ModelSelect } from "./ModelSelect";
|
|
||||||
import { FolderPicker } from "./NewSession";
|
|
||||||
|
|
||||||
/** Create an automation from a template: pick it, choose the project, adjust, create. */
|
|
||||||
export function JobTemplateDialog({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
|
|
||||||
const t = useT();
|
|
||||||
const de = getLang() === "de";
|
|
||||||
const [tpl, setTpl] = useState<JobTemplate>();
|
|
||||||
const [name, setName] = useState("");
|
|
||||||
const [schedule, setSchedule] = useState("");
|
|
||||||
const [agent, setAgent] = useState("build");
|
|
||||||
const [mode, setMode] = useState("auto");
|
|
||||||
const [model, setModel] = useState("");
|
|
||||||
const [prompt, setPrompt] = useState("");
|
|
||||||
const [cwd, setCwd] = useState(localStorage.getItem("codecraft.lastDir") ?? "");
|
|
||||||
const [error, setError] = useState<string>();
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
const config = useFetch<{ default_model?: string }>("/config").data;
|
|
||||||
useEffect(() => {
|
|
||||||
if (!model && config?.default_model) setModel(config.default_model);
|
|
||||||
}, [config, model]);
|
|
||||||
|
|
||||||
const choose = (x: JobTemplate) => {
|
|
||||||
setTpl(x);
|
|
||||||
setName(de ? x.name.de : x.name.en);
|
|
||||||
setSchedule(x.schedule);
|
|
||||||
setAgent(x.agent);
|
|
||||||
setPrompt(x.prompt);
|
|
||||||
};
|
|
||||||
|
|
||||||
const create = async () => {
|
|
||||||
setBusy(true);
|
|
||||||
setError(undefined);
|
|
||||||
try {
|
|
||||||
await post("/jobs/create", { cwd, name: name.trim(), schedule: schedule.trim(), agent, permission_mode: mode, model: model || undefined, prompt });
|
|
||||||
onCreated();
|
|
||||||
onClose();
|
|
||||||
} catch (e: any) {
|
|
||||||
setError(e.message);
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="modal-backdrop" onClick={onClose}>
|
|
||||||
<div className="modal job-tpl" role="dialog" aria-label={t("jobs.fromTemplate")} onClick={(e) => e.stopPropagation()}>
|
|
||||||
{!tpl ? (
|
|
||||||
<>
|
|
||||||
<h3>{t("jobs.fromTemplate")}</h3>
|
|
||||||
<div className="job-tpl-grid">
|
|
||||||
{JOB_TEMPLATES.map((x) => (
|
|
||||||
<button key={x.id} className="job-tpl-card" onClick={() => choose(x)}>
|
|
||||||
<Icon name={x.icon} />
|
|
||||||
<span>
|
|
||||||
<b>{de ? x.name.de : x.name.en}</b>
|
|
||||||
<small>{de ? x.desc.de : x.desc.en}</small>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="row" style={{ marginTop: 12, justifyContent: "flex-end" }}>
|
|
||||||
<button onClick={onClose}>{t("common.cancel")}</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<h3>
|
|
||||||
<Icon name={tpl.icon} /> {de ? tpl.name.de : tpl.name.en}
|
|
||||||
</h3>
|
|
||||||
<label className="field">
|
|
||||||
<span>{t("jobs.tplName")}</span>
|
|
||||||
<input value={name} onChange={(e) => setName(e.target.value)} />
|
|
||||||
</label>
|
|
||||||
<div className="field">
|
|
||||||
<span>{t("jobs.tplProject")}</span>
|
|
||||||
<FolderPicker path={cwd} onPick={setCwd} />
|
|
||||||
</div>
|
|
||||||
<div className="field">
|
|
||||||
<span>{t("jobs.tplSchedule")}</span>
|
|
||||||
<div className="row" style={{ gap: 6, flexWrap: "wrap" }}>
|
|
||||||
{SCHEDULES.map((s) => (
|
|
||||||
<button key={s.cron} type="button" className={`chip-btn ${schedule === s.cron ? "active" : ""}`} onClick={() => setSchedule(s.cron)}>
|
|
||||||
{de ? s.de : s.en}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<input className="mono" value={schedule} onChange={(e) => setSchedule(e.target.value)} placeholder="0 3 * * *" />
|
|
||||||
<small className="hint">{t("jobs.tplCronHint")}</small>
|
|
||||||
</div>
|
|
||||||
<div className="row" style={{ gap: 10, flexWrap: "wrap" }}>
|
|
||||||
<label className="field">
|
|
||||||
<span>{t("jobs.tplMode")}</span>
|
|
||||||
<select value={agent} onChange={(e) => setAgent(e.target.value)}>
|
|
||||||
<option value="build">build</option>
|
|
||||||
<option value="plan">plan</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label className="field">
|
|
||||||
<span>{t("composer.permissionMode")}</span>
|
|
||||||
<select value={mode} onChange={(e) => setMode(e.target.value)}>
|
|
||||||
<option value="auto">{t("permissionMode.auto")}</option>
|
|
||||||
<option value="accept_edits">{t("permissionMode.accept_edits")}</option>
|
|
||||||
<option value="bypass">{t("permissionMode.bypass")}</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label className="field">
|
|
||||||
<span>{t("composer.model")}</span>
|
|
||||||
<ModelSelect value={model} onChange={setModel} />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<label className="field">
|
|
||||||
<span>{t("jobs.tplPrompt")}</span>
|
|
||||||
<textarea rows={6} value={prompt} onChange={(e) => setPrompt(e.target.value)} />
|
|
||||||
</label>
|
|
||||||
{error && <div className="notice err">{error}</div>}
|
|
||||||
<div className="row" style={{ marginTop: 12, justifyContent: "flex-end" }}>
|
|
||||||
<button onClick={() => setTpl(undefined)}>{t("common.back")}</button>
|
|
||||||
<button className="primary" disabled={busy || !cwd || !name.trim() || !schedule.trim() || !prompt.trim()} onClick={create}>
|
|
||||||
{t("jobs.tplCreate")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+16
-25
@@ -5,9 +5,9 @@ import { navigate } from "../router";
|
|||||||
import { confirmDialog, toast } from "../dialogs";
|
import { confirmDialog, toast } from "../dialogs";
|
||||||
import { Icon } from "./Icon";
|
import { Icon } from "./Icon";
|
||||||
import { Spinner } from "./Activity";
|
import { Spinner } from "./Activity";
|
||||||
import { JobTemplateDialog } from "./JobTemplateDialog";
|
import { NewJobDialog } from "./NewJobDialog";
|
||||||
|
|
||||||
interface JobInfo {
|
export interface JobInfo {
|
||||||
id: string;
|
id: string;
|
||||||
path: string;
|
path: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -17,12 +17,12 @@ interface JobInfo {
|
|||||||
next_run?: number | null;
|
next_run?: number | null;
|
||||||
error?: string;
|
error?: string;
|
||||||
webhook?: boolean;
|
webhook?: boolean;
|
||||||
spec?: { name: string; schedule: string; agent: string; model?: string | null; permission_mode: string; timeout_minutes: number; prompt: string };
|
spec?: { name: string; schedule: string; agent: string; model?: string | null; permission_mode: string; timeout_minutes: number; prompt: string; assistant_session?: string | null };
|
||||||
}
|
}
|
||||||
|
|
||||||
const fmtDate = (ms?: number | null) => (ms ? new Date(ms).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }) : "–");
|
const fmtDate = (ms?: number | null) => (ms ? new Date(ms).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }) : "–");
|
||||||
|
|
||||||
/** Automations: agents started on a schedule from a Markdown job file. */
|
/** Automations: agents started on a schedule with the instructions from a Markdown file. */
|
||||||
export function Jobs({ onMenu }: { onMenu: () => void }) {
|
export function Jobs({ onMenu }: { onMenu: () => void }) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const [jobs, setJobs] = useState<JobInfo[]>();
|
const [jobs, setJobs] = useState<JobInfo[]>();
|
||||||
@@ -68,10 +68,10 @@ export function Jobs({ onMenu }: { onMenu: () => void }) {
|
|||||||
<div className="notice">{t("jobs.intro")}</div>
|
<div className="notice">{t("jobs.intro")}</div>
|
||||||
<div className="row" style={{ margin: "12px 0" }}>
|
<div className="row" style={{ margin: "12px 0" }}>
|
||||||
<button className="primary" onClick={() => setTemplates(true)}>
|
<button className="primary" onClick={() => setTemplates(true)}>
|
||||||
<Icon name="plus" /> {t("jobs.fromTemplate")}
|
<Icon name="plus" /> {t("jobs.new")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{templates && <JobTemplateDialog onClose={() => setTemplates(false)} onCreated={() => void reload()} />}
|
{templates && <NewJobDialog onClose={() => setTemplates(false)} />}
|
||||||
{!jobs && <div className="empty"><Spinner /></div>}
|
{!jobs && <div className="empty"><Spinner /></div>}
|
||||||
{jobs?.length === 0 && <div className="empty">{t("jobs.none")}</div>}
|
{jobs?.length === 0 && <div className="empty">{t("jobs.none")}</div>}
|
||||||
{jobs?.map((j) => (
|
{jobs?.map((j) => (
|
||||||
@@ -79,7 +79,11 @@ export function Jobs({ onMenu }: { onMenu: () => void }) {
|
|||||||
<div className="job-head">
|
<div className="job-head">
|
||||||
<Icon name="clock" className="job-icon" />
|
<Icon name="clock" className="job-icon" />
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<h3>{j.spec?.name ?? j.path.split("/").pop()}</h3>
|
<h3>
|
||||||
|
<button className="link-btn job-title" onClick={() => navigate(`/jobs/${j.id}`)}>
|
||||||
|
{j.spec?.name ?? j.path.split("/").pop()}
|
||||||
|
</button>
|
||||||
|
</h3>
|
||||||
<div className="hint mono">{j.path}</div>
|
<div className="hint mono">{j.path}</div>
|
||||||
</div>
|
</div>
|
||||||
<label className="switch" title={t(j.enabled ? "jobs.disable" : "jobs.enable")}>
|
<label className="switch" title={t(j.enabled ? "jobs.disable" : "jobs.enable")}>
|
||||||
@@ -107,7 +111,7 @@ export function Jobs({ onMenu }: { onMenu: () => void }) {
|
|||||||
</span>
|
</span>
|
||||||
<span>{t("jobs.mode")}</span>
|
<span>{t("jobs.mode")}</span>
|
||||||
<span>
|
<span>
|
||||||
{j.spec?.agent} · {j.spec?.model || t("agents.sessionModel")} · {j.spec?.permission_mode} · max {j.spec?.timeout_minutes} min
|
{j.spec?.agent} · {j.spec?.model || t("agents.sessionModel")} · {t(`permissionMode.${j.spec?.permission_mode ?? "auto"}` as any)} · max {j.spec?.timeout_minutes} min
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -163,14 +167,8 @@ export function Jobs({ onMenu }: { onMenu: () => void }) {
|
|||||||
})}>
|
})}>
|
||||||
{busy === j.id ? <Spinner small /> : <Icon name="play" />} {t("jobs.runNow")}
|
{busy === j.id ? <Spinner small /> : <Icon name="play" />} {t("jobs.runNow")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button className="small" onClick={() => navigate(`/jobs/${j.id}`)}>
|
||||||
className="small ghost"
|
<Icon name="edit" /> {t("jobs.edit")}
|
||||||
onClick={() => {
|
|
||||||
sessionStorage.setItem("codecraft.openFile", j.path);
|
|
||||||
navigate("/files");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon name="edit" /> {t("jobs.editFile")}
|
|
||||||
</button>
|
</button>
|
||||||
<span className="grow" />
|
<span className="grow" />
|
||||||
<button
|
<button
|
||||||
@@ -190,20 +188,13 @@ export function Jobs({ onMenu }: { onMenu: () => void }) {
|
|||||||
<div className="row" style={{ gap: 8 }}>
|
<div className="row" style={{ gap: 8 }}>
|
||||||
<input className="mono" style={{ flex: 1, minWidth: 0 }} value={path} placeholder="/workspace/project/.codecraft/jobs/nightly.md" onChange={(e) => setPath(e.target.value)} />
|
<input className="mono" style={{ flex: 1, minWidth: 0 }} value={path} placeholder="/workspace/project/.codecraft/jobs/nightly.md" onChange={(e) => setPath(e.target.value)} />
|
||||||
<button disabled={!path.trim()} onClick={() => act("new", async () => {
|
<button disabled={!path.trim()} onClick={() => act("new", async () => {
|
||||||
await post("/jobs", { path: path.trim() });
|
const j = await post<JobInfo>("/jobs", { path: path.trim() });
|
||||||
setPath("");
|
setPath("");
|
||||||
|
navigate(`/jobs/${j.id}`);
|
||||||
})}>
|
})}>
|
||||||
{t("jobs.registerBtn")}
|
{t("jobs.registerBtn")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<pre className="job-example">{`---
|
|
||||||
name: "Nightly dependency check"
|
|
||||||
schedule: "0 3 * * *"
|
|
||||||
agent: build
|
|
||||||
permission_mode: auto
|
|
||||||
timeout_minutes: 60
|
|
||||||
---
|
|
||||||
Check the project for outdated dependencies …`}</pre>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { post } from "../api";
|
||||||
|
import { getLang, useT } from "../i18n";
|
||||||
|
import { JOB_TEMPLATES, type JobTemplate } from "../jobTemplates";
|
||||||
|
import { navigate } from "../router";
|
||||||
|
import { Icon } from "./Icon";
|
||||||
|
import { FolderPicker } from "./NewSession";
|
||||||
|
|
||||||
|
/** Starting point of a new instructions file: headings to fill in (with the assistant). */
|
||||||
|
function example(name: string, de: boolean) {
|
||||||
|
return de
|
||||||
|
? `# ${name}
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
Was soll diese Automatisierung erreichen? (ein, zwei Sätze)
|
||||||
|
|
||||||
|
## Vorgehen
|
||||||
|
1. …
|
||||||
|
2. …
|
||||||
|
|
||||||
|
## Ergebnis
|
||||||
|
Was am Ende vorliegen soll – z. B. ein kurzer Bericht, geänderte Dateien, ein Commit.
|
||||||
|
|
||||||
|
## Grenzen
|
||||||
|
- Was der Agent nicht tun darf (z. B. nichts pushen, nichts löschen).
|
||||||
|
`
|
||||||
|
: `# ${name}
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
What should this automation achieve? (one or two sentences)
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
1. …
|
||||||
|
2. …
|
||||||
|
|
||||||
|
## Result
|
||||||
|
What should exist at the end – e.g. a short report, changed files, a commit.
|
||||||
|
|
||||||
|
## Limits
|
||||||
|
- What the agent must not do (e.g. no pushing, no deleting).
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* New automation: name, project and a starting point (example or template). Creates the
|
||||||
|
* instructions file (<project>/.codecraft/jobs/<name>.md, switched off) and opens the
|
||||||
|
* editor, where the schedule is set and the instructions are worked out.
|
||||||
|
*/
|
||||||
|
export function NewJobDialog({ onClose }: { onClose: () => void }) {
|
||||||
|
const t = useT();
|
||||||
|
const de = getLang() === "de";
|
||||||
|
const [tpl, setTpl] = useState<JobTemplate>();
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [cwd, setCwd] = useState(localStorage.getItem("codecraft.lastDir") ?? "");
|
||||||
|
const [error, setError] = useState<string>();
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const choose = (x?: JobTemplate) => {
|
||||||
|
setTpl(x);
|
||||||
|
if (x && (!name.trim() || JOB_TEMPLATES.some((y) => name === y.name.de || name === y.name.en))) setName(de ? x.name.de : x.name.en);
|
||||||
|
};
|
||||||
|
|
||||||
|
const create = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(undefined);
|
||||||
|
try {
|
||||||
|
const n = name.trim();
|
||||||
|
const job = await post<{ id: string }>("/jobs/create", {
|
||||||
|
cwd,
|
||||||
|
name: n,
|
||||||
|
prompt: tpl ? `# ${n}\n\n${tpl.prompt}\n` : example(n, de),
|
||||||
|
schedule: tpl?.schedule,
|
||||||
|
agent: tpl?.agent,
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
navigate(`/jobs/${job.id}`);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop" onClick={onClose}>
|
||||||
|
<div className="modal job-tpl" role="dialog" aria-label={t("jobs.new")} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h3>{t("jobs.new")}</h3>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t("jobs.tplName")}</span>
|
||||||
|
<input autoFocus value={name} placeholder={t("jobs.namePlaceholder")} onChange={(e) => setName(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<div className="field">
|
||||||
|
<span>{t("jobs.tplProject")}</span>
|
||||||
|
<FolderPicker path={cwd} onPick={setCwd} />
|
||||||
|
<small className="hint">{t("jobs.fileHint")}</small>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<span>{t("jobs.startWith")}</span>
|
||||||
|
<div className="job-tpl-grid">
|
||||||
|
<button className={`job-tpl-card ${!tpl ? "active" : ""}`} onClick={() => choose(undefined)}>
|
||||||
|
<Icon name="file-text" />
|
||||||
|
<span>
|
||||||
|
<b>{t("jobs.blank")}</b>
|
||||||
|
<small>{t("jobs.blankDesc")}</small>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{JOB_TEMPLATES.map((x) => (
|
||||||
|
<button key={x.id} className={`job-tpl-card ${tpl?.id === x.id ? "active" : ""}`} onClick={() => choose(x)}>
|
||||||
|
<Icon name={x.icon} />
|
||||||
|
<span>
|
||||||
|
<b>{de ? x.name.de : x.name.en}</b>
|
||||||
|
<small>{de ? x.desc.de : x.desc.en}</small>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && <div className="notice err">{error}</div>}
|
||||||
|
<div className="row" style={{ marginTop: 12, justifyContent: "flex-end" }}>
|
||||||
|
<button onClick={onClose}>{t("common.cancel")}</button>
|
||||||
|
<button className="primary" disabled={busy || !cwd || !name.trim()} onClick={create}>
|
||||||
|
{t("jobs.createAndEdit")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import { Icon } from "./Icon";
|
import { Icon } from "./Icon";
|
||||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||||
import { post, uploadFile } from "../api";
|
import { post, uploadFile } from "../api";
|
||||||
|
import { promptDialog, toast } from "../dialogs";
|
||||||
import { useAgents, useFetch } from "../hooks";
|
import { useAgents, useFetch } from "../hooks";
|
||||||
import { useT, type Key } from "../i18n";
|
import { useT, type Key } from "../i18n";
|
||||||
import type { ImageData } from "../types";
|
import type { ImageData } from "../types";
|
||||||
import { readImage } from "./Composer";
|
import { readImage } from "./Composer";
|
||||||
import { formatSize } from "./FileFigure";
|
import { formatSize, imageSrc } from "./FileFigure";
|
||||||
import { ModelSelect } from "./ModelSelect";
|
import { ModelSelect } from "./ModelSelect";
|
||||||
import { takePrefill } from "../share";
|
import { takePrefill } from "../share";
|
||||||
|
|
||||||
@@ -478,7 +479,7 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
|
|||||||
<div className="attachments">
|
<div className="attachments">
|
||||||
{images.map((img, i) => (
|
{images.map((img, i) => (
|
||||||
<div className="att" key={`i${i}`}>
|
<div className="att" key={`i${i}`}>
|
||||||
<img src={`data:${img.media_type};base64,${img.data}`} alt="" />
|
<img src={imageSrc(img)} alt="" />
|
||||||
<button onClick={() => setImages((c) => c.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
|
<button onClick={() => setImages((c) => c.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
|
||||||
<Icon name="close" />
|
<Icon name="close" />
|
||||||
</button>
|
</button>
|
||||||
@@ -602,6 +603,20 @@ export function FolderPicker({ path, onPick }: { path: string; onPick: (p: strin
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [listing?.path]);
|
}, [listing?.path]);
|
||||||
const rel = listing ? listing.path.slice(listing.workspace.length) || "/" : "";
|
const rel = listing ? listing.path.slice(listing.workspace.length) || "/" : "";
|
||||||
|
/** Create a subfolder of the shown folder and select it. */
|
||||||
|
const newFolder = async () => {
|
||||||
|
if (!listing) return;
|
||||||
|
const name = (await promptDialog({ title: t("files.newFolder"), message: t("files.newFolderName") }))?.trim();
|
||||||
|
if (!name) return;
|
||||||
|
if (name.includes("/") || name === "." || name === "..") return toast(t("new.invalidFolderName"));
|
||||||
|
const target = `${listing.path.replace(/\/$/, "")}/${name}`;
|
||||||
|
try {
|
||||||
|
await post("/files/create", { path: target, dir: true });
|
||||||
|
setBrowse(target);
|
||||||
|
} catch (e: any) {
|
||||||
|
toast(e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<div className="wiz-picker">
|
<div className="wiz-picker">
|
||||||
{roots.length > 1 && (
|
{roots.length > 1 && (
|
||||||
@@ -622,6 +637,11 @@ export function FolderPicker({ path, onPick }: { path: string; onPick: (p: strin
|
|||||||
<Icon name="arrow-up" /> {t("new.up")}
|
<Icon name="arrow-up" /> {t("new.up")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{listing && (
|
||||||
|
<button className="small" onClick={() => void newFolder()}>
|
||||||
|
<Icon name="plus" /> {t("files.newFolder")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="dir-list">
|
<div className="dir-list">
|
||||||
{listing?.dirs.length === 0 && <div className="empty" style={{ padding: 16 }}>{t("new.noSubdirs")}</div>}
|
{listing?.dirs.length === 0 && <div className="empty" style={{ padding: 16 }}>{t("new.noSubdirs")}</div>}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { del, post } from "../api";
|
|||||||
import { useT } from "../i18n";
|
import { useT } from "../i18n";
|
||||||
import { useStore } from "../store";
|
import { useStore } from "../store";
|
||||||
import type { QueueItem } from "../types";
|
import type { QueueItem } from "../types";
|
||||||
|
import { imageSrc } from "./FileFigure";
|
||||||
import { confirmDialog, promptDialog, toast } from "../dialogs";
|
import { confirmDialog, promptDialog, toast } from "../dialogs";
|
||||||
|
|
||||||
const EMPTY: QueueItem[] = [];
|
const EMPTY: QueueItem[] = [];
|
||||||
@@ -30,7 +31,7 @@ export function QueueList({ sessionId, busy }: { sessionId: string; busy: boolea
|
|||||||
{it.source === "background" && <span className="chip">{t("tool.background")}</span>}
|
{it.source === "background" && <span className="chip">{t("tool.background")}</span>}
|
||||||
<span className="queue-text">{it.source === "background" ? t("queue.backgroundResult") : text || "—"}</span>
|
<span className="queue-text">{it.source === "background" ? t("queue.backgroundResult") : text || "—"}</span>
|
||||||
{images.map((p, i) => (
|
{images.map((p, i) => (
|
||||||
<img key={i} src={`data:${p.image.media_type};base64,${p.image.data}`} alt="" />
|
<img key={i} src={imageSrc(p.image)} alt="" />
|
||||||
))}
|
))}
|
||||||
{it.source !== "background" && (
|
{it.source !== "background" && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
+197
-11
@@ -2,7 +2,7 @@ import { Icon } from "./Icon";
|
|||||||
import { StatusLeds } from "./StatusLeds";
|
import { StatusLeds } from "./StatusLeds";
|
||||||
import { cycleThemeMode, getThemeMode, onThemeChange } from "../theme";
|
import { cycleThemeMode, getThemeMode, onThemeChange } from "../theme";
|
||||||
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
||||||
import { get, patch, post } from "../api";
|
import { get, patch, post, put } from "../api";
|
||||||
import { promptDialog, toast } from "../dialogs";
|
import { promptDialog, toast } from "../dialogs";
|
||||||
import { fmtRelative, useT, type Key } from "../i18n";
|
import { fmtRelative, useT, type Key } from "../i18n";
|
||||||
import { navigate, openBeside } from "../router";
|
import { navigate, openBeside } from "../router";
|
||||||
@@ -102,6 +102,113 @@ function SessionRow({ s, active, attention, busy, folders }: { s: Session; activ
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Order and pinning of folders and projects in the list (per user, all devices). */
|
||||||
|
interface Layout {
|
||||||
|
order: string[];
|
||||||
|
pinned: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Folders and projects can be moved and pinned; time groups and the archive cannot. */
|
||||||
|
const movable = (id: string) => id.startsWith("folder:") || id.startsWith("project:");
|
||||||
|
|
||||||
|
const DRAG_TYPE = "application/x-codecraft-group";
|
||||||
|
|
||||||
|
/** Header of a group: collapses it; folders and projects also have a menu and can be dragged. */
|
||||||
|
function GroupHeader({
|
||||||
|
g,
|
||||||
|
collapsed,
|
||||||
|
pinned,
|
||||||
|
onToggle,
|
||||||
|
onPin,
|
||||||
|
onMove,
|
||||||
|
onDrop,
|
||||||
|
}: {
|
||||||
|
g: { id: string; label: string; icon?: string; items: Session[] };
|
||||||
|
collapsed: boolean;
|
||||||
|
pinned: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
onPin?: () => void;
|
||||||
|
onMove?: (dir: -1 | 1) => void;
|
||||||
|
onDrop?: (dragged: string, after: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const t = useT();
|
||||||
|
const [menu, setMenu] = useState(false);
|
||||||
|
const [over, setOver] = useState<"before" | "after">();
|
||||||
|
useEffect(() => {
|
||||||
|
if (!menu) return;
|
||||||
|
const close = () => setMenu(false);
|
||||||
|
window.addEventListener("click", close);
|
||||||
|
window.addEventListener("keydown", close);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("click", close);
|
||||||
|
window.removeEventListener("keydown", close);
|
||||||
|
};
|
||||||
|
}, [menu]);
|
||||||
|
const canMove = !!onMove;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`session-row group-row ${menu ? "menu-open" : ""} ${over ? `drop-${over}` : ""}`}
|
||||||
|
draggable={canMove}
|
||||||
|
onDragStart={(e) => {
|
||||||
|
if (!canMove) return;
|
||||||
|
e.dataTransfer.setData(DRAG_TYPE, g.id);
|
||||||
|
e.dataTransfer.effectAllowed = "move";
|
||||||
|
}}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
if (!onDrop || !e.dataTransfer.types.includes(DRAG_TYPE)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const r = e.currentTarget.getBoundingClientRect();
|
||||||
|
setOver(e.clientY > r.top + r.height / 2 ? "after" : "before");
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setOver(undefined)}
|
||||||
|
onDrop={(e) => {
|
||||||
|
const id = e.dataTransfer.getData(DRAG_TYPE);
|
||||||
|
const after = over === "after";
|
||||||
|
setOver(undefined);
|
||||||
|
if (id && id !== g.id && onDrop) {
|
||||||
|
e.preventDefault();
|
||||||
|
onDrop(id, after);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button className="session-group" onClick={onToggle} aria-expanded={!collapsed}>
|
||||||
|
<Icon name={collapsed ? "chevron-right" : "chevron-down"} className="tiny" />
|
||||||
|
{g.icon && <Icon name={g.icon} className="tiny" />}
|
||||||
|
<span className="grow">{g.label}</span>
|
||||||
|
{pinned && <Icon name="pin" className="tiny group-pin" />}
|
||||||
|
{collapsed && <span className="count">{g.items.length}</span>}
|
||||||
|
</button>
|
||||||
|
{canMove && (
|
||||||
|
<button
|
||||||
|
className="session-more ghost"
|
||||||
|
aria-label={t("sidebar.groupMenu")}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={menu}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setMenu(!menu);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="more" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{menu && (
|
||||||
|
<div className="session-menu" role="menu" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button role="menuitem" onClick={() => (setMenu(false), onPin?.())}>
|
||||||
|
<Icon name="pin" /> {t(pinned ? "sidebar.groupUnpin" : "sidebar.groupPin")}
|
||||||
|
</button>
|
||||||
|
<button role="menuitem" onClick={() => (setMenu(false), onMove?.(-1))}>
|
||||||
|
<Icon name="arrow-up" /> {t("sidebar.groupUp")}
|
||||||
|
</button>
|
||||||
|
<button role="menuitem" onClick={() => (setMenu(false), onMove?.(1))}>
|
||||||
|
<Icon name="arrow-down" /> {t("sidebar.groupDown")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function Sidebar({ open, activeIds, section }: { open: boolean; activeIds: string[]; section: string }) {
|
export function Sidebar({ open, activeIds, section }: { open: boolean; activeIds: string[]; section: string }) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const sessions = useStore((s) => s.sessions);
|
const sessions = useStore((s) => s.sessions);
|
||||||
@@ -164,14 +271,15 @@ export function Sidebar({ open, activeIds, section }: { open: boolean; activeIds
|
|||||||
const live = sessions.filter((s) => !s.archived);
|
const live = sessions.filter((s) => !s.archived);
|
||||||
for (const s of live.filter((s) => s.pinned)) add("pinned", t("sidebar.pinned"), s, "pin");
|
for (const s of live.filter((s) => s.pinned)) add("pinned", t("sidebar.pinned"), s, "pin");
|
||||||
const rest = live.filter((s) => !s.pinned);
|
const rest = live.filter((s) => !s.pinned);
|
||||||
const folders = [...new Set(rest.map((s) => s.folder).filter(Boolean) as string[])].sort((a, b) => a.localeCompare(b));
|
const folders = [...new Set(rest.map((s) => s.folder).filter(Boolean) as string[])];
|
||||||
for (const f of folders) for (const s of rest.filter((s) => s.folder === f)) add(`folder:${f}`, f, s, "folder");
|
for (const f of folders) for (const s of rest.filter((s) => s.folder === f)) add(`folder:${f}`, f, s, "folder");
|
||||||
const loose = rest.filter((s) => !s.folder);
|
const loose = rest.filter((s) => !s.folder);
|
||||||
if (groupBy === "project") {
|
if (groupBy === "project") {
|
||||||
const byProject = new Map<string, Session[]>();
|
const byProject = new Map<string, Session[]>();
|
||||||
for (const s of loose) byProject.set(s.cwd, [...(byProject.get(s.cwd) ?? []), s]);
|
for (const s of loose) byProject.set(s.cwd, [...(byProject.get(s.cwd) ?? []), s]);
|
||||||
// Projects with the most recent activity first.
|
// Order of the projects: see `ordered` below (not by activity – a new message
|
||||||
for (const [cwd, items] of [...byProject].sort((a, b) => b[1][0].updated_at - a[1][0].updated_at)) for (const s of items) add(`project:${cwd}`, basename(cwd) || "/", s, "folders");
|
// moves the chat to the top of its project, not the project).
|
||||||
|
for (const [cwd, items] of byProject) for (const s of items) add(`project:${cwd}`, basename(cwd) || "/", s, "folders");
|
||||||
} else {
|
} else {
|
||||||
for (const s of loose) {
|
for (const s of loose) {
|
||||||
const key = groupKey(s.updated_at);
|
const key = groupKey(s.updated_at);
|
||||||
@@ -181,6 +289,81 @@ export function Sidebar({ open, activeIds, section }: { open: boolean; activeIds
|
|||||||
for (const s of sessions.filter((s) => s.archived)) add("archive", t("sidebar.archive"), s, "archive");
|
for (const s of sessions.filter((s) => s.archived)) add("archive", t("sidebar.archive"), s, "archive");
|
||||||
return out;
|
return out;
|
||||||
}, [sessions, groupBy, t]);
|
}, [sessions, groupBy, t]);
|
||||||
|
// Folders and projects: pinned first, then in the user's order. Groups not ordered yet
|
||||||
|
// (new folder or project) start at the top and are then kept where they are.
|
||||||
|
const [layout, setLayout] = useState<Layout>();
|
||||||
|
useEffect(() => {
|
||||||
|
get<Layout>("/account/sidebar")
|
||||||
|
.then((l) => setLayout({ order: l.order ?? [], pinned: l.pinned ?? [] }))
|
||||||
|
.catch(() => setLayout({ order: [], pinned: [] }));
|
||||||
|
}, []);
|
||||||
|
const ordered = useMemo(() => {
|
||||||
|
const l = layout ?? { order: [], pinned: [] };
|
||||||
|
const head = groups.filter((g) => g.id === "pinned");
|
||||||
|
const tail = groups.filter((g) => g.id !== "pinned" && !movable(g.id));
|
||||||
|
const rank = (id: string) => l.order.indexOf(id);
|
||||||
|
const mid = groups
|
||||||
|
.filter((g) => movable(g.id))
|
||||||
|
.sort((a, b) => {
|
||||||
|
const pa = l.pinned.includes(a.id);
|
||||||
|
const pb = l.pinned.includes(b.id);
|
||||||
|
if (pa !== pb) return pa ? -1 : 1;
|
||||||
|
const ra = rank(a.id);
|
||||||
|
const rb = rank(b.id);
|
||||||
|
if (ra < 0 && rb < 0) return b.items[0].updated_at - a.items[0].updated_at;
|
||||||
|
if (ra < 0 || rb < 0) return ra < 0 ? -1 : 1;
|
||||||
|
return ra - rb;
|
||||||
|
});
|
||||||
|
return { head, mid, tail };
|
||||||
|
}, [groups, layout]);
|
||||||
|
const saveLayout = (next: Layout) => {
|
||||||
|
// Forget groups that no longer exist (no chat in that folder or project).
|
||||||
|
const known = new Set(sessions.flatMap((s) => [`project:${s.cwd}`, ...(s.folder ? [`folder:${s.folder}`] : [])]));
|
||||||
|
const clean = { order: next.order.filter((id) => known.has(id)), pinned: next.pinned.filter((id) => known.has(id)) };
|
||||||
|
setLayout(clean);
|
||||||
|
void put("/account/sidebar", clean).catch((e) => toast(e.message));
|
||||||
|
};
|
||||||
|
// Freeze the position of groups that appeared since the last visit.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!layout) return;
|
||||||
|
const fresh = ordered.mid.map((g) => g.id).filter((id) => !layout.order.includes(id));
|
||||||
|
if (fresh.length) saveLayout({ ...layout, order: [...fresh, ...layout.order] });
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [ordered, layout]);
|
||||||
|
/** Order of the shown groups with `id` moved: by a step, or next to another group. */
|
||||||
|
const reorder = (ids: string[]) => {
|
||||||
|
if (!layout) return;
|
||||||
|
const shown = new Set(ids);
|
||||||
|
saveLayout({ ...layout, order: [...ids, ...layout.order.filter((id) => !shown.has(id))] });
|
||||||
|
};
|
||||||
|
const moveGroup = (id: string, dir: -1 | 1) => {
|
||||||
|
if (!layout) return;
|
||||||
|
const ids = ordered.mid.map((g) => g.id);
|
||||||
|
const i = ids.indexOf(id);
|
||||||
|
const j = i + dir;
|
||||||
|
// Only within the pinned or the unpinned part.
|
||||||
|
if (j < 0 || j >= ids.length || layout.pinned.includes(ids[j]) !== layout.pinned.includes(id)) return;
|
||||||
|
[ids[i], ids[j]] = [ids[j], ids[i]];
|
||||||
|
reorder(ids);
|
||||||
|
};
|
||||||
|
const dropGroup = (dragged: string, target: string, after: boolean) => {
|
||||||
|
if (!layout || !movable(dragged)) return;
|
||||||
|
const ids = ordered.mid.map((g) => g.id).filter((id) => id !== dragged);
|
||||||
|
const at = ids.indexOf(target);
|
||||||
|
if (at < 0) return;
|
||||||
|
ids.splice(after ? at + 1 : at, 0, dragged);
|
||||||
|
// Dropped among pinned groups: pinned too (and the other way round).
|
||||||
|
const pinTarget = layout.pinned.includes(target);
|
||||||
|
const pinned = pinTarget ? [...new Set([...layout.pinned, dragged])] : layout.pinned.filter((p) => p !== dragged);
|
||||||
|
const shown = new Set(ids);
|
||||||
|
saveLayout({ pinned, order: [...ids, ...layout.order.filter((id) => !shown.has(id))] });
|
||||||
|
};
|
||||||
|
const pinGroup = (id: string) => {
|
||||||
|
if (!layout) return;
|
||||||
|
const pinned = layout.pinned.includes(id) ? layout.pinned.filter((p) => p !== id) : [...layout.pinned, id];
|
||||||
|
saveLayout({ ...layout, pinned });
|
||||||
|
};
|
||||||
|
|
||||||
const folderNames = useMemo(() => [...new Set(sessions.map((s) => s.folder).filter(Boolean) as string[])].sort(), [sessions]);
|
const folderNames = useMemo(() => [...new Set(sessions.map((s) => s.folder).filter(Boolean) as string[])].sort(), [sessions]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -235,14 +418,17 @@ export function Sidebar({ open, activeIds, section }: { open: boolean; activeIds
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!hits &&
|
{!hits &&
|
||||||
groups.map((g) => (
|
[...ordered.head, ...ordered.mid, ...ordered.tail].map((g) => (
|
||||||
<div key={g.id}>
|
<div key={g.id}>
|
||||||
<button className="session-group" onClick={() => toggle(g.id)} aria-expanded={!collapsed.has(g.id)}>
|
<GroupHeader
|
||||||
<Icon name={collapsed.has(g.id) ? "chevron-right" : "chevron-down"} className="tiny" />
|
g={g}
|
||||||
{g.icon && <Icon name={g.icon} className="tiny" />}
|
collapsed={collapsed.has(g.id)}
|
||||||
<span className="grow">{g.label}</span>
|
pinned={!!layout?.pinned.includes(g.id)}
|
||||||
{collapsed.has(g.id) && <span className="count">{g.items.length}</span>}
|
onToggle={() => toggle(g.id)}
|
||||||
</button>
|
onPin={movable(g.id) ? () => pinGroup(g.id) : undefined}
|
||||||
|
onMove={movable(g.id) ? (dir) => moveGroup(g.id, dir) : undefined}
|
||||||
|
onDrop={movable(g.id) ? (dragged, after) => dropGroup(dragged, g.id, after) : undefined}
|
||||||
|
/>
|
||||||
{!collapsed.has(g.id) &&
|
{!collapsed.has(g.id) &&
|
||||||
g.items.map((s) => (
|
g.items.map((s) => (
|
||||||
<SessionRow key={s.id} s={s} active={activeIds.includes(s.id)} attention={attention.has(s.id)} busy={!!busy[s.id]} folders={folderNames} />
|
<SessionRow key={s.id} s={s} active={activeIds.includes(s.id)} attention={attention.has(s.id)} busy={!!busy[s.id]} folders={folderNames} />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Icon } from "./Icon";
|
import { Icon } from "./Icon";
|
||||||
import { base64Size, FileFigure } from "./FileFigure";
|
import { FileFigure, imageSize, imageSrc } from "./FileFigure";
|
||||||
import { useT } from "../i18n";
|
import { useT } from "../i18n";
|
||||||
import { navigate } from "../router";
|
import { navigate } from "../router";
|
||||||
import { useStore } from "../store";
|
import { useStore } from "../store";
|
||||||
@@ -166,8 +166,8 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
result?.images?.map((img, i) => (
|
result?.images?.map((img, i) => (
|
||||||
<FileFigure key={i} name={typeof call.input?.path === "string" ? call.input.path : undefined} mime={img.media_type} size={base64Size(img.data)}>
|
<FileFigure key={i} name={typeof call.input?.path === "string" ? call.input.path : undefined} mime={img.media_type} size={imageSize(img)}>
|
||||||
<img src={`data:${img.media_type};base64,${img.data}`} alt="" />
|
<img src={imageSrc(img)} alt="" loading="lazy" decoding="async" />
|
||||||
</FileFigure>
|
</FileFigure>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
+57
-9
@@ -344,7 +344,7 @@ export const de: Messages = {
|
|||||||
"agents.refineShort": "Review ×{n}",
|
"agents.refineShort": "Review ×{n}",
|
||||||
"agents.deleteFile": "Agenten-Datei {path} löschen?",
|
"agents.deleteFile": "Agenten-Datei {path} löschen?",
|
||||||
"nav.jobs": "Automationen",
|
"nav.jobs": "Automationen",
|
||||||
"jobs.intro": "Agenten, die nach Zeitplan starten. Jede Automation ist eine Markdown-Datei (Auftrag + Zeitplan im Frontmatter) – lass sie dir in einem Chat von einem Agenten anlegen (Tool „schedule“) oder registriere unten eine Datei. Jeder Lauf erscheint als normaler Chat; braucht er eine Freigabe, bekommst du eine Push-Benachrichtigung.",
|
"jobs.intro": "Agenten, die nach Zeitplan starten. Die Anweisungen jeder Automatisierung stehen in einer Markdown-Datei; Name, Zeitplan und Einstellungen legst du hier fest. Mit „Neue Automatisierung“ anlegen und die Anweisungen mit dem Assistenten erarbeiten – oder einen Agenten in einem Chat darum bitten (Tool „schedule“). Jeder Lauf erscheint als normaler Chat.",
|
||||||
"jobs.none": "Noch keine Automationen.",
|
"jobs.none": "Noch keine Automationen.",
|
||||||
"jobs.schedule": "Zeitplan",
|
"jobs.schedule": "Zeitplan",
|
||||||
"jobs.next": "Nächster Lauf",
|
"jobs.next": "Nächster Lauf",
|
||||||
@@ -355,13 +355,12 @@ export const de: Messages = {
|
|||||||
"jobs.enable": "Aktivieren",
|
"jobs.enable": "Aktivieren",
|
||||||
"jobs.disable": "Deaktivieren",
|
"jobs.disable": "Deaktivieren",
|
||||||
"jobs.runNow": "Jetzt starten",
|
"jobs.runNow": "Jetzt starten",
|
||||||
"jobs.editFile": "Datei bearbeiten",
|
|
||||||
"jobs.remove": "Entfernen",
|
"jobs.remove": "Entfernen",
|
||||||
"jobs.deleteConfirm": "Automation „{name}“ entfernen? Die Job-Datei bleibt erhalten.",
|
"jobs.deleteConfirm": "Automation „{name}“ entfernen? Die Job-Datei bleibt erhalten.",
|
||||||
"jobs.openRun": "Lauf öffnen",
|
"jobs.openRun": "Lauf öffnen",
|
||||||
"jobs.prompt": "Auftrag",
|
"jobs.prompt": "Auftrag",
|
||||||
"jobs.register": "Job-Datei registrieren",
|
"jobs.register": "Vorhandene Datei registrieren",
|
||||||
"jobs.registerHint": "Pfad zu einer Markdown-Datei mit Zeitplan im Frontmatter:",
|
"jobs.registerHint": "Pfad zu einer Markdown-Datei mit Anweisungen (ältere Dateien mit Zeitplan im Frontmatter werden übernommen):",
|
||||||
"jobs.registerBtn": "Registrieren",
|
"jobs.registerBtn": "Registrieren",
|
||||||
"jobs.status.running": "läuft",
|
"jobs.status.running": "läuft",
|
||||||
"jobs.status.done": "fertig",
|
"jobs.status.done": "fertig",
|
||||||
@@ -509,6 +508,11 @@ export const de: Messages = {
|
|||||||
"sidebar.unpin": "Nicht mehr anpinnen",
|
"sidebar.unpin": "Nicht mehr anpinnen",
|
||||||
"sidebar.openBeside": "Daneben öffnen",
|
"sidebar.openBeside": "Daneben öffnen",
|
||||||
"chat.closePane": "Spalte schließen",
|
"chat.closePane": "Spalte schließen",
|
||||||
|
"sidebar.groupMenu": "Ordner-Optionen",
|
||||||
|
"sidebar.groupPin": "Oben fixieren",
|
||||||
|
"sidebar.groupUnpin": "Nicht mehr fixieren",
|
||||||
|
"sidebar.groupUp": "Nach oben",
|
||||||
|
"sidebar.groupDown": "Nach unten",
|
||||||
"sidebar.moveTo": "In Ordner verschieben…",
|
"sidebar.moveTo": "In Ordner verschieben…",
|
||||||
"sidebar.removeFromFolder": "Aus Ordner entfernen",
|
"sidebar.removeFromFolder": "Aus Ordner entfernen",
|
||||||
"sidebar.archiveOne": "Archivieren",
|
"sidebar.archiveOne": "Archivieren",
|
||||||
@@ -545,16 +549,12 @@ export const de: Messages = {
|
|||||||
"backup.s3Keep": "Im Bucket behalten (0 = alle)",
|
"backup.s3Keep": "Im Bucket behalten (0 = alle)",
|
||||||
"backup.last": "Letztes Backup",
|
"backup.last": "Letztes Backup",
|
||||||
"backup.s3Ok": "hochgeladen",
|
"backup.s3Ok": "hochgeladen",
|
||||||
"jobs.fromTemplate": "Neue Automation aus Vorlage",
|
|
||||||
"jobs.tplName": "Name",
|
"jobs.tplName": "Name",
|
||||||
"jobs.tplProject": "Projektordner",
|
"jobs.tplProject": "Projektordner",
|
||||||
"jobs.tplSchedule": "Wann",
|
|
||||||
"jobs.tplCronHint": "Cron: Minute Stunde Tag Monat Wochentag (Zeitzone des Servers).",
|
|
||||||
"jobs.tplMode": "Modus",
|
"jobs.tplMode": "Modus",
|
||||||
"jobs.tplPrompt": "Aufgabe für den Agenten",
|
|
||||||
"jobs.tplCreate": "Automation anlegen",
|
|
||||||
"new.title": "Neue Session",
|
"new.title": "Neue Session",
|
||||||
"new.directory": "Projektverzeichnis",
|
"new.directory": "Projektverzeichnis",
|
||||||
|
"new.invalidFolderName": "Ordnernamen dürfen kein „/“ enthalten.",
|
||||||
"new.up": "Hoch",
|
"new.up": "Hoch",
|
||||||
"new.noSubdirs": "Keine Unterverzeichnisse",
|
"new.noSubdirs": "Keine Unterverzeichnisse",
|
||||||
"new.model": "Modell",
|
"new.model": "Modell",
|
||||||
@@ -593,6 +593,54 @@ export const de: Messages = {
|
|||||||
"chat.compactingAuto": "Kontext fast voll – Unterhaltung wird automatisch komprimiert",
|
"chat.compactingAuto": "Kontext fast voll – Unterhaltung wird automatisch komprimiert",
|
||||||
"chat.compactingManual": "Unterhaltung wird komprimiert",
|
"chat.compactingManual": "Unterhaltung wird komprimiert",
|
||||||
"chat.compactingDetail": "{tokens} von {limit} Tokens ({pct}) – danach arbeitet der Agent mit einer Zusammenfassung weiter",
|
"chat.compactingDetail": "{tokens} von {limit} Tokens ({pct}) – danach arbeitet der Agent mit einer Zusammenfassung weiter",
|
||||||
|
"chat.telegram": "Fragen an Telegram senden",
|
||||||
|
"chat.telegramHint": "Freigaben und Rückfragen dieses Chats auch per Telegram schicken (der gerade in Telegram geöffnete Chat meldet sich immer)",
|
||||||
|
"cron.label": "Zeitplan",
|
||||||
|
"cron.mode.minutes": "Alle paar Minuten",
|
||||||
|
"cron.mode.hourly": "Stündlich",
|
||||||
|
"cron.mode.daily": "Täglich",
|
||||||
|
"cron.mode.weekly": "An Wochentagen",
|
||||||
|
"cron.mode.monthly": "Monatlich",
|
||||||
|
"cron.mode.custom": "Cron-Ausdruck",
|
||||||
|
"cron.every": "Alle",
|
||||||
|
"cron.minutes": "Minuten",
|
||||||
|
"cron.atMinute": "Zur Minute",
|
||||||
|
"cron.days": "Wochentage",
|
||||||
|
"cron.workdays": "Mo–Fr",
|
||||||
|
"cron.onDay": "Am",
|
||||||
|
"cron.at": "um",
|
||||||
|
"cron.customHint": "Minute Stunde Tag Monat Wochentag, z. B. „30 6 * * 1-5“ = Mo–Fr 06:30.",
|
||||||
|
"cron.nextRuns": "Nächste Läufe:",
|
||||||
|
"cron.shortMonths": "Monate ohne diesen Tag werden übersprungen.",
|
||||||
|
"cron.serverTz": "Uhrzeiten in Serverzeit ({tz})",
|
||||||
|
"jobEditor.saved": "Gespeichert",
|
||||||
|
"jobEditor.conflict": "Die Anweisungen wurden inzwischen geändert (z. B. vom Assistenten). Mit deiner Fassung überschreiben?",
|
||||||
|
"jobEditor.overwrite": "Überschreiben",
|
||||||
|
"jobEditor.runUnsaved": "Es gibt ungespeicherte Änderungen – der Lauf verwendet die gespeicherte Fassung. Trotzdem starten?",
|
||||||
|
"jobEditor.discard": "Ungespeicherte Änderungen verwerfen?",
|
||||||
|
"jobEditor.back": "Zurück zu den Automatisierungen",
|
||||||
|
"jobEditor.tab.prompt": "Anweisungen",
|
||||||
|
"jobEditor.tab.settings": "Zeitplan",
|
||||||
|
"jobEditor.tab.assistant": "Assistent",
|
||||||
|
"jobEditor.timeout": "Zeitlimit (Min.)",
|
||||||
|
"jobEditor.bypassHint": "Läuft nachts: Mit „Alles erlauben“ wartet der Lauf nie auf eine Freigabe.",
|
||||||
|
"jobEditor.instructions": "Anweisungen",
|
||||||
|
"jobEditor.instructionsHint": "Der Agent sieht nur diesen Text – keinen Chat, niemanden zum Nachfragen.",
|
||||||
|
"jobEditor.changedOnDisk": "Der Assistent hat die Datei geändert, während du bearbeitet hast.",
|
||||||
|
"jobEditor.reload": "Seine Fassung laden",
|
||||||
|
"jobEditor.assistantIntro": "Erarbeite die Anweisungen zusammen mit einem Modell: Beschreib, was die Automatisierung tun soll – es fragt nach und schreibt die Datei nebenan mit.",
|
||||||
|
"jobEditor.assistantStart": "Assistent starten",
|
||||||
|
"jobEditor.defaultModel": "Server-Standard",
|
||||||
|
"jobs.new": "Neue Automatisierung",
|
||||||
|
"jobs.namePlaceholder": "z. B. Nächtlicher Abhängigkeits-Check",
|
||||||
|
"jobs.fileHint": "Die Anweisungen landen in .codecraft/jobs/<name>.md in diesem Ordner.",
|
||||||
|
"jobs.startWith": "Beginnen mit",
|
||||||
|
"jobs.blank": "Leer",
|
||||||
|
"jobs.blankDesc": "Beispielgliederung zum Ausfüllen mit dem Assistenten",
|
||||||
|
"jobs.createAndEdit": "Anlegen und bearbeiten",
|
||||||
|
"jobs.edit": "Bearbeiten",
|
||||||
|
"jobs.permissionMode": "Freigabemodus",
|
||||||
|
"jobs.permissionModeHint": "Gilt ab dem nächsten Lauf. „Alles erlauben“: der Lauf fragt nie nach und wartet nicht auf Freigaben.",
|
||||||
"instructions.title": "Anweisungen für diesen Chat",
|
"instructions.title": "Anweisungen für diesen Chat",
|
||||||
"instructions.hint": "Gelten für jede Nachricht in diesem Chat, zusätzlich zum Modus – z. B. Sprache, Ton, Regeln oder der Teil des Projekts, um den es geht.",
|
"instructions.hint": "Gelten für jede Nachricht in diesem Chat, zusätzlich zum Modus – z. B. Sprache, Ton, Regeln oder der Teil des Projekts, um den es geht.",
|
||||||
"instructions.placeholder": "z. B. Antworte immer auf Englisch. Ändere nur Dateien unter api/. Frag, bevor du Abhängigkeiten hinzufügst.",
|
"instructions.placeholder": "z. B. Antworte immer auf Englisch. Ändere nur Dateien unter api/. Frag, bevor du Abhängigkeiten hinzufügst.",
|
||||||
|
|||||||
+57
-9
@@ -343,7 +343,7 @@ export const en = {
|
|||||||
"agents.refineShort": "Review ×{n}",
|
"agents.refineShort": "Review ×{n}",
|
||||||
"agents.deleteFile": "Delete agent file {path}?",
|
"agents.deleteFile": "Delete agent file {path}?",
|
||||||
"nav.jobs": "Automations",
|
"nav.jobs": "Automations",
|
||||||
"jobs.intro": "Agents that start on a schedule. Each automation is a Markdown file (prompt + schedule in the frontmatter) – ask an agent in any chat to create one (tool “schedule”) or register a file below. Every run appears as a normal chat; if it needs permission you get a push notification.",
|
"jobs.intro": "Agents that start on a schedule. Each automation has its instructions in a Markdown file; name, schedule and settings are set here. Create one with “New automation” and work out the instructions with the assistant – or ask an agent in any chat (tool “schedule”). Every run appears as a normal chat.",
|
||||||
"jobs.none": "No automations yet.",
|
"jobs.none": "No automations yet.",
|
||||||
"jobs.schedule": "Schedule",
|
"jobs.schedule": "Schedule",
|
||||||
"jobs.next": "Next run",
|
"jobs.next": "Next run",
|
||||||
@@ -354,13 +354,12 @@ export const en = {
|
|||||||
"jobs.enable": "Enable",
|
"jobs.enable": "Enable",
|
||||||
"jobs.disable": "Disable",
|
"jobs.disable": "Disable",
|
||||||
"jobs.runNow": "Run now",
|
"jobs.runNow": "Run now",
|
||||||
"jobs.editFile": "Edit file",
|
|
||||||
"jobs.remove": "Remove",
|
"jobs.remove": "Remove",
|
||||||
"jobs.deleteConfirm": "Remove the automation “{name}”? The job file stays.",
|
"jobs.deleteConfirm": "Remove the automation “{name}”? The job file stays.",
|
||||||
"jobs.openRun": "Open run",
|
"jobs.openRun": "Open run",
|
||||||
"jobs.prompt": "Instructions",
|
"jobs.prompt": "Instructions",
|
||||||
"jobs.register": "Register a job file",
|
"jobs.register": "Register an existing file",
|
||||||
"jobs.registerHint": "Path to a Markdown file with a schedule in its frontmatter:",
|
"jobs.registerHint": "Path to a Markdown file with instructions (older files with a schedule in the frontmatter are taken over):",
|
||||||
"jobs.registerBtn": "Register",
|
"jobs.registerBtn": "Register",
|
||||||
"jobs.status.running": "running",
|
"jobs.status.running": "running",
|
||||||
"jobs.status.done": "done",
|
"jobs.status.done": "done",
|
||||||
@@ -508,6 +507,11 @@ export const en = {
|
|||||||
"sidebar.unpin": "Unpin",
|
"sidebar.unpin": "Unpin",
|
||||||
"sidebar.openBeside": "Open beside",
|
"sidebar.openBeside": "Open beside",
|
||||||
"chat.closePane": "Close this column",
|
"chat.closePane": "Close this column",
|
||||||
|
"sidebar.groupMenu": "Folder options",
|
||||||
|
"sidebar.groupPin": "Pin to the top",
|
||||||
|
"sidebar.groupUnpin": "Unpin",
|
||||||
|
"sidebar.groupUp": "Move up",
|
||||||
|
"sidebar.groupDown": "Move down",
|
||||||
"sidebar.moveTo": "Move to folder…",
|
"sidebar.moveTo": "Move to folder…",
|
||||||
"sidebar.removeFromFolder": "Remove from folder",
|
"sidebar.removeFromFolder": "Remove from folder",
|
||||||
"sidebar.archiveOne": "Archive",
|
"sidebar.archiveOne": "Archive",
|
||||||
@@ -544,16 +548,12 @@ export const en = {
|
|||||||
"backup.s3Keep": "Keep in bucket (0 = all)",
|
"backup.s3Keep": "Keep in bucket (0 = all)",
|
||||||
"backup.last": "Last backup",
|
"backup.last": "Last backup",
|
||||||
"backup.s3Ok": "uploaded",
|
"backup.s3Ok": "uploaded",
|
||||||
"jobs.fromTemplate": "New automation from a template",
|
|
||||||
"jobs.tplName": "Name",
|
"jobs.tplName": "Name",
|
||||||
"jobs.tplProject": "Project folder",
|
"jobs.tplProject": "Project folder",
|
||||||
"jobs.tplSchedule": "When",
|
|
||||||
"jobs.tplCronHint": "Cron: minute hour day month weekday (server time zone).",
|
|
||||||
"jobs.tplMode": "Mode",
|
"jobs.tplMode": "Mode",
|
||||||
"jobs.tplPrompt": "Task for the agent",
|
|
||||||
"jobs.tplCreate": "Create automation",
|
|
||||||
"new.title": "New session",
|
"new.title": "New session",
|
||||||
"new.directory": "Project directory",
|
"new.directory": "Project directory",
|
||||||
|
"new.invalidFolderName": "Folder names cannot contain “/”.",
|
||||||
"new.up": "Up",
|
"new.up": "Up",
|
||||||
"new.noSubdirs": "No subdirectories",
|
"new.noSubdirs": "No subdirectories",
|
||||||
"new.model": "Model",
|
"new.model": "Model",
|
||||||
@@ -592,6 +592,54 @@ export const en = {
|
|||||||
"chat.compactingAuto": "Context nearly full – compacting the conversation automatically",
|
"chat.compactingAuto": "Context nearly full – compacting the conversation automatically",
|
||||||
"chat.compactingManual": "Compacting the conversation",
|
"chat.compactingManual": "Compacting the conversation",
|
||||||
"chat.compactingDetail": "{tokens} of {limit} tokens ({pct}) – the agent continues with a summary afterwards",
|
"chat.compactingDetail": "{tokens} of {limit} tokens ({pct}) – the agent continues with a summary afterwards",
|
||||||
|
"chat.telegram": "Send questions to Telegram",
|
||||||
|
"chat.telegramHint": "Also send approvals and questions of this chat via Telegram (the chat currently open in Telegram always reports)",
|
||||||
|
"cron.label": "Schedule",
|
||||||
|
"cron.mode.minutes": "Every few minutes",
|
||||||
|
"cron.mode.hourly": "Hourly",
|
||||||
|
"cron.mode.daily": "Daily",
|
||||||
|
"cron.mode.weekly": "On weekdays",
|
||||||
|
"cron.mode.monthly": "Monthly",
|
||||||
|
"cron.mode.custom": "Cron expression",
|
||||||
|
"cron.every": "Every",
|
||||||
|
"cron.minutes": "minutes",
|
||||||
|
"cron.atMinute": "At minute",
|
||||||
|
"cron.days": "Days of the week",
|
||||||
|
"cron.workdays": "Mon–Fri",
|
||||||
|
"cron.onDay": "On day",
|
||||||
|
"cron.at": "at",
|
||||||
|
"cron.customHint": "Minute hour day month weekday, e.g. “30 6 * * 1-5” = Mon–Fri 06:30.",
|
||||||
|
"cron.nextRuns": "Next runs:",
|
||||||
|
"cron.shortMonths": "Months without this day are skipped.",
|
||||||
|
"cron.serverTz": "Times in server time ({tz})",
|
||||||
|
"jobEditor.saved": "Saved",
|
||||||
|
"jobEditor.conflict": "The instructions were changed on disk meanwhile (e.g. by the assistant). Overwrite them with your version?",
|
||||||
|
"jobEditor.overwrite": "Overwrite",
|
||||||
|
"jobEditor.runUnsaved": "There are unsaved changes – the run uses the saved version. Start anyway?",
|
||||||
|
"jobEditor.discard": "Discard the unsaved changes?",
|
||||||
|
"jobEditor.back": "Back to automations",
|
||||||
|
"jobEditor.tab.prompt": "Instructions",
|
||||||
|
"jobEditor.tab.settings": "Schedule",
|
||||||
|
"jobEditor.tab.assistant": "Assistant",
|
||||||
|
"jobEditor.timeout": "Time limit (min)",
|
||||||
|
"jobEditor.bypassHint": "Runs at night: with “Allow everything” the run never waits for approval.",
|
||||||
|
"jobEditor.instructions": "Instructions",
|
||||||
|
"jobEditor.instructionsHint": "The agent sees only this text – no chat, nobody to ask.",
|
||||||
|
"jobEditor.changedOnDisk": "The assistant changed the file while you were editing.",
|
||||||
|
"jobEditor.reload": "Load its version",
|
||||||
|
"jobEditor.assistantIntro": "Work out the instructions together with a model: describe what the automation should do – it asks back and writes the file next to you.",
|
||||||
|
"jobEditor.assistantStart": "Start assistant",
|
||||||
|
"jobEditor.defaultModel": "Server default",
|
||||||
|
"jobs.new": "New automation",
|
||||||
|
"jobs.namePlaceholder": "e.g. Nightly dependency check",
|
||||||
|
"jobs.fileHint": "The instructions go to .codecraft/jobs/<name>.md in this folder.",
|
||||||
|
"jobs.startWith": "Start with",
|
||||||
|
"jobs.blank": "Blank",
|
||||||
|
"jobs.blankDesc": "Example structure to fill in with the assistant",
|
||||||
|
"jobs.createAndEdit": "Create and edit",
|
||||||
|
"jobs.edit": "Edit",
|
||||||
|
"jobs.permissionMode": "Approval mode",
|
||||||
|
"jobs.permissionModeHint": "Applies from the next run. “Allow everything”: the run never asks and never waits for approval.",
|
||||||
"instructions.title": "Instructions for this chat",
|
"instructions.title": "Instructions for this chat",
|
||||||
"instructions.hint": "Apply to every message in this chat, in addition to the mode – e.g. language, tone, rules, the part of the project to work on.",
|
"instructions.hint": "Apply to every message in this chat, in addition to the mode – e.g. language, tone, rules, the part of the project to work on.",
|
||||||
"instructions.placeholder": "e.g. Always answer in English. Only change files under api/. Ask before adding dependencies.",
|
"instructions.placeholder": "e.g. Always answer in English. Only change files under api/. Ask before adding dependencies.",
|
||||||
|
|||||||
@@ -71,13 +71,3 @@ export const JOB_TEMPLATES: JobTemplate[] = [
|
|||||||
"Compare the README and other documentation with the current code: commands, configuration options, APIs and examples that are outdated, missing or wrong. Do not change files. Report concrete corrections.",
|
"Compare the README and other documentation with the current code: commands, configuration options, APIs and examples that are outdated, missing or wrong. Do not change files. Report concrete corrections.",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Quick schedule choices for the dialog. */
|
|
||||||
export const SCHEDULES: { cron: string; en: string; de: string }[] = [
|
|
||||||
{ cron: "0 3 * * *", en: "daily 03:00", de: "täglich 03:00" },
|
|
||||||
{ cron: "0 8 * * 1-5", en: "weekdays 08:00", de: "werktags 08:00" },
|
|
||||||
{ cron: "0 7 * * 1", en: "Mondays 07:00", de: "montags 07:00" },
|
|
||||||
{ cron: "0 16 * * 5", en: "Fridays 16:00", de: "freitags 16:00" },
|
|
||||||
{ cron: "0 9 1 * *", en: "monthly on the 1st", de: "monatlich am 1." },
|
|
||||||
{ cron: "0 * * * *", en: "hourly", de: "stündlich" },
|
|
||||||
];
|
|
||||||
@@ -25,3 +25,21 @@ export function useReasoningMode(): ReasoningMode {
|
|||||||
getReasoningMode,
|
getReasoningMode,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TODOS_KEY = "codecraft.todosCollapsed";
|
||||||
|
|
||||||
|
/** Task list above the composer folded to a one-line summary (per device). */
|
||||||
|
export function useTodosCollapsed(): [boolean, (v: boolean) => void] {
|
||||||
|
const v = useSyncExternalStore(
|
||||||
|
(l) => {
|
||||||
|
listeners.add(l);
|
||||||
|
return () => listeners.delete(l);
|
||||||
|
},
|
||||||
|
() => localStorage.getItem(TODOS_KEY) === "1",
|
||||||
|
);
|
||||||
|
const set = (c: boolean) => {
|
||||||
|
localStorage.setItem(TODOS_KEY, c ? "1" : "0");
|
||||||
|
listeners.forEach((l) => l());
|
||||||
|
};
|
||||||
|
return [v, set];
|
||||||
|
}
|
||||||
@@ -299,6 +299,25 @@ button.session-group:hover {
|
|||||||
.session-row .session-item {
|
.session-row .session-item {
|
||||||
padding-right: 30px;
|
padding-right: 30px;
|
||||||
}
|
}
|
||||||
|
/* Group headers (folders, projects): menu button, drag and drop. */
|
||||||
|
.group-row .session-group {
|
||||||
|
padding-right: 30px;
|
||||||
|
}
|
||||||
|
.group-row .session-more {
|
||||||
|
top: 4px;
|
||||||
|
}
|
||||||
|
.group-row[draggable="true"] .session-group {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
.group-row.drop-before {
|
||||||
|
box-shadow: inset 0 2px 0 var(--accent);
|
||||||
|
}
|
||||||
|
.group-row.drop-after {
|
||||||
|
box-shadow: inset 0 -2px 0 var(--accent);
|
||||||
|
}
|
||||||
|
.session-group .group-pin {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
.session-more {
|
.session-more {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 6px;
|
top: 6px;
|
||||||
@@ -721,6 +740,39 @@ button.session-group:hover {
|
|||||||
margin: 8px 0;
|
margin: 8px 0;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
.todos-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--muted);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.todos-head:hover {
|
||||||
|
color: var(--fg1);
|
||||||
|
}
|
||||||
|
.todos-count {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.todos-current {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.todos.collapsed {
|
||||||
|
padding-block: 6px;
|
||||||
|
}
|
||||||
.todos ul {
|
.todos ul {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -3127,6 +3179,11 @@ button.danger-solid {
|
|||||||
.job-grid > span:nth-child(odd) {
|
.job-grid > span:nth-child(odd) {
|
||||||
color: var(--fg3);
|
color: var(--fg3);
|
||||||
}
|
}
|
||||||
|
.job-grid select {
|
||||||
|
max-width: 100%;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
}
|
||||||
.job-prompt {
|
.job-prompt {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
@@ -3569,6 +3626,217 @@ button.danger-solid {
|
|||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
.job-tpl-card.active {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
.job-tpl .job-tpl-grid {
|
||||||
|
max-height: 40vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.job-title {
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- automation editor ------------------------------------------------------ */
|
||||||
|
.job-tabs {
|
||||||
|
display: none;
|
||||||
|
border-bottom: 1px solid var(--bg2);
|
||||||
|
}
|
||||||
|
.job-tabs button {
|
||||||
|
flex: 1;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
padding: 8px 6px;
|
||||||
|
color: var(--muted);
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
}
|
||||||
|
.job-tabs button.active {
|
||||||
|
color: var(--fg0);
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
}
|
||||||
|
.job-editor {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.2fr) minmax(340px, 1fr);
|
||||||
|
}
|
||||||
|
.job-editor-main {
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.job-editor-settings {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.job-editor-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
.job-editor-row .field {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 150px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.job-editor-row select,
|
||||||
|
.job-editor-row input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.job-editor-status {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--fg3);
|
||||||
|
padding-bottom: 4px;
|
||||||
|
}
|
||||||
|
.job-editor-prompt {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 360px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid var(--bg2);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.job-editor-prompt-head {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--bg2);
|
||||||
|
background: var(--bg1);
|
||||||
|
}
|
||||||
|
.job-editor-prompt .notice {
|
||||||
|
margin: 8px;
|
||||||
|
}
|
||||||
|
.job-editor-chat {
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-left: 1px solid var(--bg3);
|
||||||
|
}
|
||||||
|
.job-editor-chat .topbar {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
.job-assistant-start {
|
||||||
|
margin: auto;
|
||||||
|
max-width: 340px;
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
color: var(--fg2);
|
||||||
|
}
|
||||||
|
.job-assistant-start .icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.job-tabs {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.job-editor {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
.job-editor-chat {
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
.job-editor.tab-prompt .job-editor-settings,
|
||||||
|
.job-editor.tab-settings .job-editor-prompt,
|
||||||
|
.job-editor.tab-prompt .job-editor-chat,
|
||||||
|
.job-editor.tab-settings .job-editor-chat,
|
||||||
|
.job-editor.tab-assistant .job-editor-main {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.job-editor.tab-prompt .job-editor-main {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.job-editor.tab-prompt .job-editor-prompt {
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- schedule builder ---------------------------------------------------------- */
|
||||||
|
.cron-builder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.cron-modes,
|
||||||
|
.cron-days {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.cron-modes .chip-btn,
|
||||||
|
.cron-days .chip-btn {
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.cron-days .chip-btn {
|
||||||
|
min-width: 44px;
|
||||||
|
}
|
||||||
|
.cron-fields {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 16px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.cron-fields label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13.5px;
|
||||||
|
}
|
||||||
|
.cron-fields input[type="number"] {
|
||||||
|
width: 70px;
|
||||||
|
}
|
||||||
|
.cron-custom {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch !important;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.cron-custom input {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 260px;
|
||||||
|
}
|
||||||
|
.cron-preview {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 12px;
|
||||||
|
align-items: baseline;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--fg3);
|
||||||
|
}
|
||||||
|
.cron-preview code {
|
||||||
|
background: var(--bg2);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--fg1);
|
||||||
|
}
|
||||||
|
.cron-preview .err {
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
.chip.price-extra {
|
.chip.price-extra {
|
||||||
margin-left: 4px;
|
margin-left: 4px;
|
||||||
@@ -3773,3 +4041,104 @@ span.chat-tab > button {
|
|||||||
.instructions-modal {
|
.instructions-modal {
|
||||||
width: min(640px, calc(100vw - 32px));
|
width: min(640px, calc(100vw - 32px));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- short screens ----------------------------------------------------------
|
||||||
|
* Square phones (e.g. Clicks Communicator, ~393x400 CSS px with a hardware
|
||||||
|
* keyboard) and phones in landscape: height is the scarce dimension, so bars,
|
||||||
|
* the composer and the new-chat wizard get tighter and the chat keeps the room. */
|
||||||
|
@media (max-height: 560px) {
|
||||||
|
.topbar {
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
padding-top: max(4px, env(safe-area-inset-top));
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.topbar button.ghost {
|
||||||
|
padding: 4px 6px;
|
||||||
|
}
|
||||||
|
.chat {
|
||||||
|
padding: 10px 12px 14px;
|
||||||
|
}
|
||||||
|
.msg {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.composer-wrap {
|
||||||
|
padding: 6px 10px;
|
||||||
|
padding-bottom: max(6px, env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
.composer textarea {
|
||||||
|
min-height: 38px;
|
||||||
|
max-height: 30vh;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
/* One placeholder line instead of two (the textarea grows with its content). */
|
||||||
|
.composer textarea::placeholder {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.composer-bar {
|
||||||
|
margin-top: 4px;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.composer-bar select {
|
||||||
|
max-width: 150px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
}
|
||||||
|
.composer-actions button {
|
||||||
|
padding: 4px 10px;
|
||||||
|
}
|
||||||
|
.todos {
|
||||||
|
margin: 4px 0;
|
||||||
|
padding: 6px 10px;
|
||||||
|
}
|
||||||
|
.page {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.wiz-title {
|
||||||
|
font-size: 19px;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
.wiz-sub {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.wiz-panel {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.wiz-step {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.wiz-step h3 {
|
||||||
|
font-size: 16px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.wiz-cards,
|
||||||
|
.wiz-cards.five {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.wiz-card {
|
||||||
|
padding: 8px 10px;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 44px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.wiz-card.active {
|
||||||
|
padding: 7px 9px;
|
||||||
|
}
|
||||||
|
.wiz-card-text {
|
||||||
|
padding-right: 14px;
|
||||||
|
}
|
||||||
|
.wiz-card-desc {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.wiz-icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
.wiz-num {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-1
@@ -2,7 +2,12 @@
|
|||||||
|
|
||||||
export interface ImageData {
|
export interface ImageData {
|
||||||
media_type: string;
|
media_type: string;
|
||||||
data: string;
|
/** Base64 bytes; absent when the server stores the image separately (blob). */
|
||||||
|
data?: string;
|
||||||
|
/** SHA-256 of a stored image: GET /api/blobs/<blob>. */
|
||||||
|
blob?: string;
|
||||||
|
/** Size in bytes of a stored image. */
|
||||||
|
size?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Part =
|
export type Part =
|
||||||
@@ -69,6 +74,8 @@ export interface Session {
|
|||||||
folder?: string | null;
|
folder?: string | null;
|
||||||
/** Instructions for this chat (added to the system prompt). */
|
/** Instructions for this chat (added to the system prompt). */
|
||||||
instructions?: string;
|
instructions?: string;
|
||||||
|
/** Approvals and questions of this chat are not sent to Telegram. */
|
||||||
|
telegram_quiet?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QuestionOption {
|
export interface QuestionOption {
|
||||||
|
|||||||
+27
-1
@@ -3,7 +3,33 @@
|
|||||||
* under "unreleased"; scripts/release.sh renames the key to the release version.
|
* under "unreleased"; scripts/release.sh renames the key to the release version.
|
||||||
*/
|
*/
|
||||||
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
|
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
|
||||||
unreleased: {
|
"202611-00014": {
|
||||||
|
en: ["Folder picker (new automation, new chat): create a new folder right there"],
|
||||||
|
de: ["Ordnerauswahl (neue Automatisierung, neuer Chat): direkt einen neuen Ordner anlegen"],
|
||||||
|
},
|
||||||
|
"202611-00013": {
|
||||||
|
en: [
|
||||||
|
"New automation editor: instructions, a graphical schedule and an assistant chat that writes the instructions with you",
|
||||||
|
"Chat list: drag folders and projects into order or pin them (⋯ on the folder); new messages no longer move the whole project up",
|
||||||
|
],
|
||||||
|
de: [
|
||||||
|
"Neuer Editor für Automatisierungen: Anweisungen, grafischer Zeitplan und ein Assistent-Chat, der die Anweisungen mit dir schreibt",
|
||||||
|
"Chatliste: Ordner und Projekte per Drag & Drop sortieren oder fixieren (⋯ am Ordner); neue Nachrichten schieben nicht mehr das ganze Projekt nach oben",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"202611-00012": {
|
||||||
|
en: ["Automations: set the approval mode right in the list – “Allow everything” runs unattended without asking", "⋯ → Send questions to Telegram: per chat, off for new chats"],
|
||||||
|
de: ["Automatisierungen: Freigabemodus direkt in der Liste wählen – „Alles erlauben“ läuft nachts ohne Rückfrage", "⋯ → Fragen an Telegram senden: pro Chat, bei neuen Chats aus"],
|
||||||
|
},
|
||||||
|
"202611-00011": {
|
||||||
|
en: ["The task list of the model can be folded (click on \"Tasks\")", "Better layout on square and short screens (e.g. Clicks Communicator, landscape)"],
|
||||||
|
de: ["Die Aufgabenliste des Modells lässt sich zuklappen (Klick auf „Aufgaben“)", "Besseres Layout auf quadratischen und niedrigen Displays (z. B. Clicks Communicator, Querformat)"],
|
||||||
|
},
|
||||||
|
"202611-00010": {
|
||||||
|
en: ["Chats with many images (screenshots, generated images) open much faster – images load when they scroll into view"],
|
||||||
|
de: ["Chats mit vielen Bildern (Screenshots, generierte Bilder) öffnen viel schneller – Bilder laden erst beim Hinscrollen"],
|
||||||
|
},
|
||||||
|
"202611-00008": {
|
||||||
en: ["Instructions per chat: ⋯ → Instructions for this chat (Telegram: /prompt)"],
|
en: ["Instructions per chat: ⋯ → Instructions for this chat (Telegram: /prompt)"],
|
||||||
de: ["Anweisungen pro Chat: ⋯ → Anweisungen für diesen Chat (Telegram: /prompt)"],
|
de: ["Anweisungen pro Chat: ⋯ → Anweisungen für diesen Chat (Telegram: /prompt)"],
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in new issue
Block a user