Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca14ba3338 | ||
|
|
97e66d2118 | ||
|
|
b3a298fb7c | ||
|
|
61219dbca2 | ||
|
|
9fd92d5b39 | ||
|
|
b99f71526a |
No files matched your search
@@ -3,6 +3,36 @@
|
|||||||
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.
|
||||||
|
|
||||||
|
## [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
|
## [v202611-00014] – 2026-11-10
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img alt="Version v202611-00014" src="https://img.shields.io/badge/version-v202611--00014-d69e2e?style=flat-square">
|
<img alt="Version v202611-00017" src="https://img.shields.io/badge/version-v202611--00017-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">
|
||||||
|
|||||||
+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> {
|
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 img = own_image(&app, &user, &id)?;
|
||||||
let bytes = tokio::fs::read(&img.path).await.map_err(|_| not_found("image file"))?;
|
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() {
|
||||||
let ctype = match std::path::Path::new(&img.path).extension().and_then(|e| e.to_str()) {
|
|
||||||
Some("jpg") | Some("jpeg") => "image/jpeg",
|
Some("jpg") | Some("jpeg") => "image/jpeg",
|
||||||
Some("webp") => "image/webp",
|
Some("webp") => "image/webp",
|
||||||
_ => "image/png",
|
_ => "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 {
|
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"),
|
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:#}"),
|
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() {
|
match self.store.gc_blobs() {
|
||||||
Ok(0) => {}
|
Ok(0) => {}
|
||||||
Ok(n) => tracing::info!("removed {n} unused images"),
|
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))
|
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()
|
Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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());
|
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??;
|
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})))
|
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 name = if images.len() > 1 { format!("{stamp}-{base}-{}.{ext}", i + 1) } else { format!("{stamp}-{base}.{ext}") };
|
||||||
let path = req.dir.join(name);
|
let path = req.dir.join(name);
|
||||||
tokio::fs::write(&path, &bytes).await?;
|
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 {
|
let rec = ImageRecord {
|
||||||
id: new_id(),
|
id: new_id(),
|
||||||
user_id: req.user_id.clone(),
|
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),
|
width: img["width"].as_i64().unwrap_or(0),
|
||||||
height: img["height"].as_i64().unwrap_or(0),
|
height: img["height"].as_i64().unwrap_or(0),
|
||||||
created_at: now_ms(),
|
created_at: now_ms(),
|
||||||
|
blob,
|
||||||
};
|
};
|
||||||
app.store.add_image(&rec)?;
|
app.store.add_image(&rec)?;
|
||||||
records.push(rec);
|
records.push(rec);
|
||||||
@@ -252,6 +255,98 @@ pub async fn generate(app: &App, req: GenerateRequest) -> Result<Vec<ImageRecord
|
|||||||
Ok(records)
|
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.
|
/// Downscaled copy for the model's context.
|
||||||
fn preview_for_model(path: &Path) -> Option<ImageData> {
|
fn preview_for_model(path: &Path) -> Option<ImageData> {
|
||||||
let img = image::open(path).ok()?;
|
let img = image::open(path).ok()?;
|
||||||
@@ -363,6 +458,37 @@ impl Tool for GenerateImage {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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 {
|
fn m(id: &str) -> ImageModel {
|
||||||
with_defaults(ImageModel { id: id.into(), ..Default::default() })
|
with_defaults(ImageModel { id: id.into(), ..Default::default() })
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-1
@@ -563,7 +563,15 @@ async fn execute_call(
|
|||||||
// Pass depth through the agent name channel: subagents run with depth+1.
|
// Pass depth through the agent name channel: subagents run with depth+1.
|
||||||
run_task(app, &ctx, input, depth).await
|
run_task(app, &ctx, input, depth).await
|
||||||
} else {
|
} 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.
|
// Diagnostics: report new errors of the changed file right away.
|
||||||
let res = match res {
|
let res = match res {
|
||||||
@@ -609,6 +617,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> {
|
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 prompt = tools::arg_str(&input, "prompt")?.to_string();
|
||||||
let description = input["description"].as_str().unwrap_or("subagent").to_string();
|
let description = input["description"].as_str().unwrap_or("subagent").to_string();
|
||||||
@@ -1301,6 +1314,14 @@ async fn compact_inner(app: Arc<App>, session_id: &str, cancel: CancellationToke
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[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 {
|
fn call(id: &str) -> Part {
|
||||||
Part::ToolCall { id: id.into(), name: "bash".into(), input: serde_json::json!({}), extra: None }
|
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"),
|
("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"),
|
("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"),
|
("usage", "allowance", "ALTER TABLE usage ADD COLUMN allowance REAL"),
|
||||||
] {
|
] {
|
||||||
let exists: bool = conn
|
let exists: bool = conn
|
||||||
@@ -394,6 +395,10 @@ impl Store {
|
|||||||
}
|
}
|
||||||
drop(rows);
|
drop(rows);
|
||||||
drop(stmt);
|
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 all: Vec<String> = conn.prepare("SELECT hash FROM blobs")?.query_map([], |r| r.get(0))?.collect::<rusqlite::Result<_>>()?;
|
||||||
let mut removed = 0;
|
let mut removed = 0;
|
||||||
for h in all.iter().filter(|h| !used.contains(*h)) {
|
for h in all.iter().filter(|h| !used.contains(*h)) {
|
||||||
@@ -966,6 +971,10 @@ pub struct ImageRecord {
|
|||||||
pub width: i64,
|
pub width: i64,
|
||||||
pub height: i64,
|
pub height: i64,
|
||||||
pub created_at: 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> {
|
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)?,
|
width: r.get(6)?,
|
||||||
height: r.get(7)?,
|
height: r.get(7)?,
|
||||||
created_at: r.get(8)?,
|
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 {
|
impl Store {
|
||||||
pub fn add_image(&self, img: &ImageRecord) -> Result<()> {
|
pub fn add_image(&self, img: &ImageRecord) -> Result<()> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
&format!("INSERT INTO images ({IMAGE_COLS}) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)"),
|
&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],
|
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(())
|
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).
|
/// Latest images; `user = None` lists everybody's (admins).
|
||||||
pub fn images(&self, user: Option<&str>, limit: i64) -> Result<Vec<ImageRecord>> {
|
pub fn images(&self, user: Option<&str>, limit: i64) -> Result<Vec<ImageRecord>> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
|
|||||||
+3
-1
@@ -2,7 +2,9 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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" />
|
<meta name="theme-color" content="#282828" />
|
||||||
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
|||||||
@@ -202,6 +202,17 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
|||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
if (el && stick.current) el.scrollTop = el.scrollHeight;
|
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 (loadError) return <div className="empty">{loadError}</div>;
|
||||||
if (!session) return <div className="empty">{t("common.loading")}</div>;
|
if (!session) return <div className="empty">{t("common.loading")}</div>;
|
||||||
@@ -597,9 +608,10 @@ function UserText({ text }: { text: string }) {
|
|||||||
const last = rest.pop()!;
|
const last = rest.pop()!;
|
||||||
if (last.trim().endsWith(":")) break;
|
if (last.trim().endsWith(":")) break;
|
||||||
}
|
}
|
||||||
|
const body = rest.join("\n").trim();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{rest.join("\n").trimEnd()}
|
{body && <Markdown text={body} breaks />}
|
||||||
{files.length > 0 && (
|
{files.length > 0 && (
|
||||||
<div className="user-files">
|
<div className="user-files">
|
||||||
{files.map((f) => (
|
{files.map((f) => (
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type { ImageData, Session } from "../types";
|
|||||||
import { ModelSelect } from "./ModelSelect";
|
import { ModelSelect } from "./ModelSelect";
|
||||||
import { EffortSelect } from "./Regenerate";
|
import { EffortSelect } from "./Regenerate";
|
||||||
import { VoiceButton } from "./Voice";
|
import { VoiceButton } from "./Voice";
|
||||||
|
import { Markdown } from "./Markdown";
|
||||||
import { confirmDialog, promptDialog, toast } from "../dialogs";
|
import { confirmDialog, promptDialog, toast } from "../dialogs";
|
||||||
|
|
||||||
interface Suggestion {
|
interface Suggestion {
|
||||||
@@ -67,6 +68,8 @@ export function Composer({
|
|||||||
const [effort, setEffort] = useState("");
|
const [effort, setEffort] = useState("");
|
||||||
const [interim, setInterim] = useState("");
|
const [interim, setInterim] = useState("");
|
||||||
const [picker, setPicker] = useState(false);
|
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 models = useModels();
|
||||||
const [prov, ...mid] = session.model.split("/");
|
const [prov, ...mid] = session.model.split("/");
|
||||||
const reasoning = !!models.find((p) => p.provider === prov)?.models.find((m) => m.id === mid.join("/"))?.reasoning;
|
const reasoning = !!models.find((p) => p.provider === prov)?.models.find((m) => m.id === mid.join("/"))?.reasoning;
|
||||||
@@ -265,7 +268,20 @@ export function Composer({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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
|
<textarea
|
||||||
|
style={preview ? { display: "none" } : undefined}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
rows={1}
|
rows={1}
|
||||||
value={text}
|
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} />
|
<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")}>
|
<label className="ghost" style={{ cursor: "pointer", padding: "3px 6px" }} title={t("composer.attach")}>
|
||||||
<Icon name="clip" />
|
<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 (
|
return (
|
||||||
<div className="markdown">
|
<div className="markdown">
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
@@ -81,7 +104,7 @@ export const Markdown = memo(function Markdown({ text, baseDir }: { text: string
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{text}
|
{breaks ? keepLineBreaks(text) : text}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -158,11 +158,25 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r
|
|||||||
)}
|
)}
|
||||||
{Array.isArray(meta?.image_ids) ? (
|
{Array.isArray(meta?.image_ids) ? (
|
||||||
<div className="gallery small">
|
<div className="gallery small">
|
||||||
{meta.image_ids.map((id: string) => (
|
{meta.image_ids.map((id: string, i: number) => {
|
||||||
<FileFigure key={id} url={`/api/images/${id}/file`} className="gallery-item">
|
// The file may be gone (moved or deleted later): fall back to the preview the model saw.
|
||||||
<img src={`/api/images/${id}/file`} alt="" />
|
const preview = result?.images?.[i];
|
||||||
</FileFigure>
|
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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
result?.images?.map((img, i) => (
|
result?.images?.map((img, i) => (
|
||||||
|
|||||||
@@ -663,6 +663,9 @@ export const de: Messages = {
|
|||||||
"composer.mode": "Modus",
|
"composer.mode": "Modus",
|
||||||
"composer.model": "Modell",
|
"composer.model": "Modell",
|
||||||
"composer.permissionMode": "Berechtigungsmodus",
|
"composer.permissionMode": "Berechtigungsmodus",
|
||||||
|
"composer.preview": "Vorschau (Markdown)",
|
||||||
|
"composer.previewEdit": "Zurück zum Bearbeiten",
|
||||||
|
"composer.previewEmpty": "Noch nichts zum Anzeigen.",
|
||||||
"composer.attach": "Dateien anhängen",
|
"composer.attach": "Dateien anhängen",
|
||||||
|
|
||||||
"command.compact": "Unterhaltung zusammenfassen, um Kontext freizugeben",
|
"command.compact": "Unterhaltung zusammenfassen, um Kontext freizugeben",
|
||||||
|
|||||||
@@ -662,6 +662,9 @@ export const en = {
|
|||||||
"composer.mode": "Mode",
|
"composer.mode": "Mode",
|
||||||
"composer.model": "Model",
|
"composer.model": "Model",
|
||||||
"composer.permissionMode": "Permission mode",
|
"composer.permissionMode": "Permission mode",
|
||||||
|
"composer.preview": "Preview (Markdown)",
|
||||||
|
"composer.previewEdit": "Back to editing",
|
||||||
|
"composer.previewEmpty": "Nothing to preview yet.",
|
||||||
"composer.attach": "Attach files",
|
"composer.attach": "Attach files",
|
||||||
|
|
||||||
"command.compact": "Summarize the conversation to free up context",
|
"command.compact": "Summarize the conversation to free up context",
|
||||||
|
|||||||
@@ -10,6 +10,43 @@ import { captureShare } from "./share";
|
|||||||
applyTheme();
|
applyTheme();
|
||||||
captureShare();
|
captureShare();
|
||||||
registerSW({ immediate: true });
|
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(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
|||||||
@@ -527,6 +527,20 @@ button.session-group:hover {
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
position: relative;
|
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 {
|
.msg-user .msg-actions {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 4px;
|
top: 4px;
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
* 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[] }> = {
|
||||||
|
"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": {
|
"202611-00014": {
|
||||||
en: ["Folder picker (new automation, new chat): create a new folder right there"],
|
en: ["Folder picker (new automation, new chat): create a new folder right there"],
|
||||||
de: ["Ordnerauswahl (neue Automatisierung, neuer Chat): direkt einen neuen Ordner anlegen"],
|
de: ["Ordnerauswahl (neue Automatisierung, neuer Chat): direkt einen neuen Ordner anlegen"],
|
||||||
|
|||||||
Reference in new issue
Block a user