mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-27 16:02:43 +00:00
* feat(chat-sdk): chat through a raw app's backend runnable Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(chat-sdk): finish a lost stream and wait out a stopped turn in streamChatTurn Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(chat-sdk): show the answer when a stream is lost before a tool call Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(chat-sdk): send attachments through the raw app runnable Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(chat-sdk): point sandboxed attachments at the runnable section Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: agent runnables for raw apps Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: build agent runnables without the parquet feature, cache the agent check query Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: address review nits on the agent picker and the CLI schema doc Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: run an agent runnable once when it is given no memory_id Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: accept previous_messages on agent runs, read-checking the files it quotes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: refuse an anonymous agent turn while one with the same memory still runs Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: show the agent's settings in the raw-app runnable panel, size its button like the row Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: offer an agent runnable memory_id or previous_messages after the agent's memory Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: take previous_messages in a memory-off agent's run form, type agent attachments as s3object Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: file an anonymous agent turn as a conversation, so its memory is collected and its turns serialized Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: own anonymous agent conversations under a fixed name that fits created_by Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: own anonymous agent conversations as anonymous and keep them out of conversation lists Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: point file_chat_turn at the anonymous conversation owner Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: title the runnable picker after what it lists, in sentence case Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: start a chat with local history listing its stored conversations Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: chat through a raw app's agent runnable with { wmill, runnable } Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: key an agent runnable chat's local history by its app as well Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: address review findings on agent runnables Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: offer memory_id to an agent runnable over an older auto memory Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: drop inherited static inputs from an agent runnable, show the memory-off note only when it is off Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
170 lines
6.6 KiB
Rust
170 lines
6.6 KiB
Rust
/*
|
|
* Author: Ruben Fiszel
|
|
* Copyright: Windmill Labs, Inc 2022
|
|
* This file and its contents are licensed under the AGPLv3 License.
|
|
* Please see the included NOTICE for copyright information and
|
|
* LICENSE-AGPL for a copy of the license.
|
|
*/
|
|
|
|
use std::sync::atomic::AtomicBool;
|
|
|
|
use serde_json::{from_value, Value};
|
|
|
|
use crate::{error, scripts::ScriptLang};
|
|
|
|
pub use windmill_types::apps::*;
|
|
|
|
lazy_static::lazy_static! {
|
|
pub static ref APP_WORKSPACED_ROUTE: AtomicBool = AtomicBool::new(false);
|
|
}
|
|
|
|
/// Whether the app value carries an `inlineScript` anywhere.
|
|
///
|
|
/// Deliberately not built on [`traverse_app_inline_scripts`], which only reports a script whose
|
|
/// `language` parses and stops descending as soon as it sees an `inlineScript` key. That is right
|
|
/// for locking (nothing to lock without a language) and wrong for an authorization check, where
|
|
/// the author picks the fields: this refuses the key itself, whatever it contains.
|
|
pub fn app_value_has_inline_script(value: &Value) -> bool {
|
|
match value {
|
|
Value::Object(object) => {
|
|
object.contains_key("inlineScript") || object.values().any(app_value_has_inline_script)
|
|
}
|
|
Value::Array(array) => array.iter().any(app_value_has_inline_script),
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
/// What a path runnable of an app runs.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
|
pub enum AppRunnableKind {
|
|
Script,
|
|
Flow,
|
|
/// A saved AI agent (an `ai_agent` resource), run as one chat turn.
|
|
Agent,
|
|
}
|
|
|
|
/// Every workspace runnable the app value points a component at, as `(kind, path)`.
|
|
///
|
|
/// This is what the deployed bundle actually asks `execute_component` to run: it resolves a
|
|
/// `runnable_id` against the stored `runnables` and sends `{runType}/{path}`. The policy's
|
|
/// triggerables are a separate surface, so both have to be authorized. Anything not plain text
|
|
/// is refused, never skipped: the editor compares `type` loosely (`["path"] == "path"`) and
|
|
/// interpolates `path` (`["u/x"]` → `u/x`), so a skipped entry still runs.
|
|
pub fn app_value_runnable_paths(value: &Value) -> error::Result<Vec<(AppRunnableKind, String)>> {
|
|
let bad = |msg: String| error::Error::BadRequest(msg);
|
|
let runnables = match value.get("runnables") {
|
|
None | Some(Value::Null) => return Ok(vec![]),
|
|
Some(Value::Object(runnables)) => runnables,
|
|
Some(_) => return Err(bad("App runnables must be an object".to_string())),
|
|
};
|
|
let mut out = Vec::new();
|
|
for (id, runnable) in runnables {
|
|
let Value::Object(runnable) = runnable else {
|
|
return Err(bad(format!("Runnable {id} must be an object")));
|
|
};
|
|
match runnable.get("type") {
|
|
Some(Value::String(t)) if t == "runnableByPath" || t == "path" => {}
|
|
None | Some(Value::String(_)) => continue,
|
|
Some(_) => return Err(bad(format!("Runnable {id} has a non-text type"))),
|
|
}
|
|
let Some(Value::String(path)) = runnable.get("path") else {
|
|
return Err(bad(format!("Runnable {id} must name its path as text")));
|
|
};
|
|
let kind = match runnable.get("runType") {
|
|
Some(Value::String(t)) if t == "flow" => AppRunnableKind::Flow,
|
|
Some(Value::String(t)) if t == "script" || t == "hubscript" => AppRunnableKind::Script,
|
|
Some(Value::String(t)) if t == "agent" => AppRunnableKind::Agent,
|
|
_ => return Err(bad(format!("Unsupported runType for runnable {path}"))),
|
|
};
|
|
out.push((kind, path.clone()));
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
pub const RAW_APP_RUNNABLES: &str = "runnables";
|
|
|
|
/// The `runnables` map of a raw app, when `value` is it. An inline script's dependency-map node
|
|
/// id is its closest enclosing component `id`; raw app runnables carry none, so each is keyed by
|
|
/// its name in this map instead, or a relock could not tell them apart.
|
|
pub fn raw_app_runnables_map<'a>(
|
|
key: &str,
|
|
value: &'a Value,
|
|
container_id: Option<&String>,
|
|
) -> Option<&'a serde_json::Map<String, Value>> {
|
|
match value {
|
|
Value::Object(runnables) if container_id.is_none() && key == RAW_APP_RUNNABLES => {
|
|
Some(runnables)
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Traverse FlowValue while invoking provided by caller callback on leafs
|
|
// #[async_recursion::async_recursion(?Send)]
|
|
pub fn traverse_app_inline_scripts<
|
|
C: FnMut(AppInlineScript, Option<String>) -> error::Result<()>,
|
|
>(
|
|
value: &Value,
|
|
// Set to None.
|
|
container_id: Option<String>,
|
|
cb: &mut C,
|
|
) -> error::Result<()> {
|
|
match value {
|
|
Value::Object(object) => {
|
|
if let Some(Value::Object(script)) = object.get("inlineScript") {
|
|
let (language, lock, code) = (
|
|
script
|
|
.get("language")
|
|
.cloned()
|
|
.map(|v| from_value::<ScriptLang>(v).ok())
|
|
.flatten(),
|
|
script
|
|
.get("lock")
|
|
.and_then(Value::as_str)
|
|
.map(str::to_owned),
|
|
script
|
|
.get("content")
|
|
.and_then(Value::as_str)
|
|
.map(str::to_owned)
|
|
.ok_or(error::Error::internal_err(
|
|
"Missing `content` in inlineScript".to_string(),
|
|
))?,
|
|
);
|
|
if language.is_some() {
|
|
cb(
|
|
AppInlineScript { language, content: code.to_owned(), lock },
|
|
container_id.clone(),
|
|
)?;
|
|
}
|
|
} else {
|
|
for (key, value) in object {
|
|
if let Some(runnables) =
|
|
raw_app_runnables_map(key, value, container_id.as_ref())
|
|
{
|
|
for (id, runnable) in runnables {
|
|
traverse_app_inline_scripts(runnable, Some(id.clone()), cb)?;
|
|
}
|
|
continue;
|
|
}
|
|
traverse_app_inline_scripts(
|
|
value,
|
|
object
|
|
.get("id")
|
|
.and_then(Value::as_str)
|
|
.map(str::to_owned)
|
|
.or(container_id.clone()),
|
|
cb,
|
|
)?;
|
|
}
|
|
}
|
|
}
|
|
Value::Array(array) => {
|
|
for value in array {
|
|
traverse_app_inline_scripts(value, container_id.clone(), cb)?;
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
Ok(())
|
|
}
|