Release 0.3.0 #5
@@ -0,0 +1,66 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to Skald Circle are recorded here, newest first.
|
||||||
|
|
||||||
|
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versions
|
||||||
|
are the workspace `Cargo.toml` version — the one `ci/verify-version.sh` checks before a
|
||||||
|
release PR may merge — and a section is closed at the commit that bumps it.
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Several conversations per source: open extra chats with `+`, and the tab bar you left
|
||||||
|
open is restored at your next login, on any device.
|
||||||
|
- A background task now reports back into the chat that started it instead of only the
|
||||||
|
Inbox, and a chat shows the tasks still running under it.
|
||||||
|
- Skills reworked for the multi-user model: a shared tree plus a per-member one, with a
|
||||||
|
generated index injected into the agent's prompt.
|
||||||
|
- The agent is told what its sandbox can actually run, from a probe of its own container.
|
||||||
|
- Event triage can be tuned per person: a check interval that overrides the instance one,
|
||||||
|
and notification preferences read from `user-memory/notifications.md`.
|
||||||
|
- File viewer: syntax highlighting for code files and for code blocks in the chat, a
|
||||||
|
hover copy button on those blocks, and history browsing for a file under git.
|
||||||
|
- Project explorer: download a folder as a streaming ZIP.
|
||||||
|
- Collapsible icon-only sidebar on desktop.
|
||||||
|
- DeepInfra, as a declarative LLM provider.
|
||||||
|
- The project coordinator offers to keep a history of a project.
|
||||||
|
- An agent can ask which connectors it holds instead of guessing.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Runtime image `v4`: Debian 13 base, plus the shared libraries a headless Chromium needs.
|
||||||
|
- Unencrypted users are unlocked and their runtimes started at boot, so Telegram, cron and
|
||||||
|
the background agents work after a restart without anyone opening the web app first.
|
||||||
|
- PDFs render through pdf.js instead of an iframe.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- The server keeps running after you log out of the box; the install / update / uninstall
|
||||||
|
scripts were hardened alongside it.
|
||||||
|
- Skald survives a restart of the Docker daemon.
|
||||||
|
- A user database gets the owner schema re-applied when it is opened.
|
||||||
|
- An approval bypass applies to the tool it was granted for, not to its whole connector.
|
||||||
|
- Connectors: an admin can use the ones they implicitly hold, per-user ones appear in the
|
||||||
|
security-group picker, one whose process died is brought back, a global one's
|
||||||
|
dependencies are installed where they are needed, and the prompt's connector list is
|
||||||
|
rebuilt when the set changes.
|
||||||
|
- Telegram: pairing codes are no longer burned on the way out nor handed out unrecorded,
|
||||||
|
and `send_attachment` resolves paths in the user's own workspace.
|
||||||
|
- The notification home is stored in the owner's database instead of the registry, where
|
||||||
|
it silently dropped every batch it built.
|
||||||
|
- LLM calls send the provider's model id on the wire rather than the local alias, and
|
||||||
|
catalog capabilities resolve for reasoning-mode queries.
|
||||||
|
- `get_ast_outline` runs in the caller's workspace, gives a markdown heading a section
|
||||||
|
range instead of a single line, and shows a proper name and icon on its chat card.
|
||||||
|
- The re-login dialog no longer hijacks the login screen, the new-chat `+` menu is visible
|
||||||
|
and clickable, and the session-detail page stays live instead of freezing on a snapshot.
|
||||||
|
- A silently dead agent WebSocket is detected and redialled.
|
||||||
|
- A generated image lands in your own workspace instead of a server folder nobody could
|
||||||
|
reach, so the assistant can finally send it to you on Telegram, open it in the viewer,
|
||||||
|
or work on it with a command. It still shows inline in the web chat, its file is named
|
||||||
|
after the prompt, and it is now readable only by the person who asked for it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Releases up to and including `0.2.0` predate this file; `git log` is the record for them.
|
||||||
@@ -444,6 +444,14 @@ Create `agents/<id>/meta.json` and `agents/<id>/AGENT.md`. The agent is discover
|
|||||||
|
|
||||||
`docs/` is **not developer documentation** — it's written for the in-app LLM, not for a human reading the repo, and is mounted read-only into every user's container at `~/docs/` (see the Filesystem & containers section: `docs_host` on `UserFs`, `DOCS_DIR` in `container/mod.rs`). It explains the software's UX (plugins, and eventually agents/connectors/memory/roles/…) in plain terms, in English, so the assistant can help a non-technical user configure things instead of guessing. `docs/index.md` is the entry point (general index of feature pages); `docs/plugins/<plugin id>.md` covers each built-in plugin. The three `type: chat` agents (`assistant`, `kid`, `project-coordinator`) are told in their `AGENT.md` to read `docs/index.md` when a user asks how the software works. **Standing rule: every change that impacts the UX must update `docs/` in the same change** — a new/renamed feature page plus the `docs/index.md` index entry. It goes stale like any other doc, except users actually see this one.
|
`docs/` is **not developer documentation** — it's written for the in-app LLM, not for a human reading the repo, and is mounted read-only into every user's container at `~/docs/` (see the Filesystem & containers section: `docs_host` on `UserFs`, `DOCS_DIR` in `container/mod.rs`). It explains the software's UX (plugins, and eventually agents/connectors/memory/roles/…) in plain terms, in English, so the assistant can help a non-technical user configure things instead of guessing. `docs/index.md` is the entry point (general index of feature pages); `docs/plugins/<plugin id>.md` covers each built-in plugin. The three `type: chat` agents (`assistant`, `kid`, `project-coordinator`) are told in their `AGENT.md` to read `docs/index.md` when a user asks how the software works. **Standing rule: every change that impacts the UX must update `docs/` in the same change** — a new/renamed feature page plus the `docs/index.md` index entry. It goes stale like any other doc, except users actually see this one.
|
||||||
|
|
||||||
|
### The changelog
|
||||||
|
|
||||||
|
`CHANGELOG.md` (repo root) is the release history, and it carries the **twin standing rule**: every change a user or an operator would notice must add a bullet under `## [Unreleased]` **in the same change** — a feature, a behaviour change, a bug fix, a new config key, an image-tag bump. Same reason as `docs/`: written after the fact it is written from the diff, which is exactly the version nobody can use.
|
||||||
|
|
||||||
|
Format is [Keep a Changelog](https://keepachangelog.com): newest first, one `## [x.y.z] - YYYY-MM-DD` section per released version, bullets grouped under `Added` / `Changed` / `Fixed` / `Removed` / `Security`. The versions are the **workspace `Cargo.toml` version** — the same string `ci/verify-version.sh` gates a release PR on — so cutting a release is two edits in one commit: bump `version` in `Cargo.toml`, and rename `## [Unreleased]` to the version with today's date, leaving a fresh empty `Unreleased` above it. There are no git tags on this repo; the changelog *is* the record of what a given `v{version}` tarball contains.
|
||||||
|
|
||||||
|
Entries are written **for the person reading the release, not for the person who wrote the code**: say what changed for them, not which module moved — the commit message and the diff already hold that. Which is also the test for whether a bullet is owed at all: a refactor with no observable effect gets none, however large. Keep one bullet per user-visible thing, not one per commit, and fold a fix-on-top-of-an-unreleased-feature into that feature's bullet rather than listing a bug that never shipped. History before `0.2.0` is not covered — git is the record for it.
|
||||||
|
|
||||||
## Config
|
## Config
|
||||||
|
|
||||||
Copy `default.config.yaml` → `config.yml`. Never commit `config.yml` (contains API keys).
|
Copy `default.config.yaml` → `config.yml`. Never commit `config.yml` (contains API keys).
|
||||||
|
|||||||
@@ -9,12 +9,10 @@
|
|||||||
///
|
///
|
||||||
/// `get(id)` resolves by explicit id across both plugin and DB-backed providers.
|
/// `get(id)` resolves by explicit id across both plugin and DB-backed providers.
|
||||||
/// When called without an id, plugin providers take precedence over DB-backed ones.
|
/// When called without an id, plugin providers take precedence over DB-backed ones.
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use anyhow::{Context, Result, anyhow};
|
use anyhow::{Result, anyhow};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use rand::RngExt;
|
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
@@ -50,14 +48,12 @@ pub struct ImageGeneratorManager {
|
|||||||
pool: Arc<SqlitePool>,
|
pool: Arc<SqlitePool>,
|
||||||
registry: Arc<ProviderRegistry>,
|
registry: Arc<ProviderRegistry>,
|
||||||
state: RwLock<ManagerState>,
|
state: RwLock<ManagerState>,
|
||||||
data_root: PathBuf,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ImageGeneratorManager {
|
impl ImageGeneratorManager {
|
||||||
pub async fn new(
|
pub async fn new(
|
||||||
pool: Arc<SqlitePool>,
|
pool: Arc<SqlitePool>,
|
||||||
registry: Arc<ProviderRegistry>,
|
registry: Arc<ProviderRegistry>,
|
||||||
data_root: impl Into<PathBuf>,
|
|
||||||
) -> Result<Arc<Self>> {
|
) -> Result<Arc<Self>> {
|
||||||
let mgr = Arc::new(Self {
|
let mgr = Arc::new(Self {
|
||||||
pool,
|
pool,
|
||||||
@@ -66,7 +62,6 @@ impl ImageGeneratorManager {
|
|||||||
db_slots: Vec::new(),
|
db_slots: Vec::new(),
|
||||||
plugins: Vec::new(),
|
plugins: Vec::new(),
|
||||||
}),
|
}),
|
||||||
data_root: data_root.into(),
|
|
||||||
});
|
});
|
||||||
mgr.reload().await?;
|
mgr.reload().await?;
|
||||||
Ok(mgr)
|
Ok(mgr)
|
||||||
@@ -192,32 +187,30 @@ impl ImageGeneratorManager {
|
|||||||
|
|
||||||
// ── Generation ────────────────────────────────────────────────────────────
|
// ── Generation ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
pub async fn generate(
|
/// Renders `prompt` with `provider_id` and hands the raw bytes back.
|
||||||
|
///
|
||||||
|
/// **Placement is the caller's**, deliberately. This used to write the file
|
||||||
|
/// into the server's own `data/images/` and return that host path to
|
||||||
|
/// the model — a path in nobody's vocabulary: it is not the caller's home,
|
||||||
|
/// not their container, and every consumer downstream resolves agent paths
|
||||||
|
/// (§6). Telegram's `send_attachment` therefore looked for
|
||||||
|
/// `data/images/x.png` under the user's home and answered "file not found",
|
||||||
|
/// and `read_file`/`execute_cmd`/the viewer could not reach it either. The
|
||||||
|
/// manager has no `UserFs` and no session, so the one place that does — the
|
||||||
|
/// tool, through its `ToolContext` — owns where the image lands.
|
||||||
|
pub async fn generate_bytes(
|
||||||
&self,
|
&self,
|
||||||
provider_id: &str,
|
provider_id: &str,
|
||||||
prompt: &str,
|
prompt: &str,
|
||||||
extra_params: Option<&serde_json::Value>,
|
extra_params: Option<&serde_json::Value>,
|
||||||
) -> Result<(PathBuf, String)> {
|
) -> Result<Vec<u8>> {
|
||||||
let provider = self.get(provider_id).await
|
let provider = self.get(provider_id).await
|
||||||
.ok_or_else(|| anyhow!("image provider '{}' not found", provider_id))?;
|
.ok_or_else(|| anyhow!("image provider '{}' not found", provider_id))?;
|
||||||
|
|
||||||
let images_dir = self.data_root.join("images");
|
|
||||||
tokio::fs::create_dir_all(&images_dir).await?;
|
|
||||||
|
|
||||||
let bytes = provider.generate(prompt, extra_params).await?;
|
let bytes = provider.generate(prompt, extra_params).await?;
|
||||||
|
info!(provider_id, bytes = bytes.len(), "image generated");
|
||||||
|
|
||||||
let file_id: String = rand::rng()
|
Ok(bytes)
|
||||||
.sample_iter(rand::distr::Alphanumeric)
|
|
||||||
.take(32)
|
|
||||||
.map(char::from)
|
|
||||||
.collect();
|
|
||||||
let path = images_dir.join(format!("{file_id}.png"));
|
|
||||||
tokio::fs::write(&path, &bytes).await?;
|
|
||||||
|
|
||||||
let url = format!("/api/images/{file_id}");
|
|
||||||
info!(provider_id, path = %path.display(), "image generated");
|
|
||||||
|
|
||||||
Ok((path, url))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Tool injection ─────────────────────────────────────────────────────────
|
// ── Tool injection ─────────────────────────────────────────────────────────
|
||||||
@@ -236,10 +229,6 @@ impl ImageGeneratorManager {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn images_dir(&self) -> PathBuf {
|
|
||||||
self.data_root.join("images")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Private ───────────────────────────────────────────────────────────────
|
// ── Private ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async fn reload(&self) -> Result<()> {
|
async fn reload(&self) -> Result<()> {
|
||||||
|
|||||||
@@ -108,7 +108,6 @@ impl Media {
|
|||||||
let image_generator_manager = ImageGeneratorManager::new(
|
let image_generator_manager = ImageGeneratorManager::new(
|
||||||
Arc::clone(&rt.db),
|
Arc::clone(&rt.db),
|
||||||
Arc::clone(&models.provider_registry),
|
Arc::clone(&models.provider_registry),
|
||||||
"data",
|
|
||||||
).await?;
|
).await?;
|
||||||
// Evaluate the await outside the `info!` macro: leaving the temporary
|
// Evaluate the await outside the `info!` macro: leaving the temporary
|
||||||
// `tracing::Value` from the field expression alive across the await
|
// `tracing::Value` from the field expression alive across the await
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ use anyhow::Result;
|
|||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
use crate::image_generate::ImageGeneratorManager;
|
use crate::image_generate::ImageGeneratorManager;
|
||||||
use crate::tools::{Tool, ToolCategory, ToolDescriptionLength, truncate_label, MAX_LABEL_SHORT, MAX_LABEL_FULL};
|
use crate::tools::{
|
||||||
|
SimpleExecution, Tool, ToolCategory, ToolContext, ToolDescriptionLength, ToolExecution,
|
||||||
|
ToolResult, truncate_label, MAX_LABEL_SHORT, MAX_LABEL_FULL,
|
||||||
|
};
|
||||||
|
|
||||||
// ── image_generate_providers_list ─────────────────────────────────────────────
|
// ── image_generate_providers_list ─────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -52,8 +55,12 @@ impl Tool for ImageGenerateTool {
|
|||||||
fn category(&self) -> ToolCategory { ToolCategory::Config }
|
fn category(&self) -> ToolCategory { ToolCategory::Config }
|
||||||
|
|
||||||
fn description(&self) -> &str {
|
fn description(&self) -> &str {
|
||||||
"Generate an image from a text prompt. \
|
"Generate an image from a text prompt. Blocks until the image is ready, then saves \
|
||||||
Blocks until the image is ready, then returns the local path and a web URL."
|
it into your own workspace and returns `{path, url}`. `path` (under `uploads/…`, \
|
||||||
|
relative to your home) is in your usual vocabulary — pass it to show_file_to_user, \
|
||||||
|
send_attachment, read_file or execute_cmd. `url` renders the image inline in the \
|
||||||
|
web and mobile chat if you embed it as a Markdown image, ; it is a web \
|
||||||
|
link, so on a channel without Markdown (Telegram) send the file itself instead."
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parameters_schema(&self) -> Value {
|
fn parameters_schema(&self) -> Value {
|
||||||
@@ -87,9 +94,27 @@ impl Tool for ImageGenerateTool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn execute_async<'a>(&'a self, args: Value) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<String>> + Send + 'a>> {
|
/// The generated image is written into the **caller's** workspace, so the path
|
||||||
let mgr = Arc::clone(&self.mgr);
|
/// this returns is the one agent vocabulary every consumer already speaks:
|
||||||
Box::pin(async move {
|
/// `send_attachment` (Telegram), `show_file_to_user` (web/mobile), the fs-tools
|
||||||
|
/// and `execute_cmd` all resolve it through the same [`UserFs`]. It previously
|
||||||
|
/// returned a path under the server's own data root, which none of them could
|
||||||
|
/// reach — the model got a file it could not hand to anyone.
|
||||||
|
///
|
||||||
|
/// `uploads/{session}/` rather than a directory of its own: that is the single
|
||||||
|
/// placement seam (`uploads::save_to_home`, collision-safe naming included) and
|
||||||
|
/// the one directory the media inliner is authorized to read from, so a vision
|
||||||
|
/// model can be shown the image it just made.
|
||||||
|
///
|
||||||
|
/// A `url` rides alongside the path because the chat renders Markdown images —
|
||||||
|
/// so `` in the answer shows the picture inline instead of naming a file
|
||||||
|
/// the user then has to open.
|
||||||
|
fn run_with<'a>(&'a self, ctx: &ToolContext, args: Value) -> Box<dyn ToolExecution + 'a> {
|
||||||
|
let mgr = Arc::clone(&self.mgr);
|
||||||
|
let fs = Arc::clone(&ctx.fs);
|
||||||
|
let session_id = ctx.session_id;
|
||||||
|
|
||||||
|
Box::new(SimpleExecution::new(Box::pin(async move {
|
||||||
let provider_id = args["provider_id"].as_str()
|
let provider_id = args["provider_id"].as_str()
|
||||||
.ok_or_else(|| anyhow::anyhow!("missing provider_id"))?
|
.ok_or_else(|| anyhow::anyhow!("missing provider_id"))?
|
||||||
.to_string();
|
.to_string();
|
||||||
@@ -100,8 +125,129 @@ impl Tool for ImageGenerateTool {
|
|||||||
Value::Object(_) => Some(args["extra_params"].clone()),
|
Value::Object(_) => Some(args["extra_params"].clone()),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
let (path, url) = mgr.generate(&provider_id, &prompt, extra_params.as_ref()).await?;
|
|
||||||
Ok(json!({ "path": path, "url": url }).to_string())
|
let bytes = mgr.generate_bytes(&provider_id, &prompt, extra_params.as_ref()).await?;
|
||||||
|
|
||||||
|
// The extension is sniffed, never assumed: providers return png, jpeg or
|
||||||
|
// webp, and it is the extension that decides whether Telegram sends the
|
||||||
|
// file inline as a photo or as a nondescript document.
|
||||||
|
let mime = crate::session::handler::media::sniff_mime(&bytes[..bytes.len().min(16)])
|
||||||
|
.unwrap_or("image/png");
|
||||||
|
let name = file_name_for(&prompt, mime);
|
||||||
|
|
||||||
|
let att = crate::uploads::save_to_home(
|
||||||
|
&fs, session_id, &name, Some(mime.to_string()), &bytes,
|
||||||
|
).await?;
|
||||||
|
|
||||||
|
let url = file_url(&att.path);
|
||||||
|
Ok(ToolResult::Text(json!({ "path": att.path, "url": url }).to_string()))
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generation needs the caller's workspace to put the image in, and only
|
||||||
|
/// [`run_with`](Self::run_with) is handed one. Same shape as `execute_cmd`:
|
||||||
|
/// the context-free path fails loudly rather than writing somewhere nobody
|
||||||
|
/// can read.
|
||||||
|
fn execute_async<'a>(&'a self, _args: Value) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<String>> + Send + 'a>> {
|
||||||
|
Box::pin(async move {
|
||||||
|
Err(anyhow::anyhow!(
|
||||||
|
"image_generate needs a session context to save the image into your workspace"
|
||||||
|
))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Web URL for a file in the caller's workspace: the per-user file endpoint, the
|
||||||
|
/// same one the viewer and the explorer read through.
|
||||||
|
///
|
||||||
|
/// Deliberately an **ownership-scoped** endpoint. Generated images used to be
|
||||||
|
/// served from one instance-wide directory by id, behind `require_auth` alone —
|
||||||
|
/// authenticated but with no notion of who owned the image, the same shape as the
|
||||||
|
/// `/data` static mount that was removed for exactly that. `/api/file` resolves
|
||||||
|
/// the path through the caller's own `UserFs`, so a link that leaks reveals
|
||||||
|
/// nothing to anyone not already entitled to it. (That id-based route is gone: it
|
||||||
|
/// had no writer left once placement moved into the workspace.)
|
||||||
|
fn file_url(agent_path: &str) -> String {
|
||||||
|
// Same idiom as `mcp::oauth`: a throwaway URL's query is a correctly
|
||||||
|
// percent-encoded `path=…`, without hand-rolling an encoder.
|
||||||
|
let query = reqwest::Url::parse_with_params("http://local/", &[("path", agent_path)])
|
||||||
|
.ok()
|
||||||
|
.and_then(|u| u.query().map(str::to_owned))
|
||||||
|
.unwrap_or_else(|| format!("path={agent_path}"));
|
||||||
|
|
||||||
|
format!("/api/file?{query}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a file name from the prompt, so the user sees `a-red-bicycle.png` in
|
||||||
|
/// their files and in Telegram rather than a random id. Collisions are the upload
|
||||||
|
/// seam's problem (it appends `_1`, `_2`, …), so this need not be unique.
|
||||||
|
fn file_name_for(prompt: &str, mime: &str) -> String {
|
||||||
|
let ext = match mime {
|
||||||
|
"image/jpeg" => "jpg",
|
||||||
|
"image/webp" => "webp",
|
||||||
|
"image/gif" => "gif",
|
||||||
|
_ => "png",
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut slug = String::new();
|
||||||
|
for ch in prompt.chars() {
|
||||||
|
if slug.len() >= 48 { break; }
|
||||||
|
if ch.is_ascii_alphanumeric() {
|
||||||
|
slug.extend(ch.to_lowercase());
|
||||||
|
} else if !slug.ends_with('-') {
|
||||||
|
slug.push('-');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let slug = slug.trim_matches('-');
|
||||||
|
let stem = if slug.is_empty() { "image" } else { slug };
|
||||||
|
|
||||||
|
format!("{stem}.{ext}")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn url_points_at_the_per_user_file_endpoint_and_encodes_the_path() {
|
||||||
|
assert_eq!(
|
||||||
|
file_url("uploads/7/a-red-bicycle.png"),
|
||||||
|
"/api/file?path=uploads%2F7%2Fa-red-bicycle.png",
|
||||||
|
);
|
||||||
|
// A name the slug rules cannot produce, but that the endpoint must still
|
||||||
|
// receive intact rather than as two query params.
|
||||||
|
assert_eq!(
|
||||||
|
file_url("uploads/7/a&b c.png"),
|
||||||
|
"/api/file?path=uploads%2F7%2Fa%26b+c.png",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn file_name_slugs_the_prompt_and_keys_the_extension_on_the_mime() {
|
||||||
|
assert_eq!(file_name_for("A red bicycle", "image/png"), "a-red-bicycle.png");
|
||||||
|
assert_eq!(file_name_for("A red bicycle", "image/jpeg"), "a-red-bicycle.jpg");
|
||||||
|
assert_eq!(file_name_for("A red bicycle", "image/webp"), "a-red-bicycle.webp");
|
||||||
|
// An unrecognized type still produces a usable image name.
|
||||||
|
assert_eq!(file_name_for("A red bicycle", "application/octet-stream"), "a-red-bicycle.png");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn file_name_never_carries_path_or_shell_syntax_out_of_the_prompt() {
|
||||||
|
// Everything non-alphanumeric collapses to a single dash, so a prompt can
|
||||||
|
// neither escape the uploads directory nor smuggle syntax into a later
|
||||||
|
// `execute_cmd` on the returned path.
|
||||||
|
assert_eq!(file_name_for("../../etc/passwd", "image/png"), "etc-passwd.png");
|
||||||
|
assert_eq!(file_name_for("a $(whoami) cat", "image/png"), "a-whoami-cat.png");
|
||||||
|
// A prompt with nothing usable still yields a name.
|
||||||
|
assert_eq!(file_name_for("...", "image/png"), "image.png");
|
||||||
|
assert_eq!(file_name_for("", "image/png"), "image.png");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn file_name_stays_short_for_a_long_prompt() {
|
||||||
|
let name = file_name_for(&"word ".repeat(60), "image/png");
|
||||||
|
assert!(name.len() <= 53, "{name}");
|
||||||
|
assert!(name.ends_with(".png"));
|
||||||
|
assert!(!name.starts_with('-') && !name.contains("-."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,4 +28,5 @@ The plugin polls the ComfyUI server every 5 seconds. If it's offline, every mode
|
|||||||
|
|
||||||
- A single generation can take up to 5 minutes before the plugin times out and reports an error.
|
- A single generation can take up to 5 minutes before the plugin times out and reports an error.
|
||||||
- Image-to-image is supported when the workflow declares an `input_image_node` in `_personal_agent`.
|
- Image-to-image is supported when the workflow declares an `input_image_node` in `_personal_agent`.
|
||||||
|
- A generated image is saved into the requesting user's own workspace, under `uploads/<conversation id>/`, and the tool returns both a path and a web URL. In the web or mobile chat, embedding the URL as a Markdown image shows the picture directly in the conversation; the path is for everything else — `show_file_to_user`, `send_attachment` on Telegram (which has no Markdown, so the file must be sent, not linked), and ordinary file work like reading it, moving it into a project or a shared folder, or processing it with a command.
|
||||||
- If ComfyUI is unreachable, tell the user to start their ComfyUI server — there is nothing to fix in this app's configuration in that case.
|
- If ComfyUI is unreachable, tell the user to start their ComfyUI server — there is nothing to fix in this app's configuration in that case.
|
||||||
|
|||||||
@@ -34,3 +34,4 @@ That's the whole flow — no admin involvement needed for a normal pairing. (An
|
|||||||
|
|
||||||
- Output sent to Telegram is automatically constrained to Telegram-safe HTML formatting (bold, italic, code blocks, links, quotes) — no Markdown, no tables. This is handled automatically; nothing to configure.
|
- Output sent to Telegram is automatically constrained to Telegram-safe HTML formatting (bold, italic, code blocks, links, quotes) — no Markdown, no tables. This is handled automatically; nothing to configure.
|
||||||
- Revoking a user's plugin access immediately stops that person's Telegram chat from working, without needing them to re-pair if access is restored later.
|
- Revoking a user's plugin access immediately stops that person's Telegram chat from working, without needing them to re-pair if access is restored later.
|
||||||
|
- You can send a file from your workspace into the Telegram chat with `send_attachment`, giving it a path in your usual vocabulary (`~/report.pdf`, `uploads/…`, `shared/…`, or an absolute path inside your sandbox). Images and videos arrive inline, anything else as a downloadable file. This is the Telegram equivalent of `show_file_to_user`, which only exists in the web and mobile apps — on Telegram a generated image or a document you produced has to be sent this way, or the user never sees it.
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
use axum::{
|
|
||||||
extract::{Path, State},
|
|
||||||
http::{HeaderValue, StatusCode, header},
|
|
||||||
response::{IntoResponse, Response},
|
|
||||||
};
|
|
||||||
use tokio::fs;
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
|
||||||
use skald_core::skald::Skald;
|
|
||||||
|
|
||||||
/// GET /api/images/:task_id
|
|
||||||
///
|
|
||||||
/// Serves a generated image from `data/images/<task_id>.png`.
|
|
||||||
pub async fn get_image(
|
|
||||||
State(skald): State<Arc<Skald>>,
|
|
||||||
Path(task_id): Path<String>,
|
|
||||||
) -> Response {
|
|
||||||
// Reject any path traversal attempts.
|
|
||||||
if task_id.contains('/') || task_id.contains('\\') || task_id.contains("..") {
|
|
||||||
return StatusCode::BAD_REQUEST.into_response();
|
|
||||||
}
|
|
||||||
|
|
||||||
let task_id = task_id.trim_end_matches(".png");
|
|
||||||
let path = skald.image_generator_manager().images_dir().join(format!("{task_id}.png"));
|
|
||||||
|
|
||||||
match fs::read(&path).await {
|
|
||||||
Ok(bytes) => {
|
|
||||||
let mut response = bytes.into_response();
|
|
||||||
response.headers_mut().insert(
|
|
||||||
header::CONTENT_TYPE,
|
|
||||||
HeaderValue::from_static("image/png"),
|
|
||||||
);
|
|
||||||
response
|
|
||||||
}
|
|
||||||
Err(_) => StatusCode::NOT_FOUND.into_response(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,7 +11,6 @@ pub mod guard;
|
|||||||
pub mod stats;
|
pub mod stats;
|
||||||
pub mod files;
|
pub mod files;
|
||||||
pub mod image_generate_models;
|
pub mod image_generate_models;
|
||||||
pub mod images;
|
|
||||||
pub mod inbox;
|
pub mod inbox;
|
||||||
pub mod llm;
|
pub mod llm;
|
||||||
pub mod marketplace;
|
pub mod marketplace;
|
||||||
@@ -229,8 +228,6 @@ pub fn router() -> Router<Arc<Skald>> {
|
|||||||
.route("/shared-folders/{id}", patch(shared_folders::update_description).delete(shared_folders::delete))
|
.route("/shared-folders/{id}", patch(shared_folders::update_description).delete(shared_folders::delete))
|
||||||
.route("/shared-folders/{id}/members", post(shared_folders::add_member))
|
.route("/shared-folders/{id}/members", post(shared_folders::add_member))
|
||||||
.route("/shared-folders/{id}/members/{user_id}", delete(shared_folders::remove_member))
|
.route("/shared-folders/{id}/members/{user_id}", delete(shared_folders::remove_member))
|
||||||
// Images (generated by image_generate tool)
|
|
||||||
.route("/images/{task_id}", get(images::get_image))
|
|
||||||
// MCP tool-result media (images/audio/files returned by MCP servers)
|
// MCP tool-result media (images/audio/files returned by MCP servers)
|
||||||
.route("/mcp-media/{file}", get(mcp_media::get_media))
|
.route("/mcp-media/{file}", get(mcp_media::get_media))
|
||||||
// Files
|
// Files
|
||||||
|
|||||||
Reference in New Issue
Block a user