Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e9eda28b5 | ||
|
|
bd896794fd | ||
|
|
ca14ba3338 | ||
|
|
97e66d2118 | ||
|
|
b3a298fb7c | ||
|
|
61219dbca2 | ||
|
|
9fd92d5b39 | ||
|
|
b99f71526a | ||
|
|
90d65ec654 | ||
|
|
7298102e93 |
No files matched your search
@@ -3,6 +3,66 @@
|
||||
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
|
||||
until 1.0, minor versions may contain breaking changes.
|
||||
|
||||
## [v202611-00018] – 2026-11-10
|
||||
|
||||
### Added
|
||||
|
||||
- **Enforced reviews:** when a review is configured for a mode, every todo the agent marks
|
||||
completed is reviewed; with findings the todo is reopened and the agent has to fix them and
|
||||
complete it again (up to the configured rounds per todo). Ending the turn with open todos sends
|
||||
the agent back to work as long as it makes progress – for unattended runs, e.g. overnight
|
||||
(such modes may take up to 2,000 steps per turn). The chat shows passed reviews and these
|
||||
reminders; *Settings → Modes* can switch it off per mode (then only one review at the end of a
|
||||
turn, as before).
|
||||
- **Max input per model:** providers that limit the input below the context window (e.g. 272k of
|
||||
400k) are taken into account; fetched from the model catalogue, editable in the provider settings.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Context overflow instead of compaction** (e.g. DeepSeek V4.1 Flash with 384k max output):
|
||||
requests asked for the model's full max output, and providers count input + output against the
|
||||
window, so long chats failed before automatic compaction kicked in. The output budget is now what
|
||||
the window leaves, the chat is compacted when no reasonable room for an answer is left, and a
|
||||
"context too long" error compacts and retries once instead of stopping. A turn that hits the
|
||||
step limit now says so.
|
||||
|
||||
## [v202611-00017] – 2026-11-10
|
||||
|
||||
### Added
|
||||
|
||||
- **Markdown in your messages:** your messages are shown formatted like the model's answers
|
||||
(headings, lists, tables, code blocks, quotes, bold/italic); single line breaks stay as typed.
|
||||
The eye button next to the text field shows a preview of the formatted message before sending.
|
||||
|
||||
## [v202611-00016] – 2026-11-10
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Phone: page stayed shifted after the keyboard closed** – the top bar (menu) was out of
|
||||
reach. On Android the keyboard now shrinks the page instead of shifting it
|
||||
(`interactive-widget=resizes-content`; the chat stays at its latest message); browsers that
|
||||
still shift the page (iOS Safari) get it scrolled back once the keyboard closes.
|
||||
|
||||
## [v202611-00015] – 2026-11-10
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Generated images missing on other devices:** when an agent moved or renamed a generated
|
||||
image afterwards (e.g. sorted it into subfolders), the gallery pointed to the old file – only
|
||||
the device that had it in its browser cache still showed it. Now:
|
||||
- generated images are also kept in the image store and shown from there when the file is gone
|
||||
(existing images get their copy on the first start);
|
||||
- their entries follow moves and renames: after an agent's `mv`/`rename`, when moved with the
|
||||
file manager, and on the next view (found by file name, or by content when renamed);
|
||||
- in the chat, an image that cannot be found falls back to the preview the model saw.
|
||||
|
||||
## [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
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Version v202611-00013" src="https://img.shields.io/badge/version-v202611--00013-d69e2e?style=flat-square">
|
||||
<img alt="Version v202611-00018" src="https://img.shields.io/badge/version-v202611--00018-d69e2e?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="React PWA" src="https://img.shields.io/badge/frontend-React%20PWA-2d81ff?style=flat-square&logo=react">
|
||||
|
||||
@@ -64,6 +64,9 @@ pub struct ModelConfig {
|
||||
pub context: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub max_output: Option<u64>,
|
||||
/// Input limit when the provider sets one below the context window (e.g. 272k of 400k).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub max_input: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub reasoning: bool,
|
||||
#[serde(default)]
|
||||
|
||||
+16
-3
@@ -1563,13 +1563,26 @@ fn own_image(app: &App, user: &User, id: &str) -> Result<crate::store::ImageReco
|
||||
|
||||
async fn image_file(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> Result<Response, ApiError> {
|
||||
let img = own_image(&app, &user, &id)?;
|
||||
let bytes = tokio::fs::read(&img.path).await.map_err(|_| not_found("image file"))?;
|
||||
let ctype = match std::path::Path::new(&img.path).extension().and_then(|e| e.to_str()) {
|
||||
let ctype_of = |p: &str| match std::path::Path::new(p).extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()).as_deref() {
|
||||
Some("jpg") | Some("jpeg") => "image/jpeg",
|
||||
Some("webp") => "image/webp",
|
||||
_ => "image/png",
|
||||
};
|
||||
Ok(([(axum::http::header::CONTENT_TYPE, ctype), (axum::http::header::CACHE_CONTROL, "private, max-age=86400")], bytes).into_response())
|
||||
let respond = |ctype: String, bytes: Vec<u8>| ([(axum::http::header::CONTENT_TYPE, ctype), (axum::http::header::CACHE_CONTROL, "private, max-age=86400".to_string())], bytes).into_response();
|
||||
if let Ok(bytes) = tokio::fs::read(&img.path).await {
|
||||
return Ok(respond(ctype_of(&img.path).into(), bytes));
|
||||
}
|
||||
// The file was moved or renamed (agents tidy up folders): with a copy in the image store,
|
||||
// answer from there and look for the file in the background; otherwise look now.
|
||||
if let Some((mime, bytes)) = img.blob.as_deref().map(|h| app.store.blob(h)).transpose()?.flatten() {
|
||||
let a = app.clone();
|
||||
tokio::task::spawn_blocking(move || crate::images::relocate(&a, &img));
|
||||
return Ok(respond(mime, bytes));
|
||||
}
|
||||
let a = app.clone();
|
||||
let found = tokio::task::spawn_blocking(move || crate::images::relocate(&a, &img)).await.ok().flatten().ok_or_else(|| not_found("image file"))?;
|
||||
let bytes = tokio::fs::read(&found).await.map_err(|_| not_found("image file"))?;
|
||||
Ok(respond(ctype_of(&found.display().to_string()).into(), bytes))
|
||||
}
|
||||
|
||||
async fn delete_image(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||
|
||||
@@ -144,6 +144,11 @@ impl App {
|
||||
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 crate::images::backfill_copies(&self) {
|
||||
Ok(0) => {}
|
||||
Ok(n) => tracing::info!("kept copies of {n} generated images in the image store"),
|
||||
Err(e) => tracing::warn!("copies of generated images: {e:#}"),
|
||||
}
|
||||
match self.store.gc_blobs() {
|
||||
Ok(0) => {}
|
||||
Ok(n) => tracing::info!("removed {n} unused images"),
|
||||
|
||||
+2
-1
@@ -56,7 +56,8 @@ 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 {
|
||||
/// Address of `bytes` in the image store (SHA-256, hex).
|
||||
pub fn hash(bytes: &[u8]) -> String {
|
||||
Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
|
||||
@@ -409,6 +409,7 @@ fn opencode_model(m: &Value) -> Value {
|
||||
"id": m["id"],
|
||||
"context": m["limit"]["context"],
|
||||
"max_output": m["limit"]["output"],
|
||||
"max_input": m["limit"]["input"],
|
||||
"vision": images,
|
||||
"reasoning": m["capabilities"]["reasoning"]["supported"].as_bool().unwrap_or(false),
|
||||
"input_price": input["price"],
|
||||
|
||||
@@ -413,6 +413,9 @@ pub struct RefineConfig {
|
||||
/// Review criteria; empty = the mode's defaults.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub criteria: Option<String>,
|
||||
/// Also review every todo the agent marks completed (reopened when findings come back).
|
||||
#[serde(default = "yes")]
|
||||
pub after_todos: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
|
||||
@@ -353,6 +353,10 @@ pub async fn move_or_copy(Extension(user): Extension<User>, State(app): State<Ap
|
||||
}
|
||||
let (f, t) = (from.clone(), to.clone());
|
||||
tokio::task::spawn_blocking(move || if b.copy { copy_recursive(&f, &t) } else { std::fs::rename(&f, &t) }).await??;
|
||||
if !b.copy {
|
||||
// Generated images in the moved file or folder keep their entries.
|
||||
app.store.move_image_paths(&from.display().to_string(), &to.display().to_string())?;
|
||||
}
|
||||
Ok(Json(json!({"ok": true, "path": to})))
|
||||
}
|
||||
|
||||
|
||||
@@ -213,6 +213,8 @@ pub async fn generate(app: &App, req: GenerateRequest) -> Result<Vec<ImageRecord
|
||||
let name = if images.len() > 1 { format!("{stamp}-{base}-{}.{ext}", i + 1) } else { format!("{stamp}-{base}.{ext}") };
|
||||
let path = req.dir.join(name);
|
||||
tokio::fs::write(&path, &bytes).await?;
|
||||
// A copy in the image store keeps it viewable when the file is moved or deleted.
|
||||
let blob = app.store.put_blob(&ctype, &bytes).map_err(|e| tracing::warn!("image copy: {e:#}")).ok();
|
||||
let rec = ImageRecord {
|
||||
id: new_id(),
|
||||
user_id: req.user_id.clone(),
|
||||
@@ -223,6 +225,7 @@ pub async fn generate(app: &App, req: GenerateRequest) -> Result<Vec<ImageRecord
|
||||
width: img["width"].as_i64().unwrap_or(0),
|
||||
height: img["height"].as_i64().unwrap_or(0),
|
||||
created_at: now_ms(),
|
||||
blob,
|
||||
};
|
||||
app.store.add_image(&rec)?;
|
||||
records.push(rec);
|
||||
@@ -252,6 +255,98 @@ pub async fn generate(app: &App, req: GenerateRequest) -> Result<Vec<ImageRecord
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
/// A generated image whose file is gone: the same file name below its original folder
|
||||
/// (or the folder above it), e.g. after an agent sorted the images into subfolders.
|
||||
/// Only inside `roots`; looks at a few thousand entries at most.
|
||||
pub fn find_moved(path: &Path, roots: &[PathBuf]) -> Option<PathBuf> {
|
||||
let name = path.file_name()?;
|
||||
search_near(path, roots, |p, _| p.file_name() == Some(name))
|
||||
}
|
||||
|
||||
/// Like `find_moved`, but by content: an image file with the same bytes (renamed).
|
||||
pub fn find_by_content(path: &Path, roots: &[PathBuf], hash: &str, size: u64) -> Option<PathBuf> {
|
||||
search_near(path, roots, |p, len| {
|
||||
let ext = p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase());
|
||||
len == size && matches!(ext.as_deref(), Some("png" | "jpg" | "jpeg" | "webp")) && std::fs::read(p).is_ok_and(|b| crate::blobs::hash(&b) == hash)
|
||||
})
|
||||
}
|
||||
|
||||
/// Files below the folder of `path` and the folder above it (3 levels, hidden folders
|
||||
/// skipped, inside `roots`) for which `matches(path, size)` holds.
|
||||
fn search_near(path: &Path, roots: &[PathBuf], matches: impl Fn(&Path, u64) -> bool) -> Option<PathBuf> {
|
||||
let dir = path.parent()?;
|
||||
let mut budget = 5000usize;
|
||||
for base in [Some(dir), dir.parent()].into_iter().flatten() {
|
||||
if !roots.iter().any(|r| base.starts_with(r)) {
|
||||
continue;
|
||||
}
|
||||
let mut stack = vec![(base.to_path_buf(), 0usize)];
|
||||
while let Some((d, depth)) = stack.pop() {
|
||||
let Ok(entries) = std::fs::read_dir(&d) else { continue };
|
||||
for e in entries.flatten() {
|
||||
if budget == 0 {
|
||||
return None;
|
||||
}
|
||||
budget -= 1;
|
||||
let p = e.path();
|
||||
let Ok(ft) = e.file_type() else { continue };
|
||||
if ft.is_file() && p != path && matches(&p, e.metadata().map(|m| m.len()).unwrap_or(0)) {
|
||||
return Some(p);
|
||||
}
|
||||
let hidden = p.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with('.') || n == "node_modules");
|
||||
if ft.is_dir() && depth < 3 && !hidden {
|
||||
stack.push((p, depth + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Where the file of a generated image is now: by name, then (with a copy in the image
|
||||
/// store) by content. Corrects the stored path when found.
|
||||
pub fn relocate(app: &App, rec: &ImageRecord) -> Option<PathBuf> {
|
||||
let path = Path::new(&rec.path);
|
||||
let roots = app.user_roots(rec.user_id.as_deref());
|
||||
let found = find_moved(path, &roots).or_else(|| {
|
||||
let hash = rec.blob.as_deref()?;
|
||||
let (_, bytes) = app.store.blob(hash).ok()??;
|
||||
find_by_content(path, &roots, hash, bytes.len() as u64)
|
||||
})?;
|
||||
let _ = app.store.set_image_path(&rec.id, &found.display().to_string());
|
||||
Some(found)
|
||||
}
|
||||
|
||||
/// After an agent moved or renamed files: find the generated images of `owner` from the
|
||||
/// last two weeks whose files are gone and correct their paths. Returns how many.
|
||||
pub fn relocate_missing(app: &App, owner: Option<&str>) -> usize {
|
||||
let since = now_ms() - 14 * 86_400_000;
|
||||
let Ok(list) = app.store.images(owner, 500) else { return 0 };
|
||||
list.iter().filter(|r| r.created_at >= since && !Path::new(&r.path).exists()).filter(|r| relocate(app, r).is_some()).count()
|
||||
}
|
||||
|
||||
/// Copies in the image store for generated images from before they were kept (once per
|
||||
/// image, files up to 25 MB): later moves and renames are then found by content.
|
||||
pub fn backfill_copies(app: &App) -> Result<usize> {
|
||||
let mut n = 0;
|
||||
for (id, path) in app.store.images_without_copy()? {
|
||||
let Ok(meta) = std::fs::metadata(&path) else { continue };
|
||||
if !meta.is_file() || meta.len() > 25 * 1024 * 1024 {
|
||||
continue;
|
||||
}
|
||||
let Ok(bytes) = std::fs::read(&path) else { continue };
|
||||
let mime = match Path::new(&path).extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()).as_deref() {
|
||||
Some("jpg" | "jpeg") => "image/jpeg",
|
||||
Some("webp") => "image/webp",
|
||||
_ => "image/png",
|
||||
};
|
||||
let hash = app.store.put_blob(mime, &bytes)?;
|
||||
app.store.set_image_blob(&id, &hash)?;
|
||||
n += 1;
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Downscaled copy for the model's context.
|
||||
fn preview_for_model(path: &Path) -> Option<ImageData> {
|
||||
let img = image::open(path).ok()?;
|
||||
@@ -363,6 +458,37 @@ impl Tool for GenerateImage {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn finds_moved_files_and_keeps_their_copies() {
|
||||
let dir = std::env::temp_dir().join(format!("cc-img-moved-{}", std::process::id()));
|
||||
let art = dir.join("art");
|
||||
std::fs::create_dir_all(art.join("older")).unwrap();
|
||||
std::fs::write(art.join("older/20261110-a.png"), b"x").unwrap();
|
||||
// Moved into a subfolder: found; renamed or outside the roots: not.
|
||||
assert_eq!(find_moved(&art.join("20261110-a.png"), std::slice::from_ref(&dir)), Some(art.join("older/20261110-a.png")));
|
||||
assert_eq!(find_moved(&art.join("20261110-b.png"), std::slice::from_ref(&dir)), None);
|
||||
assert_eq!(find_moved(&art.join("20261110-a.png"), &[dir.join("elsewhere")]), None);
|
||||
// Renamed: found by content.
|
||||
std::fs::write(art.join("older/level-5.png"), b"renamed bytes").unwrap();
|
||||
let h = crate::blobs::hash(b"renamed bytes");
|
||||
assert_eq!(find_by_content(&art.join("20261110-c.png"), std::slice::from_ref(&dir), &h, 13), Some(art.join("older/level-5.png")));
|
||||
assert_eq!(find_by_content(&art.join("20261110-c.png"), std::slice::from_ref(&dir), &crate::blobs::hash(b"other"), 13), None);
|
||||
// The image store keeps the copy of a generated image.
|
||||
let store = crate::store::Store::open(&dir.join("t.db")).unwrap();
|
||||
let hash = store.put_blob("image/png", b"png bytes").unwrap();
|
||||
store
|
||||
.add_image(&ImageRecord { id: "i".into(), user_id: None, session_id: None, prompt: "p".into(), model: "m".into(), path: "/gone.png".into(), width: 1, height: 1, created_at: 0, blob: Some(hash.clone()) })
|
||||
.unwrap();
|
||||
assert_eq!(store.gc_blobs().unwrap(), 0);
|
||||
// Moved with the file manager: the entry follows (file or folder, not look-alikes).
|
||||
assert_eq!(store.move_image_paths("/gone.png", "/art/new.png").unwrap(), 1);
|
||||
assert_eq!(store.move_image_paths("/art", "/bilder/").unwrap(), 1);
|
||||
assert_eq!(store.move_image_paths("/bild", "/x").unwrap(), 0);
|
||||
assert_eq!(store.image("i").unwrap().unwrap().path, "/bilder/new.png");
|
||||
assert_eq!(store.blob(&hash).unwrap().unwrap().1, b"png bytes");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
fn m(id: &str) -> ImageModel {
|
||||
with_defaults(ImageModel { id: id.into(), ..Default::default() })
|
||||
}
|
||||
|
||||
@@ -121,7 +121,7 @@ pub fn agents(app: &App, root: &Path, cwd: &Path) -> Vec<Agent> {
|
||||
model: meta.get("model").filter(|m| m.contains('/')).cloned(),
|
||||
source: "file".into(),
|
||||
max_parallel: meta.get("max_parallel").and_then(|v| v.parse().ok()),
|
||||
refine: meta.get("refine_rounds").and_then(|v| v.parse().ok()).map(|rounds| crate::config::RefineConfig { rounds, model: meta.get("refine_model").cloned(), criteria: None }),
|
||||
refine: meta.get("refine_rounds").and_then(|v| v.parse().ok()).map(|rounds| crate::config::RefineConfig { rounds, model: meta.get("refine_model").cloned(), criteria: None, after_todos: meta.get("refine_after_todos").is_none_or(|v| v != "false") }),
|
||||
path: Some(p.display().to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
+277
-20
@@ -18,6 +18,8 @@ use tokio::sync::mpsc;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
const MAX_STEPS: usize = 250;
|
||||
/// Modes with enforced reviews work through their todo list (e.g. overnight).
|
||||
const MAX_STEPS_ENFORCED: usize = 2_000;
|
||||
const MAX_SUBAGENT_DEPTH: usize = 3;
|
||||
|
||||
/// What happened to a prompt.
|
||||
@@ -260,7 +262,15 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
|
||||
let mut stop_hooks = 0;
|
||||
let mut refine_done = 0u32;
|
||||
let turn_start = now_ms();
|
||||
for _ in 0..MAX_STEPS {
|
||||
// Compacted for the current step (no second compaction before a request went through).
|
||||
let mut compacted_now = false;
|
||||
// Mandatory reviews of completed todos: rounds per todo, and where the last one ended.
|
||||
let mut todo_reviews: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
|
||||
let mut review_since = turn_start;
|
||||
// Reminders about open todos at the end of a turn (see MAX_IDLE_CONTINUES).
|
||||
let mut last_reminder: Option<Value> = None;
|
||||
let mut idle_continues = 0u32;
|
||||
for step_no in 0..=MAX_STEPS_ENFORCED {
|
||||
if cancel.is_cancelled() {
|
||||
break;
|
||||
}
|
||||
@@ -275,40 +285,65 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
|
||||
agent.prompt = prompts::craft_prompt(clean_room);
|
||||
}
|
||||
let model_spec = agent.model.clone().unwrap_or_else(|| session.model.clone());
|
||||
let step_limit = if enforced_review(&agent) > 0 { MAX_STEPS_ENFORCED } else { MAX_STEPS };
|
||||
if step_no >= step_limit {
|
||||
app.emit(Event::Error { session_id: session_id.into(), message: format!("Stopped after {step_limit} steps (limit of a turn) – send a message to continue.") });
|
||||
break;
|
||||
}
|
||||
let (provider, mut model) = app.registry().resolve(&model_spec)?;
|
||||
if let Some(effort) = app.with_runtime(session_id, |r| r.effort.clone()) {
|
||||
model.effort = Some(effort);
|
||||
}
|
||||
|
||||
// Auto-compaction when the last request used most of the context window.
|
||||
if let (Some(u), Some(ctx_len)) = (&session.last_usage, model.context) {
|
||||
let history = active_history(app.store.messages_for_model(session_id)?);
|
||||
// Providers count input + max_tokens against the context window: the answer needs
|
||||
// room too. Estimated input = last request (input + its answer) + what came since.
|
||||
let est_input = estimate_input(session.last_usage.as_ref(), &history);
|
||||
let want_output = model.max_output.unwrap_or(16_000);
|
||||
if let Some(ctx_len) = model.context {
|
||||
let threshold = app.config().server.auto_compact_threshold;
|
||||
if threshold > 0.0 && u.context_tokens() as f64 > ctx_len as f64 * threshold {
|
||||
compact(app.clone(), session_id, cancel.clone(), Some((u.context_tokens(), ctx_len))).await?;
|
||||
// Input limit: the context window, or the provider's lower input limit.
|
||||
let input_limit = model.max_input.map_or(ctx_len, |m| m.min(ctx_len));
|
||||
let room = ctx_len.saturating_sub(est_input);
|
||||
// Most of the input limit used, or no room left for a reasonable answer.
|
||||
if threshold > 0.0 && (est_input as f64 > input_limit as f64 * threshold || room < want_output.min(MIN_ANSWER_ROOM)) && !compacted_now {
|
||||
compacted_now = true;
|
||||
compact(app.clone(), session_id, cancel.clone(), Some((est_input, input_limit))).await?;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let max_tokens = answer_budget(want_output, model.context, est_input);
|
||||
|
||||
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 history = active_history(app.store.messages_for_model(session_id)?);
|
||||
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),
|
||||
messages: history,
|
||||
tools: tools.iter().map(|t| t.def()).collect(),
|
||||
max_tokens: model.max_output.unwrap_or(16_000),
|
||||
max_tokens,
|
||||
reasoning: true,
|
||||
session_id: Some(session_id.to_string()),
|
||||
model: model.clone(),
|
||||
};
|
||||
let step = match stream_step(&app, &session, &agent.name, &model_spec, provider, req.clone(), &cancel).await {
|
||||
Ok(step) => step,
|
||||
Ok(step) => {
|
||||
compacted_now = false;
|
||||
step
|
||||
}
|
||||
// The estimate was off and the provider says the input is too long: compact
|
||||
// once and try again instead of stopping.
|
||||
Err(e) if is_context_overflow(&e) && !compacted_now && !cancel.is_cancelled() => {
|
||||
compacted_now = true;
|
||||
app.emit(Event::Error { session_id: session_id.into(), message: format!("context window full ({}) – compacting the conversation and continuing", short_error(&e)) });
|
||||
compact(app.clone(), session_id, cancel.clone(), Some((est_input, model.context.unwrap_or(0)))).await?;
|
||||
continue;
|
||||
}
|
||||
// Host down or budget used up: switch to the model's fallback for this step.
|
||||
Err(e) if model.fallback.is_some() && !cancel.is_cancelled() && needs_fallback(&e) => {
|
||||
let fb = model.fallback.clone().unwrap_or_default();
|
||||
let (fb_provider, fb_model) = app.registry().resolve(&fb)?;
|
||||
app.emit(Event::Error { session_id: session_id.into(), message: format!("{model_spec} is not available ({}) – using the fallback model {fb}", short_error(&e)) });
|
||||
let req = ChatRequest { model: fb_model.clone(), max_tokens: fb_model.max_output.unwrap_or(16_000), ..req };
|
||||
let req = ChatRequest { model: fb_model.clone(), max_tokens: answer_budget(fb_model.max_output.unwrap_or(16_000), fb_model.context, est_input), ..req };
|
||||
stream_step(&app, &session, &agent.name, &fb, fb_provider, req, &cancel).await?
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
@@ -353,12 +388,37 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
|
||||
}
|
||||
}
|
||||
let normal_end = matches!(step.stop_reason.as_str(), "stop" | "end_turn" | "");
|
||||
// Enforced reviews also mean: work until the todo list is done. Ending the turn with
|
||||
// open todos sends the agent back (as long as it makes progress).
|
||||
if calls.is_empty() && depth == 0 && normal_end && !cancel.is_cancelled() && enforced_review(&agent) > 0 {
|
||||
let todos_now = app.session(session_id)?.todos;
|
||||
let open = open_todos(&todos_now);
|
||||
if !open.is_empty() {
|
||||
let progressed = last_reminder.as_ref() != Some(&todos_now);
|
||||
idle_continues = if progressed { 0 } else { idle_continues + 1 };
|
||||
if idle_continues < MAX_IDLE_CONTINUES {
|
||||
last_reminder = Some(todos_now);
|
||||
save_user_parts(
|
||||
&app,
|
||||
session_id,
|
||||
vec![Part::Text {
|
||||
text: format!(
|
||||
"<todo-reminder open=\"{}\">\n{}\n</todo-reminder>\nThese todos are still open. Continue working until all of them are completed (each completed todo is reviewed). Only if you are truly blocked and need the user, say clearly what you need and stop.",
|
||||
open.len(),
|
||||
open.iter().map(|t| format!("- {t}")).collect::<Vec<_>>().join("\n")
|
||||
),
|
||||
}],
|
||||
)?;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
if calls.is_empty() && depth == 0 && normal_end && !cancel.is_cancelled() {
|
||||
// Refinement loop: a reviewer checks the turn; findings send the agent back to work.
|
||||
let rounds = agent.refine.as_ref().map(|r| r.rounds).unwrap_or(0);
|
||||
if refine_done < rounds {
|
||||
refine_done += 1;
|
||||
match review_turn(&app, &session, &agent, &model_spec, turn_start, refine_done, rounds, &cancel).await {
|
||||
match review_turn(&app, &session, &agent, &model_spec, turn_start, None, refine_done, rounds, &cancel).await {
|
||||
Ok(Some(feedback)) => {
|
||||
save_user_parts(&app, session_id, vec![Part::Text { text: feedback }])?;
|
||||
continue;
|
||||
@@ -412,6 +472,40 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
|
||||
results.push(execute_call(&app, &session, &base, by_name(name), id, name, input.clone(), depth).await);
|
||||
}
|
||||
}
|
||||
// Mode with a review: every todo marked completed is reviewed; with findings it is
|
||||
// reopened and the agent has to fix them and complete it again (max. `rounds` per todo).
|
||||
// The verdict goes into the same message as the tool results.
|
||||
let rounds = enforced_review(&agent);
|
||||
if rounds > 0 && depth == 0 && !cancel.is_cancelled() && calls.iter().any(|(_, n, _)| n == "todo_write") {
|
||||
let todos_now = app.session(session_id)?.todos;
|
||||
let due: Vec<String> = newly_completed(&session.todos, &todos_now)
|
||||
.into_iter()
|
||||
.filter(|t| todo_reviews.get(t).copied().unwrap_or(0) < rounds)
|
||||
.collect();
|
||||
if !due.is_empty() {
|
||||
for t in &due {
|
||||
*todo_reviews.entry(t.clone()).or_default() += 1;
|
||||
}
|
||||
let round = due.iter().filter_map(|t| todo_reviews.get(t)).copied().max().unwrap_or(1);
|
||||
let fresh = app.session(session_id)?;
|
||||
let reviewed = review_turn(&app, &fresh, &agent, &model_spec, review_since, Some(&due), round, rounds, &cancel).await;
|
||||
review_since = now_ms();
|
||||
match reviewed {
|
||||
Ok(Some(feedback)) => {
|
||||
app.set_todos(session_id, reopen_todos(&todos_now, &due))?;
|
||||
results.push(Part::Text { text: feedback });
|
||||
}
|
||||
Ok(None) => {
|
||||
let open = open_todos(&todos_now);
|
||||
let next = if open.is_empty() { "All todos are done – check the whole request once more, then finish." } else { "Continue with the next open todo." };
|
||||
results.push(Part::Text {
|
||||
text: format!("<refine-approved reviewer=\"{}\">\n{}\n</refine-approved>\nReview passed. {next}", review_model(&agent, &model_spec), due.iter().map(|t| format!("- {t}")).collect::<Vec<_>>().join("\n")),
|
||||
});
|
||||
}
|
||||
Err(e) => tracing::warn!("todo review of {session_id} failed: {e:#}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
// Deliver messages the user queued while we were working (with attachments).
|
||||
results.extend(take_queue(&app, session_id));
|
||||
save_user_parts(&app, session_id, results)?;
|
||||
@@ -419,6 +513,55 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
|
||||
Ok(final_text)
|
||||
}
|
||||
|
||||
/// Review rounds when the mode enforces reviews of completed todos (0 = not enforced).
|
||||
fn enforced_review(agent: &prompts::Agent) -> u32 {
|
||||
agent.refine.as_ref().filter(|r| r.after_todos).map(|r| r.rounds).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The model that reviews for this mode.
|
||||
fn review_model(agent: &prompts::Agent, model_spec: &str) -> String {
|
||||
agent.refine.as_ref().and_then(|r| r.model.clone()).filter(|m| !m.is_empty()).unwrap_or_else(|| model_spec.to_string())
|
||||
}
|
||||
|
||||
/// Contents of the todos that are not completed yet.
|
||||
fn open_todos(todos: &Value) -> Vec<String> {
|
||||
todos.as_array().into_iter().flatten().filter(|t| t["status"] != "completed").filter_map(|t| t["content"].as_str()).map(String::from).collect()
|
||||
}
|
||||
|
||||
/// Times in a row the agent may end its turn with open todos without making progress.
|
||||
const MAX_IDLE_CONTINUES: u32 = 10;
|
||||
|
||||
/// Contents of the todos that are completed in `after` but were not in `before`.
|
||||
fn newly_completed(before: &Value, after: &Value) -> Vec<String> {
|
||||
let done_before: std::collections::HashSet<&str> = before
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|t| t["status"] == "completed")
|
||||
.filter_map(|t| t["content"].as_str())
|
||||
.collect();
|
||||
after
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|t| t["status"] == "completed")
|
||||
.filter_map(|t| t["content"].as_str())
|
||||
.filter(|c| !done_before.contains(c))
|
||||
.map(String::from)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The todo list with the given items open again (in progress).
|
||||
fn reopen_todos(todos: &Value, items: &[String]) -> Value {
|
||||
let mut out = todos.clone();
|
||||
for t in out.as_array_mut().into_iter().flatten() {
|
||||
if t["content"].as_str().is_some_and(|c| items.iter().any(|i| i == c)) {
|
||||
t["status"] = Value::from("in_progress");
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn tool_result(call_id: &str, name: &str, content: String, is_error: bool, meta: Option<Value>) -> Part {
|
||||
Part::ToolResult { call_id: call_id.into(), name: name.into(), content, images: vec![], is_error, meta }
|
||||
}
|
||||
@@ -563,7 +706,15 @@ async fn execute_call(
|
||||
// Pass depth through the agent name channel: subagents run with depth+1.
|
||||
run_task(app, &ctx, input, depth).await
|
||||
} else {
|
||||
tool.run(input, &ctx).await
|
||||
let moves = name == "bash" && input["command"].as_str().is_some_and(moves_files);
|
||||
let out = tool.run(input, &ctx).await;
|
||||
if moves {
|
||||
// Generated images the command moved or renamed keep their entries.
|
||||
let a = app.clone();
|
||||
let owner = session.owner_id.clone();
|
||||
tokio::task::spawn_blocking(move || crate::images::relocate_missing(&a, owner.as_deref()));
|
||||
}
|
||||
out
|
||||
};
|
||||
// Diagnostics: report new errors of the changed file right away.
|
||||
let res = match res {
|
||||
@@ -609,6 +760,11 @@ async fn execute_call(
|
||||
}
|
||||
}
|
||||
|
||||
/// A shell command that may move or rename files (`mv`, `git mv`, `rename`).
|
||||
fn moves_files(cmd: &str) -> bool {
|
||||
cmd.split(|c: char| c.is_whitespace() || ";&|()`$".contains(c)).any(|w| w == "mv" || w == "rename")
|
||||
}
|
||||
|
||||
async fn run_task(app: &Arc<App>, ctx: &ToolCtx, input: Value, depth: usize) -> Result<tools::ToolOutput> {
|
||||
let prompt = tools::arg_str(&input, "prompt")?.to_string();
|
||||
let description = input["description"].as_str().unwrap_or("subagent").to_string();
|
||||
@@ -766,6 +922,47 @@ fn estimate_prompt_tokens(req: &ChatRequest) -> u64 {
|
||||
|
||||
/// Errors after which the fallback model takes over: the host is unreachable
|
||||
/// (connection, timeout, gateway errors) or the provider's budget is used up.
|
||||
/// Room an answer gets at least before the conversation is compacted.
|
||||
const MIN_ANSWER_ROOM: u64 = 32_000;
|
||||
|
||||
/// Estimated input tokens of the next request: the last request (its input and answer)
|
||||
/// plus the messages that came after that answer (tool results, user messages), at about
|
||||
/// 3 characters per token; without usage (fresh or just compacted) the whole history.
|
||||
fn estimate_input(last: Option<&crate::types::Usage>, history: &[Message]) -> u64 {
|
||||
let chars_of = |m: &Message| -> u64 {
|
||||
m.parts
|
||||
.iter()
|
||||
.map(|p| match p {
|
||||
Part::Text { text } | Part::Reasoning { text, .. } | Part::Summary { text, .. } => text.len() as u64,
|
||||
Part::ToolCall { input, .. } => input.to_string().len() as u64,
|
||||
Part::ToolResult { content, images, .. } => content.len() as u64 + images.len() as u64 * 4_500,
|
||||
Part::Image { .. } => 4_500,
|
||||
})
|
||||
.sum()
|
||||
};
|
||||
match last {
|
||||
Some(u) => {
|
||||
let after = history.iter().rposition(|m| m.role == Role::Assistant).map(|i| &history[i + 1..]).unwrap_or(&[]);
|
||||
u.context_tokens() + after.iter().map(chars_of).sum::<u64>() / 3
|
||||
}
|
||||
None => history.iter().map(chars_of).sum::<u64>() / 3 + 8_000,
|
||||
}
|
||||
}
|
||||
|
||||
/// max_tokens for a request: the model's maximum, but no more than the window leaves
|
||||
/// (with a margin for the estimate), and never below a small floor.
|
||||
fn answer_budget(max_output: u64, context: Option<u64>, est_input: u64) -> u64 {
|
||||
let Some(ctx) = context else { return max_output };
|
||||
let room = ctx.saturating_sub(est_input + est_input / 50 + 2_000);
|
||||
max_output.min(room).max(4_096.min(max_output))
|
||||
}
|
||||
|
||||
/// The provider rejected the request because input + output exceed the context window.
|
||||
fn is_context_overflow(e: &anyhow::Error) -> bool {
|
||||
let s = format!("{e:#}").to_lowercase();
|
||||
["input too long", "context length", "context_length", "maximum context", "context window", "prompt is too long", "too many tokens", "reduce the length"].iter().any(|k| s.contains(k))
|
||||
}
|
||||
|
||||
fn needs_fallback(e: &anyhow::Error) -> bool {
|
||||
let s = format!("{e:#}").to_lowercase();
|
||||
["connect", "timed out", "timeout", "http 502", "http 503", "http 504", "dns", "budget"].iter().any(|k| s.contains(k))
|
||||
@@ -1120,7 +1317,7 @@ fn advance_phase(app: &App, session_id: &str, phase: &str, only_from: Option<&st
|
||||
/// Review the last turn against the user's request. Returns feedback for the
|
||||
/// agent, or None when the reviewer approves.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn review_turn(app: &Arc<App>, session: &Session, agent: &prompts::Agent, model_spec: &str, turn_start: i64, round: u32, rounds: u32, cancel: &CancellationToken) -> Result<Option<String>> {
|
||||
async fn review_turn(app: &Arc<App>, session: &Session, agent: &prompts::Agent, model_spec: &str, since: i64, todos: Option<&[String]>, round: u32, rounds: u32, cancel: &CancellationToken) -> Result<Option<String>> {
|
||||
let refine = agent.refine.clone().unwrap_or_default();
|
||||
let spec = refine.model.clone().filter(|m| !m.is_empty()).unwrap_or_else(|| model_spec.to_string());
|
||||
let (provider, mut model) = app.registry().resolve(&spec)?;
|
||||
@@ -1134,7 +1331,12 @@ async fn review_turn(app: &Arc<App>, session: &Session, agent: &prompts::Agent,
|
||||
.rposition(|m| matches!(m.role, Role::User) && m.parts.iter().any(|p| matches!(p, Part::Text { text } if !text.starts_with("<refine-feedback") && !text.starts_with("<hook-"))))
|
||||
.unwrap_or(0);
|
||||
let request: String = history.get(req_idx).map(|m| m.parts.iter().filter_map(|p| if let Part::Text { text } = p { Some(text.as_str()) } else { None }).collect::<Vec<_>>().join("\n")).unwrap_or_default();
|
||||
let mut work = transcript_of(&history[(req_idx + 1).min(history.len())..]);
|
||||
// A todo review looks at the work since the last review, the end-of-turn review at the whole turn.
|
||||
let from = match todos {
|
||||
Some(_) => history.iter().position(|m| m.created_at >= since).unwrap_or(history.len()).max(req_idx + 1),
|
||||
None => req_idx + 1,
|
||||
};
|
||||
let mut work = transcript_of(&history[from.min(history.len())..]);
|
||||
if work.len() > 30_000 {
|
||||
let mut start = work.len() - 30_000;
|
||||
while !work.is_char_boundary(start) {
|
||||
@@ -1145,7 +1347,7 @@ async fn review_turn(app: &Arc<App>, session: &Session, agent: &prompts::Agent,
|
||||
// Actual file changes of this turn (checkpoint vs. current content).
|
||||
let mut changes = String::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for (path, before) in app.store.checkpoints_since(&session.id, turn_start)? {
|
||||
for (path, before) in app.store.checkpoints_since(&session.id, since)? {
|
||||
if !seen.insert(path.clone()) || changes.len() > 30_000 {
|
||||
continue;
|
||||
}
|
||||
@@ -1159,10 +1361,17 @@ async fn review_turn(app: &Arc<App>, session: &Session, agent: &prompts::Agent,
|
||||
let system = "You review the work of an AI coding agent for the user. Be strict about what matters for the user's request and ignore style nitpicks. \
|
||||
Reply in exactly this format: the first line is APPROVED or CHANGES_NEEDED. For CHANGES_NEEDED, follow with at most 8 concrete, actionable findings as a bullet list \
|
||||
(with file:line where possible). If the important work is done and correct, reply APPROVED.";
|
||||
let focus = match todos {
|
||||
Some(items) => format!(
|
||||
"<completed-todos>\n{}\n</completed-todos>\nThe agent just marked these todo items as completed. Review only whether they are really done and correct; other parts of the request may still be in progress.\n\n",
|
||||
items.iter().map(|t| format!("- {t}")).collect::<Vec<_>>().join("\n")
|
||||
),
|
||||
None => String::new(),
|
||||
};
|
||||
let body = format!(
|
||||
"<request>\n{request}\n</request>\n\n<criteria>\n{criteria}\n</criteria>\n\n<agent-work mode=\"{}\">\n{work}\n</agent-work>\n\n<file-changes>\n{}\n</file-changes>",
|
||||
"<request>\n{request}\n</request>\n\n{focus}<criteria>\n{criteria}\n</criteria>\n\n<agent-work mode=\"{}\">\n{work}\n</agent-work>\n\n<file-changes>\n{}\n</file-changes>",
|
||||
agent.name,
|
||||
if changes.is_empty() { "(no files changed in this turn)" } else { changes.as_str() }
|
||||
if changes.is_empty() { "(no files changed since the last review)" } else { changes.as_str() }
|
||||
);
|
||||
let req = ChatRequest { system: system.into(), messages: vec![Message::new(Role::User, vec![Part::Text { text: body }])], tools: vec![], max_tokens: 4000, reasoning: false, model: model.clone(), session_id: Some(session.id.clone()) };
|
||||
let (text, usage) = complete_text(app, &spec, provider, req, cancel.clone()).await?;
|
||||
@@ -1175,10 +1384,17 @@ async fn review_turn(app: &Arc<App>, session: &Session, agent: &prompts::Agent,
|
||||
}
|
||||
let findings: String = lines.collect::<Vec<_>>().join("\n");
|
||||
let findings = if findings.trim().is_empty() { text.clone() } else { findings };
|
||||
Ok(Some(format!(
|
||||
"<refine-feedback round=\"{round}/{rounds}\" reviewer=\"{spec}\">\n{}\n</refine-feedback>\nAddress these review findings (or explain briefly why one does not apply), verify, then finish.",
|
||||
findings.trim()
|
||||
)))
|
||||
Ok(Some(match todos {
|
||||
Some(items) => format!(
|
||||
"<refine-feedback round=\"{round}/{rounds}\" reviewer=\"{spec}\" todos=\"{}\">\n{}\n</refine-feedback>\nThe review of the todo(s) you just completed found problems, so they are open again. Fix these findings (or explain briefly why one does not apply), verify, then mark the todo(s) completed again – that triggers the next review.",
|
||||
items.len(),
|
||||
findings.trim()
|
||||
),
|
||||
None => format!(
|
||||
"<refine-feedback round=\"{round}/{rounds}\" reviewer=\"{spec}\">\n{}\n</refine-feedback>\nAddress these review findings (or explain briefly why one does not apply), verify, then finish.",
|
||||
findings.trim()
|
||||
),
|
||||
}))
|
||||
}
|
||||
.await;
|
||||
app.emit(Event::Waiting { session_id: session.id.clone(), reason: None });
|
||||
@@ -1301,6 +1517,47 @@ async fn compact_inner(app: Arc<App>, session_id: &str, cancel: CancellationToke
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn todo_completion_and_reopening() {
|
||||
let before = serde_json::json!([{"content": "A", "status": "in_progress"}, {"content": "B", "status": "completed"}, {"content": "C", "status": "pending"}]);
|
||||
let after = serde_json::json!([{"content": "A", "status": "completed"}, {"content": "B", "status": "completed"}, {"content": "C", "status": "pending"}]);
|
||||
assert_eq!(newly_completed(&before, &after), vec!["A".to_string()]);
|
||||
assert_eq!(newly_completed(&Value::Null, &after), vec!["A".to_string(), "B".to_string()]);
|
||||
assert_eq!(open_todos(&after), vec!["C".to_string()]);
|
||||
let reopened = reopen_todos(&after, &["A".to_string()]);
|
||||
assert_eq!(reopened[0]["status"], "in_progress");
|
||||
assert_eq!(reopened[1]["status"], "completed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn answer_room_and_overflow() {
|
||||
// The case from production: DeepSeek V4.1 Flash, 1M window, 384K output, 720K input.
|
||||
let (ctx, out) = (1_048_576u64, 393_216u64);
|
||||
let b = answer_budget(out, Some(ctx), 720_000);
|
||||
assert!(720_000 + b <= ctx && b > 300_000, "{b}");
|
||||
assert_eq!(answer_budget(out, Some(ctx), 100_000), out);
|
||||
assert_eq!(answer_budget(16_000, None, 5_000_000), 16_000);
|
||||
assert_eq!(answer_budget(out, Some(ctx), 1_040_000), 4_096);
|
||||
assert!(is_context_overflow(&anyhow!("HTTP 400: [invalid_request_error] Input too long: model limit 1048576 tokens, requested 1108739 tokens")));
|
||||
assert!(is_context_overflow(&anyhow!("This model's maximum context length is 128000 tokens")));
|
||||
assert!(!is_context_overflow(&anyhow!("HTTP 429 rate limited")));
|
||||
// Estimate: last request + what came after its answer (about 3 chars per token).
|
||||
let u = crate::types::Usage { input_tokens: 1_000, cache_read_tokens: 9_000, output_tokens: 500, ..Default::default() };
|
||||
let mut call = Message::new(Role::Assistant, vec![call("c1")]);
|
||||
call.id = "a".into();
|
||||
let result = Message::new(Role::User, vec![Part::ToolResult { call_id: "c1".into(), name: "bash".into(), content: "x".repeat(3_000), images: vec![], is_error: false, meta: None }]);
|
||||
assert_eq!(estimate_input(Some(&u), &[call.clone(), result.clone()]), 10_500 + 1_000);
|
||||
assert!(estimate_input(None, &[call, result]) >= 1_000 + 8_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_commands_that_move_files() {
|
||||
assert!(moves_files("mkdir -p art/old && mv art/*.png art/old/"));
|
||||
assert!(moves_files("git mv a.png b.png"));
|
||||
assert!(moves_files("cd art;mv x y"));
|
||||
assert!(!moves_files("ls -la movies && cat remove.txt"));
|
||||
}
|
||||
|
||||
fn call(id: &str) -> Part {
|
||||
Part::ToolCall { id: id.into(), name: "bash".into(), input: serde_json::json!({}), extra: None }
|
||||
}
|
||||
|
||||
+53
-3
@@ -170,6 +170,7 @@ impl Store {
|
||||
("users", "monthly_quota", "ALTER TABLE users ADD COLUMN monthly_quota REAL"),
|
||||
("jobs", "webhook_secret", "ALTER TABLE jobs ADD COLUMN webhook_secret TEXT"),
|
||||
("jobs", "settings", "ALTER TABLE jobs ADD COLUMN settings TEXT"),
|
||||
("images", "blob", "ALTER TABLE images ADD COLUMN blob TEXT"),
|
||||
("usage", "allowance", "ALTER TABLE usage ADD COLUMN allowance REAL"),
|
||||
] {
|
||||
let exists: bool = conn
|
||||
@@ -394,6 +395,10 @@ impl Store {
|
||||
}
|
||||
drop(rows);
|
||||
drop(stmt);
|
||||
// Copies of generated images (see ImageRecord::blob).
|
||||
for h in conn.prepare("SELECT blob FROM images WHERE blob IS NOT NULL")?.query_map([], |r| r.get::<_, String>(0))? {
|
||||
used.insert(h?);
|
||||
}
|
||||
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)) {
|
||||
@@ -966,6 +971,10 @@ pub struct ImageRecord {
|
||||
pub width: i64,
|
||||
pub height: i64,
|
||||
pub created_at: i64,
|
||||
/// Copy of the bytes in the image store: the file may be moved or deleted later
|
||||
/// (agents tidy up folders), the image stays viewable.
|
||||
#[serde(skip)]
|
||||
pub blob: Option<String>,
|
||||
}
|
||||
|
||||
fn image_row(r: &rusqlite::Row) -> rusqlite::Result<ImageRecord> {
|
||||
@@ -979,21 +988,62 @@ fn image_row(r: &rusqlite::Row) -> rusqlite::Result<ImageRecord> {
|
||||
width: r.get(6)?,
|
||||
height: r.get(7)?,
|
||||
created_at: r.get(8)?,
|
||||
blob: r.get(9)?,
|
||||
})
|
||||
}
|
||||
|
||||
const IMAGE_COLS: &str = "id, user_id, session_id, prompt, model, path, width, height, created_at";
|
||||
const IMAGE_COLS: &str = "id, user_id, session_id, prompt, model, path, width, height, created_at, blob";
|
||||
|
||||
impl Store {
|
||||
pub fn add_image(&self, img: &ImageRecord) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
&format!("INSERT INTO images ({IMAGE_COLS}) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)"),
|
||||
params![img.id, img.user_id, img.session_id, img.prompt, img.model, img.path, img.width, img.height, img.created_at],
|
||||
&format!("INSERT INTO images ({IMAGE_COLS}) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)"),
|
||||
params![img.id, img.user_id, img.session_id, img.prompt, img.model, img.path, img.width, img.height, img.created_at, img.blob],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Store bytes in the image store; returns their hash.
|
||||
pub fn put_blob(&self, media_type: &str, bytes: &[u8]) -> Result<String> {
|
||||
let hash = crate::blobs::hash(bytes);
|
||||
self.conn.lock().unwrap().execute(
|
||||
"INSERT OR IGNORE INTO blobs (hash, media_type, data, created_at) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![hash, media_type, bytes, now_ms()],
|
||||
)?;
|
||||
Ok(hash)
|
||||
}
|
||||
|
||||
/// Generated images from before the copies in the image store (id, path).
|
||||
pub fn images_without_copy(&self) -> Result<Vec<(String, String)>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare("SELECT id, path FROM images WHERE blob IS NULL")?;
|
||||
let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub fn set_image_blob(&self, id: &str, hash: &str) -> Result<()> {
|
||||
self.conn.lock().unwrap().execute("UPDATE images SET blob = ?2 WHERE id = ?1", params![id, hash])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A file or folder was moved: generated images in it follow.
|
||||
pub fn move_image_paths(&self, from: &str, to: &str) -> Result<usize> {
|
||||
let from = from.trim_end_matches('/');
|
||||
let to = to.trim_end_matches('/');
|
||||
Ok(self.conn.lock().unwrap().execute(
|
||||
"UPDATE images SET path = ?2 || substr(path, length(?1) + 1)
|
||||
WHERE path = ?1 OR substr(path, 1, length(?1) + 1) = ?1 || '/'",
|
||||
params![from, to],
|
||||
)?)
|
||||
}
|
||||
|
||||
/// The file of a generated image was found somewhere else.
|
||||
pub fn set_image_path(&self, id: &str, path: &str) -> Result<()> {
|
||||
self.conn.lock().unwrap().execute("UPDATE images SET path = ?2 WHERE id = ?1", params![id, path])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Latest images; `user = None` lists everybody's (admins).
|
||||
pub fn images(&self, user: Option<&str>, limit: i64) -> Result<Vec<ImageRecord>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
|
||||
+3
-1
@@ -2,7 +2,9 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<!-- interactive-widget: the on-screen keyboard shrinks the page (Android) instead of
|
||||
shifting it up – the top bar stays in place. -->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content" />
|
||||
<meta name="theme-color" content="#282828" />
|
||||
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
|
||||
@@ -115,7 +115,36 @@ function BackgroundResult({ text }: { text: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
const REFINE_RE = /^<refine-feedback round="([^"]*)" reviewer="([^"]*)">\n?([\s\S]*?)\n?<\/refine-feedback>/;
|
||||
const REFINE_RE = /^<refine-feedback round="([^"]*)" reviewer="([^"]*)"(?: todos="\d+")?>\n?([\s\S]*?)\n?<\/refine-feedback>/;
|
||||
/** Enforced reviews: a completed todo passed, or a reminder about open todos. */
|
||||
const NOTE_RE = /^<(refine-approved|todo-reminder)(?: reviewer="([^"]*)")?(?: open="\d+")?>\n?([\s\S]*?)\n?<\/(?:refine-approved|todo-reminder)>/;
|
||||
/** Messages from the harness shown as cards instead of user bubbles. */
|
||||
const isHarnessNote = (text: string) => BG_RE.test(text) || REFINE_RE.test(text) || NOTE_RE.test(text);
|
||||
|
||||
/** A todo passed its review, or the agent was sent back to its open todos. */
|
||||
function HarnessNote({ text }: { text: string }) {
|
||||
const t = useT();
|
||||
const m = text.match(NOTE_RE);
|
||||
if (!m) return null;
|
||||
const [, kind, reviewer, body] = m;
|
||||
const items = body.split("\n").map((l) => l.replace(/^- /, "").trim()).filter(Boolean);
|
||||
const approved = kind === "refine-approved";
|
||||
return (
|
||||
<div className={`harness-note ${approved ? "ok" : ""}`}>
|
||||
<Icon name={approved ? "check" : "refresh"} />
|
||||
<span className="grow">
|
||||
<b>{t(approved ? "chat.reviewPassed" : "chat.todoReminder", { n: items.length })}</b> {items.join(" · ")}
|
||||
</span>
|
||||
{reviewer && <span className="chip">{reviewer}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HarnessMessage({ text }: { text: string }) {
|
||||
if (REFINE_RE.test(text)) return <RefineFeedback text={text} />;
|
||||
if (NOTE_RE.test(text)) return <HarnessNote text={text} />;
|
||||
return <BackgroundResult text={text} />;
|
||||
}
|
||||
|
||||
/** Findings of the review loop, sent back to the agent. */
|
||||
function RefineFeedback({ text }: { text: string }) {
|
||||
@@ -202,6 +231,17 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
||||
const el = scrollRef.current;
|
||||
if (el && stick.current) el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
// … also when the pane gets smaller (on-screen keyboard opens) without a render.
|
||||
const hasSession = !!session;
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el || typeof ResizeObserver === "undefined") return;
|
||||
const ro = new ResizeObserver(() => {
|
||||
if (stick.current) el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [hasSession, sessionId]);
|
||||
|
||||
if (loadError) return <div className="empty">{loadError}</div>;
|
||||
if (!session) return <div className="empty">{t("common.loading")}</div>;
|
||||
@@ -458,14 +498,14 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
||||
);
|
||||
}
|
||||
const allTexts = m.parts.filter((p) => p.type === "text") as { text: string }[];
|
||||
const background = allTexts.filter((p) => BG_RE.test(p.text) || REFINE_RE.test(p.text));
|
||||
const texts = allTexts.filter((p) => !BG_RE.test(p.text) && !REFINE_RE.test(p.text));
|
||||
const background = allTexts.filter((p) => isHarnessNote(p.text));
|
||||
const texts = allTexts.filter((p) => !isHarnessNote(p.text));
|
||||
const imgs = m.parts.filter((p) => p.type === "image") as { image: ImageData }[];
|
||||
if (texts.length === 0 && imgs.length === 0) {
|
||||
return background.length ? (
|
||||
<div key={m.id} className="msg">
|
||||
{background.map((p, i) => (
|
||||
REFINE_RE.test(p.text) ? <RefineFeedback key={i} text={p.text} /> : <BackgroundResult key={i} text={p.text} />
|
||||
<HarnessMessage key={i} text={p.text} />
|
||||
))}
|
||||
</div>
|
||||
) : null;
|
||||
@@ -473,7 +513,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
||||
return (
|
||||
<div key={m.id} className="msg">
|
||||
{background.map((p, i) => (
|
||||
REFINE_RE.test(p.text) ? <RefineFeedback key={i} text={p.text} /> : <BackgroundResult key={i} text={p.text} />
|
||||
<HarnessMessage key={i} text={p.text} />
|
||||
))}
|
||||
<div className="msg-user">
|
||||
{!busy && !readOnly && (
|
||||
@@ -597,9 +637,10 @@ function UserText({ text }: { text: string }) {
|
||||
const last = rest.pop()!;
|
||||
if (last.trim().endsWith(":")) break;
|
||||
}
|
||||
const body = rest.join("\n").trim();
|
||||
return (
|
||||
<>
|
||||
{rest.join("\n").trimEnd()}
|
||||
{body && <Markdown text={body} breaks />}
|
||||
{files.length > 0 && (
|
||||
<div className="user-files">
|
||||
{files.map((f) => (
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { ImageData, Session } from "../types";
|
||||
import { ModelSelect } from "./ModelSelect";
|
||||
import { EffortSelect } from "./Regenerate";
|
||||
import { VoiceButton } from "./Voice";
|
||||
import { Markdown } from "./Markdown";
|
||||
import { confirmDialog, promptDialog, toast } from "../dialogs";
|
||||
|
||||
interface Suggestion {
|
||||
@@ -67,6 +68,8 @@ export function Composer({
|
||||
const [effort, setEffort] = useState("");
|
||||
const [interim, setInterim] = useState("");
|
||||
const [picker, setPicker] = useState(false);
|
||||
// Rendered Markdown instead of the text field (to check formatting before sending).
|
||||
const [preview, setPreview] = useState(false);
|
||||
const models = useModels();
|
||||
const [prov, ...mid] = session.model.split("/");
|
||||
const reasoning = !!models.find((p) => p.provider === prov)?.models.find((m) => m.id === mid.join("/"))?.reasoning;
|
||||
@@ -265,7 +268,20 @@ export function Composer({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{preview && (
|
||||
<div
|
||||
className="composer-preview"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
title={t("composer.previewEdit")}
|
||||
onClick={() => (setPreview(false), setTimeout(() => ref.current?.focus(), 0))}
|
||||
onKeyDown={(e) => e.key === "Enter" && (setPreview(false), setTimeout(() => ref.current?.focus(), 0))}
|
||||
>
|
||||
{text.trim() ? <Markdown text={text} breaks /> : <span className="hint">{t("composer.previewEmpty")}</span>}
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
style={preview ? { display: "none" } : undefined}
|
||||
ref={ref}
|
||||
rows={1}
|
||||
value={text}
|
||||
@@ -337,6 +353,15 @@ export function Composer({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
className={`ghost ${preview ? "active" : ""}`}
|
||||
style={{ padding: "3px 6px" }}
|
||||
onClick={() => setPreview(!preview)}
|
||||
aria-pressed={preview}
|
||||
title={t(preview ? "composer.previewEdit" : "composer.preview")}
|
||||
>
|
||||
<Icon name={preview ? "edit" : "eye"} />
|
||||
</button>
|
||||
<VoiceButton onText={(d) => setText(text ? `${text.replace(/\s+$/, "")} ${d}` : d)} onInterim={setInterim} />
|
||||
<label className="ghost" style={{ cursor: "pointer", padding: "3px 6px" }} title={t("composer.attach")}>
|
||||
<Icon name="clip" />
|
||||
|
||||
@@ -44,7 +44,30 @@ export function SvgBlock({ code, children }: { code: string; children?: ReactNod
|
||||
);
|
||||
}
|
||||
|
||||
export const Markdown = memo(function Markdown({ text, baseDir }: { text: string; baseDir?: string }) {
|
||||
/**
|
||||
* Keep single line breaks as typed (Markdown would join the lines into one paragraph):
|
||||
* a hard break at the end of every line that continues a paragraph. Code blocks stay as
|
||||
* they are.
|
||||
*/
|
||||
export function keepLineBreaks(text: string) {
|
||||
let fence: string | null = null;
|
||||
const lines = text.split("\n");
|
||||
return lines
|
||||
.map((line, i) => {
|
||||
const f = line.match(/^\s*(`{3,}|~{3,})/);
|
||||
if (f) {
|
||||
if (!fence) fence = f[1][0];
|
||||
else if (f[1][0] === fence) fence = null;
|
||||
return line;
|
||||
}
|
||||
const next = lines[i + 1];
|
||||
if (fence || next === undefined || !line.trim() || !next.trim() || / {2}$|\\$/.test(line)) return line;
|
||||
return `${line} `;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export const Markdown = memo(function Markdown({ text, baseDir, breaks }: { text: string; baseDir?: string; breaks?: boolean }) {
|
||||
return (
|
||||
<div className="markdown">
|
||||
<ReactMarkdown
|
||||
@@ -81,7 +104,7 @@ export const Markdown = memo(function Markdown({ text, baseDir }: { text: string
|
||||
},
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
{breaks ? keepLineBreaks(text) : text}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Icon } from "./Icon";
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { post, uploadFile } from "../api";
|
||||
import { promptDialog, toast } from "../dialogs";
|
||||
import { useAgents, useFetch } from "../hooks";
|
||||
import { useT, type Key } from "../i18n";
|
||||
import type { ImageData } from "../types";
|
||||
@@ -602,6 +603,20 @@ export function FolderPicker({ path, onPick }: { path: string; onPick: (p: strin
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [listing?.path]);
|
||||
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 (
|
||||
<div className="wiz-picker">
|
||||
{roots.length > 1 && (
|
||||
@@ -622,6 +637,11 @@ export function FolderPicker({ path, onPick }: { path: string; onPick: (p: strin
|
||||
<Icon name="arrow-up" /> {t("new.up")}
|
||||
</button>
|
||||
)}
|
||||
{listing && (
|
||||
<button className="small" onClick={() => void newFolder()}>
|
||||
<Icon name="plus" /> {t("files.newFolder")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="dir-list">
|
||||
{listing?.dirs.length === 0 && <div className="empty" style={{ padding: 16 }}>{t("new.noSubdirs")}</div>}
|
||||
|
||||
@@ -376,6 +376,7 @@ interface ModelRow {
|
||||
name?: string;
|
||||
context?: number;
|
||||
max_output?: number;
|
||||
max_input?: number;
|
||||
reasoning?: boolean;
|
||||
vision?: boolean;
|
||||
effort?: string;
|
||||
@@ -454,6 +455,7 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re
|
||||
price_multipliers: f.price_multipliers ?? m.price_multipliers,
|
||||
context: m.context ?? f.context,
|
||||
max_output: m.max_output ?? f.max_output,
|
||||
max_input: m.max_input ?? f.max_input ?? undefined,
|
||||
vision: m.vision || !!f.vision,
|
||||
reasoning: m.reasoning || !!f.reasoning,
|
||||
};
|
||||
@@ -616,6 +618,7 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re
|
||||
<th>ID</th>
|
||||
<th>{t("settings.context")}</th>
|
||||
<th>{t("settings.maxOutput")}</th>
|
||||
<th title={t("settings.maxInputHint")}>{t("settings.maxInput")}</th>
|
||||
<th title={t("settings.reasoning")}><Icon name="brain" /></th>
|
||||
<th title={t("settings.vision")}><Icon name="eye" /></th>
|
||||
<th>{t("settings.effort")}</th>
|
||||
@@ -640,6 +643,9 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re
|
||||
<td>
|
||||
<input type="number" value={m.max_output ?? ""} onChange={(e) => setModel(i, { max_output: num(e.target.value) })} />
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" value={m.max_input ?? ""} placeholder="–" onChange={(e) => setModel(i, { max_input: num(e.target.value) })} />
|
||||
</td>
|
||||
<td>
|
||||
<input type="checkbox" checked={!!m.reasoning} onChange={(e) => setModel(i, { reasoning: e.target.checked })} />
|
||||
</td>
|
||||
|
||||
@@ -158,11 +158,25 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r
|
||||
)}
|
||||
{Array.isArray(meta?.image_ids) ? (
|
||||
<div className="gallery small">
|
||||
{meta.image_ids.map((id: string) => (
|
||||
<FileFigure key={id} url={`/api/images/${id}/file`} className="gallery-item">
|
||||
<img src={`/api/images/${id}/file`} alt="" />
|
||||
</FileFigure>
|
||||
))}
|
||||
{meta.image_ids.map((id: string, i: number) => {
|
||||
// The file may be gone (moved or deleted later): fall back to the preview the model saw.
|
||||
const preview = result?.images?.[i];
|
||||
return (
|
||||
<FileFigure key={id} url={`/api/images/${id}/file`} className="gallery-item">
|
||||
<img
|
||||
src={`/api/images/${id}/file`}
|
||||
alt=""
|
||||
onError={(e) => {
|
||||
const img = e.currentTarget;
|
||||
if (preview && !img.dataset.fallback) {
|
||||
img.dataset.fallback = "1";
|
||||
img.src = imageSrc(preview);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FileFigure>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
result?.images?.map((img, i) => (
|
||||
|
||||
@@ -61,6 +61,7 @@ function AgentRow({ a, raw, tools, onSaved }: { a: AgentInfo; raw: any; tools: T
|
||||
refine_rounds: String(raw?.refine?.rounds ?? a.refine?.rounds ?? 0),
|
||||
refine_model: raw?.refine?.model ?? "",
|
||||
refine_criteria: raw?.refine?.criteria ?? "",
|
||||
refine_after_todos: (raw?.refine?.after_todos ?? a.refine?.after_todos ?? true) as boolean,
|
||||
});
|
||||
const builtin = a.source === "builtin" || (a.source === "config" && !raw?.prompt && ["build", "plan", "craft", "chat", "general", "explore"].includes(a.name));
|
||||
|
||||
@@ -74,7 +75,7 @@ function AgentRow({ a, raw, tools, onSaved }: { a: AgentInfo; raw: any; tools: T
|
||||
const rounds = Math.max(0, Math.min(10, Number(form.refine_rounds) || 0));
|
||||
patches.push({
|
||||
path: [...base, "refine"],
|
||||
value: rounds || form.refine_model || form.refine_criteria ? { rounds, model: form.refine_model || null, criteria: form.refine_criteria || null } : null,
|
||||
value: rounds || form.refine_model || form.refine_criteria ? { rounds, model: form.refine_model || null, criteria: form.refine_criteria || null, after_todos: form.refine_after_todos } : null,
|
||||
});
|
||||
}
|
||||
if (!builtin || form.description) patches.push({ path: [...base, "description"], value: form.description || null });
|
||||
@@ -158,6 +159,11 @@ function AgentRow({ a, raw, tools, onSaved }: { a: AgentInfo; raw: any; tools: T
|
||||
<label>{t("agents.refineCriteria")}</label>
|
||||
<textarea rows={3} value={form.refine_criteria} onChange={(e) => setForm({ ...form, refine_criteria: e.target.value })} />
|
||||
<div className="hint">{t("agents.refineCriteriaHint")}</div>
|
||||
<label className="check">
|
||||
<input type="checkbox" checked={form.refine_after_todos} onChange={(e) => setForm({ ...form, refine_after_todos: e.target.checked })} />
|
||||
{t("agents.refineAfterTodos")}
|
||||
</label>
|
||||
<div className="hint">{t("agents.refineAfterTodosHint")}</div>
|
||||
</>
|
||||
)}
|
||||
<label>{t("agents.description")}</label>
|
||||
|
||||
@@ -334,6 +334,10 @@ export const de: Messages = {
|
||||
"wizard.idea.cadBox": "Eine Box mit Schnappdeckel, Innenmaß 80 × 50 × 30 mm",
|
||||
"wizard.idea.cadBracket": "Eine Wandhalterung für ein 25-mm-Rohr mit zwei M4-Schraublöchern",
|
||||
"activity.reviewing": "Ergebnis wird geprüft (Runde {round})…",
|
||||
"chat.reviewPassed": "Review bestanden:",
|
||||
"chat.todoReminder": "Weiterarbeiten – {n} offene Todos:",
|
||||
"agents.refineAfterTodos": "Jedes erledigte Todo prüfen und bis zum Ende der Liste arbeiten",
|
||||
"agents.refineAfterTodosHint": "Jedes Todo, das der Agent als erledigt markiert, wird geprüft; bei Befunden wird es wieder geöffnet. Beendet er den Turn mit offenen Todos, arbeitet er weiter (für unbeaufsichtigte Läufe, z. B. über Nacht). Aus: nur ein Review am Ende des Turns.",
|
||||
"chat.reviewRound": "Review {round}: Änderungen nötig",
|
||||
"agents.refine": "Review-Schleife",
|
||||
"agents.refineRounds": "Review-Runden pro Aufgabe",
|
||||
@@ -554,6 +558,7 @@ export const de: Messages = {
|
||||
"jobs.tplMode": "Modus",
|
||||
"new.title": "Neue Session",
|
||||
"new.directory": "Projektverzeichnis",
|
||||
"new.invalidFolderName": "Ordnernamen dürfen kein „/“ enthalten.",
|
||||
"new.up": "Hoch",
|
||||
"new.noSubdirs": "Keine Unterverzeichnisse",
|
||||
"new.model": "Modell",
|
||||
@@ -662,6 +667,9 @@ export const de: Messages = {
|
||||
"composer.mode": "Modus",
|
||||
"composer.model": "Modell",
|
||||
"composer.permissionMode": "Berechtigungsmodus",
|
||||
"composer.preview": "Vorschau (Markdown)",
|
||||
"composer.previewEdit": "Zurück zum Bearbeiten",
|
||||
"composer.previewEmpty": "Noch nichts zum Anzeigen.",
|
||||
"composer.attach": "Dateien anhängen",
|
||||
|
||||
"command.compact": "Unterhaltung zusammenfassen, um Kontext freizugeben",
|
||||
@@ -753,6 +761,8 @@ export const de: Messages = {
|
||||
"settings.pricesHint": "Preise pro 1 Mio. Tokens in der eingestellten Währung. Cache-Preise fallen auf den Input-Preis zurück.",
|
||||
"settings.modelsEmptyHint": "Keine Modelle eingetragen: Sie werden automatisch beim Provider abgefragt.",
|
||||
"settings.context": "Kontext",
|
||||
"settings.maxInput": "Max. Eingabe",
|
||||
"settings.maxInputHint": "Eingabe-Limit, falls der Anbieter eines unterhalb des Kontextfensters setzt (leer: das Kontextfenster). Der Chat wird zusammengefasst, bevor die Eingabe es erreicht.",
|
||||
"settings.maxOutput": "Max. Ausgabe",
|
||||
"settings.reasoning": "Reasoning",
|
||||
"settings.vision": "Bilder",
|
||||
|
||||
@@ -333,6 +333,10 @@ export const en = {
|
||||
"wizard.idea.cadBox": "A box with a snap-on lid, inner size 80 × 50 × 30 mm",
|
||||
"wizard.idea.cadBracket": "A wall bracket for a 25 mm pipe with two M4 screw holes",
|
||||
"activity.reviewing": "Reviewing the result (round {round})…",
|
||||
"chat.reviewPassed": "Review passed:",
|
||||
"chat.todoReminder": "Back to work – {n} open todos:",
|
||||
"agents.refineAfterTodos": "Review every completed todo and work until the list is done",
|
||||
"agents.refineAfterTodosHint": "Each todo the agent marks completed is reviewed; with findings it is reopened. Ending the turn with open todos sends the agent back to work (for unattended runs, e.g. overnight). Off: only one review at the end of the turn.",
|
||||
"chat.reviewRound": "Review {round}: changes requested",
|
||||
"agents.refine": "Review loop",
|
||||
"agents.refineRounds": "Review rounds per task",
|
||||
@@ -553,6 +557,7 @@ export const en = {
|
||||
"jobs.tplMode": "Mode",
|
||||
"new.title": "New session",
|
||||
"new.directory": "Project directory",
|
||||
"new.invalidFolderName": "Folder names cannot contain “/”.",
|
||||
"new.up": "Up",
|
||||
"new.noSubdirs": "No subdirectories",
|
||||
"new.model": "Model",
|
||||
@@ -661,6 +666,9 @@ export const en = {
|
||||
"composer.mode": "Mode",
|
||||
"composer.model": "Model",
|
||||
"composer.permissionMode": "Permission mode",
|
||||
"composer.preview": "Preview (Markdown)",
|
||||
"composer.previewEdit": "Back to editing",
|
||||
"composer.previewEmpty": "Nothing to preview yet.",
|
||||
"composer.attach": "Attach files",
|
||||
|
||||
"command.compact": "Summarize the conversation to free up context",
|
||||
@@ -752,6 +760,8 @@ export const en = {
|
||||
"settings.pricesHint": "Prices per 1M tokens in the configured currency. Cache prices default to the input price.",
|
||||
"settings.modelsEmptyHint": "No models listed: they are discovered automatically from the provider.",
|
||||
"settings.context": "Context",
|
||||
"settings.maxInput": "Max input",
|
||||
"settings.maxInputHint": "Input limit, if the provider sets one below the context window (empty: the context window). The chat is compacted before the input reaches it.",
|
||||
"settings.maxOutput": "Max output",
|
||||
"settings.reasoning": "Reasoning",
|
||||
"settings.vision": "Vision",
|
||||
|
||||
@@ -10,6 +10,43 @@ import { captureShare } from "./share";
|
||||
applyTheme();
|
||||
captureShare();
|
||||
registerSW({ immediate: true });
|
||||
undoKeyboardShift();
|
||||
|
||||
/**
|
||||
* Browsers that do not resize the page for the on-screen keyboard (iOS Safari, older
|
||||
* Android) scroll the whole page up to show the input – and do not always scroll it back
|
||||
* when the keyboard closes, leaving the top bar (menu) out of reach. The app never
|
||||
* scrolls the page itself (only its panes), so any page offset is undone once the
|
||||
* visible area grows again.
|
||||
*/
|
||||
function undoKeyboardShift() {
|
||||
const vv = window.visualViewport;
|
||||
if (!vv) return;
|
||||
let last = vv.height;
|
||||
const reset = () => {
|
||||
const el = document.scrollingElement ?? document.documentElement;
|
||||
if (window.scrollY !== 0 || el.scrollTop !== 0 || document.body.scrollTop !== 0) {
|
||||
window.scrollTo(0, 0);
|
||||
el.scrollTop = 0;
|
||||
document.body.scrollTop = 0;
|
||||
}
|
||||
};
|
||||
vv.addEventListener("resize", () => {
|
||||
// Grown back (keyboard closed or closing): after the browser's own adjustment.
|
||||
if (vv.height > last + 1) {
|
||||
requestAnimationFrame(reset);
|
||||
setTimeout(reset, 350);
|
||||
}
|
||||
last = vv.height;
|
||||
});
|
||||
// Keyboard closed by leaving the input (no resize on some browsers).
|
||||
document.addEventListener("focusout", () =>
|
||||
setTimeout(() => {
|
||||
const a = document.activeElement;
|
||||
if (!a || !(a instanceof HTMLInputElement || a instanceof HTMLTextAreaElement || (a as HTMLElement).isContentEditable)) reset();
|
||||
}, 300),
|
||||
);
|
||||
}
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
|
||||
@@ -527,6 +527,20 @@ button.session-group:hover {
|
||||
word-break: break-word;
|
||||
position: relative;
|
||||
}
|
||||
/* User messages are Markdown too; the line breaks come from keepLineBreaks. */
|
||||
.msg-user .markdown {
|
||||
white-space: normal;
|
||||
}
|
||||
.composer-preview {
|
||||
min-height: 44px;
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px dashed var(--bg3);
|
||||
background: var(--bg1);
|
||||
cursor: text;
|
||||
}
|
||||
.msg-user .msg-actions {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
@@ -1054,6 +1068,13 @@ button.session-group:hover {
|
||||
.form label {
|
||||
color: var(--fg2);
|
||||
}
|
||||
.form label.check {
|
||||
grid-column: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--fg1);
|
||||
}
|
||||
.form .hint {
|
||||
grid-column: 2;
|
||||
font-size: 12px;
|
||||
@@ -3150,6 +3171,28 @@ button.danger-solid {
|
||||
.refine-card summary .icon {
|
||||
color: var(--purple);
|
||||
}
|
||||
.harness-note {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
margin: 6px 0;
|
||||
padding: 6px 10px;
|
||||
border-left: 3px solid var(--blue);
|
||||
border-radius: 4px;
|
||||
background: var(--bg1);
|
||||
font-size: 12.5px;
|
||||
color: var(--fg2);
|
||||
}
|
||||
.harness-note .icon {
|
||||
color: var(--blue);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.harness-note.ok {
|
||||
border-left-color: var(--green);
|
||||
}
|
||||
.harness-note.ok .icon {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
/* --- automations ---------------------------------------------------------------- */
|
||||
.job.off {
|
||||
|
||||
+2
-1
@@ -141,6 +141,7 @@ export interface ModelInfo {
|
||||
name: string;
|
||||
context?: number;
|
||||
max_output?: number | null;
|
||||
max_input?: number | null;
|
||||
reasoning: boolean;
|
||||
vision: boolean;
|
||||
effort?: string | null;
|
||||
@@ -166,7 +167,7 @@ export interface AgentInfo {
|
||||
tools?: string[] | null;
|
||||
source: "builtin" | "config" | "file" | "personal";
|
||||
max_parallel?: number | null;
|
||||
refine?: { rounds: number; model?: string | null; criteria?: string | null } | null;
|
||||
refine?: { rounds: number; model?: string | null; criteria?: string | null; after_todos?: boolean } | null;
|
||||
/** Source file of Markdown agents. */
|
||||
path?: string | null;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,24 @@
|
||||
* under "unreleased"; scripts/release.sh renames the key to the release version.
|
||||
*/
|
||||
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
|
||||
"202611-00018": {
|
||||
en: [
|
||||
"Modes with a review: every completed todo is reviewed, and the agent keeps working until the todo list is done – for overnight runs",
|
||||
"Long chats are compacted in time instead of failing with “input too long”",
|
||||
],
|
||||
de: [
|
||||
"Modi mit Review: jedes erledigte Todo wird geprüft, und der Agent arbeitet weiter, bis die Liste abgehakt ist – für Läufe über Nacht",
|
||||
"Lange Chats werden rechtzeitig zusammengefasst, statt mit „Eingabe zu lang“ abzubrechen",
|
||||
],
|
||||
},
|
||||
"202611-00017": {
|
||||
en: ["Your messages are shown as Markdown; the eye button next to the text field previews it before sending"],
|
||||
de: ["Deine Nachrichten werden als Markdown dargestellt; das Augen-Symbol neben dem Textfeld zeigt vor dem Senden eine Vorschau"],
|
||||
},
|
||||
"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",
|
||||
|
||||
Reference in new issue
Block a user