diff --git a/README.md b/README.md index 61e0e92..868d609 100644 --- a/README.md +++ b/README.md @@ -28,10 +28,44 @@ export TELLERS_API_BASE=https://api.tellers.ai ## Usage -### Chat Commands +### Chat / Prompt -- `tellers "prompt"` — displays a minimal chat TUI from a streamed response -- `tellers --full-auto --background "prompt"` — starts a chat and prints only the chat id +Run a prompt against the Tellers agent: + +- **`tellers "prompt"`** — Streams the response and starts a minimal REPL (reply in the terminal; Ctrl-C to exit). +- **`tellers --background "prompt"`** — Single request, no REPL; prints the response text (or last JSON result when using `--json-response`). +- **`tellers --full-auto --background "prompt"`** — Same as `--background` with full-auto behavior. + +**Prompt options:** + +| Flag | Description | +|------|-------------| +| `--no-interaction` | Single response only, no REPL. | +| `--json-response` | Use the JSON endpoint; output is the last `tellers.json_result` event (no interaction implied). | +| `--tool ` | Enable a tool (repeat for multiple). Omit to use default tools from settings. | +| `--llm-model ` | LLM model (e.g. `gpt-5.4-2026-03-05`). | +| `--interactive`, `-i` | Interactively set options: JSON response (y/N), no interaction (y/N), tool selection (checkbox list), and LLM model (list). | + +**Examples:** + +```bash +# Streamed chat with REPL +tellers "Generate a video, with cats" + +# Single response, no follow-up +tellers --no-interaction "Generate a video, with stock footage video of cats" + +# JSON endpoint: only the last JSON result is printed +tellers --json-response "Generate a video, with stock footage video of cats" + +# Choose model and tools via flags +tellers --llm-model gpt-5.4-2026-03-05 --tool tool_a --tool tool_b "Your prompt" + +# Interactive: prompts for JSON, no-interaction, checkbox list for tools, model picker +tellers -i "Generate a video, with stock footage video of cats" +``` + +**Interactive tool selection:** When you use `-i` and the settings include available tools, a TUI checkbox list is shown. Use **↑/↓** to move, **Space** to toggle, **a** to toggle all, **Enter** to confirm. Checkboxes are pre-set from each tool’s `enabled` field in the settings JSON (missing = enabled). ### Upload Command diff --git a/src/cli.rs b/src/cli.rs index 87585e1..cad45c3 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -12,6 +12,26 @@ pub struct Cli { #[arg(long)] pub background: bool, + /// Disable interaction with the agent (single response, no REPL). + #[arg(long)] + pub no_interaction: bool, + + /// Use JSON response endpoint (no_interaction implied; SSE tellers.json_result events). + #[arg(long)] + pub json_response: bool, + + /// Tool(s) to enable (can be repeated). Omit for default tools. + #[arg(long = "tool", value_name = "TOOL_ID")] + pub tools: Vec, + + /// LLM model to use (e.g. gpt-5.4-2026-03-05). + #[arg(long, value_name = "MODEL")] + pub llm_model: Option, + + /// Interactively set json_response, no_interaction, tools, and llm_model. + #[arg(short, long)] + pub interactive: bool, + #[arg()] pub prompt: Option, diff --git a/src/commands/prompt.rs b/src/commands/prompt.rs index 69940c4..7789057 100644 --- a/src/commands/prompt.rs +++ b/src/commands/prompt.rs @@ -3,23 +3,242 @@ use std::io::{self, Write}; use std::sync::mpsc; use std::thread; use tellers_api_client::apis::configuration::Configuration; -use tellers_api_client::models::AgentMessageRequest; +use tellers_api_client::models::agent_message_request::LlmModel; +use tellers_api_client::models::agent_message_request_without_no_interaction::LlmModel as LlmModelJson; +use tellers_api_client::models::{AgentMessageRequest, AgentMessageRequestWithoutNoInteraction}; use crate::commands::api_config; -pub fn run_interactive(prompt_text: String, _full_auto: bool) -> Result<(), String> { +#[derive(Clone, Default, Debug)] +pub struct PromptOptions { + pub no_interaction: bool, + pub json_response: bool, + pub tools: Option>, + pub llm_model: Option, +} + +impl PromptOptions { + pub fn from_cli( + no_interaction: bool, + json_response: bool, + tools: Vec, + llm_model: Option, + ) -> Self { + Self { + no_interaction, + json_response, + tools: if tools.is_empty() { None } else { Some(tools) }, + llm_model, + } + } + +} + +pub fn run_interactive_options( + base: &PromptOptions, +) -> Result { + let cfg = api_config::create_config(); + let api_key = api_config::get_api_key(None)?; + let bearer_header = api_config::get_bearer_header(None); + + let (models, tool_ids, tool_default_checked) = tokio::runtime::Runtime::new() + .map_err(|e| format!("failed to start runtime: {}", e))? + .block_on(async { + fetch_models_and_tool_ids(&cfg, &api_key, bearer_header.as_deref()).await + })?; + + let mut opts = base.clone(); + let mut stdout = io::stdout(); + let stdin = io::stdin(); + + print!("Use JSON response endpoint? [y/N]: "); + let _ = stdout.flush(); + let mut line = String::new(); + let _ = stdin.read_line(&mut line); + if line.trim().eq_ignore_ascii_case("y") || line.trim().eq_ignore_ascii_case("yes") { + opts.json_response = true; + } + + if !opts.json_response { + print!("No interaction (single response, no REPL)? [y/N]: "); + let _ = stdout.flush(); + line.clear(); + let _ = stdin.read_line(&mut line); + if line.trim().eq_ignore_ascii_case("y") || line.trim().eq_ignore_ascii_case("yes") { + opts.no_interaction = true; + } + } + + if !tool_ids.is_empty() { + match crate::tui::run_checkbox_list( + "Select tools (Space=toggle, Enter=confirm)", + tool_ids, + Some(tool_default_checked), + ) { + Ok(selected) => { + if !selected.is_empty() { + opts.tools = Some(selected); + } + } + Err(e) => return Err(e), + } + } + + if !models.is_empty() { + println!("Available LLM models:"); + for (i, m) in models.iter().enumerate() { + println!(" {}: {}", i + 1, m); + } + print!("Select model (number or Enter to use default): "); + let _ = stdout.flush(); + line.clear(); + let _ = stdin.read_line(&mut line); + let input = line.trim(); + if !input.is_empty() { + if let Ok(n) = input.parse::() { + if n >= 1 && n <= models.len() { + opts.llm_model = Some(models[n - 1].clone()); + } + } else { + opts.llm_model = Some(input.to_string()); + } + } + } + + Ok(opts) +} + +async fn fetch_models_and_tool_ids( + cfg: &Configuration, + api_key: &str, + bearer_opt: Option<&str>, +) -> Result<(Vec, Vec, Vec), String> { + let settings = fetch_settings(cfg, api_key, bearer_opt).await?; + let models: Vec = settings + .get("available_llm_models") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + let (tool_ids, default_checked): (Vec, Vec) = settings + .get("available_agent_tools") + .and_then(|v| v.as_array()) + .map(|arr| { + let mut ids = Vec::with_capacity(arr.len()); + let mut checked = Vec::with_capacity(arr.len()); + for o in arr.iter() { + let obj = o.as_object()?; + let id = obj + .get("id") + .or_else(|| obj.get("name")) + .and_then(|v| v.as_str()) + .map(String::from); + if let Some(id) = id { + let enabled = obj + .get("enabled") + .and_then(|v| v.as_bool()) + .unwrap_or(true); + ids.push(id); + checked.push(enabled); + } + } + Some((ids, checked)) + }) + .and_then(|x| x) + .unwrap_or_else(|| (Vec::new(), Vec::new())); + Ok((models, tool_ids, default_checked)) +} + +async fn fetch_settings( + cfg: &Configuration, + api_key: &str, + bearer_opt: Option<&str>, +) -> Result { + let url = format!("{}/settings", cfg.base_path); + let client = reqwest::Client::new(); + let mut req = client.get(&url).header("x-api-key", api_key); + if let Some(b) = bearer_opt { + req = req.header(AUTHORIZATION, b); + } + let resp = req + .send() + .await + .map_err(|e| format!("request failed: {}", e))?; + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(format!("request failed: status {} body: {}", status, body)); + } + let body: serde_json::Value = resp + .json() + .await + .map_err(|e| format!("invalid response: {}", e))?; + Ok(body) +} + +fn parse_llm_model(s: &str) -> Option { + serde_json::from_str::(&format!("\"{}\"", s)).ok() +} + +fn build_agent_request(message: String, opts: &PromptOptions) -> AgentMessageRequest { + let mut req = AgentMessageRequest::new(message); + req.no_interaction = Some(opts.no_interaction); + if let Some(ref tools) = opts.tools { + req.tools = Some(tools.clone()); + } + if let Some(ref model) = opts.llm_model { + if let Some(lm) = parse_llm_model(model) { + req.llm_model = Some(lm); + } + } + req +} + +fn parse_llm_model_json(s: &str) -> Option { + serde_json::from_str::(&format!("\"{}\"", s)).ok() +} + +fn build_agent_request_json( + message: String, + opts: &PromptOptions, +) -> AgentMessageRequestWithoutNoInteraction { + let mut req = AgentMessageRequestWithoutNoInteraction::new(message); + if let Some(ref tools) = opts.tools { + req.tools = Some(tools.clone()); + } + if let Some(ref model) = opts.llm_model { + if let Some(lm) = parse_llm_model_json(model) { + req.llm_model = Some(lm); + } + } + req +} + +pub fn run_interactive( + prompt_text: String, + _full_auto: bool, + opts: PromptOptions, +) -> Result<(), String> { let cfg = api_config::create_config(); let api_key = api_config::get_api_key(None)?; let bearer_header = api_config::get_bearer_header(None); + let opts_clone = opts.clone(); stream_and_print( &cfg, &api_key, bearer_header.as_deref(), prompt_text.clone(), + &opts_clone, )?; - // Simple REPL: user can reply; Ctrl-C exits process + if opts.no_interaction || opts.json_response { + return Ok(()); + } + let mut stdout = io::stdout(); let stdin = io::stdin(); loop { @@ -33,24 +252,82 @@ pub fn run_interactive(prompt_text: String, _full_auto: bool) -> Result<(), Stri if message.is_empty() { continue; } - stream_and_print(&cfg, &api_key, bearer_header.as_deref(), message)?; + let opts_loop = opts.clone(); + stream_and_print(&cfg, &api_key, bearer_header.as_deref(), message, &opts_loop)?; } Ok(()) } -pub fn run_background(prompt_text: String, _full_auto: bool) -> Result { +pub fn run_background( + prompt_text: String, + _full_auto: bool, + opts: PromptOptions, +) -> Result { let cfg = api_config::create_config(); let api_key = api_config::get_api_key(None)?; let bearer_header = api_config::get_bearer_header(None); - let request = AgentMessageRequest::new(prompt_text); + let request = if opts.json_response { + let req = build_agent_request_json(prompt_text, &opts); + tokio::runtime::Runtime::new() + .map_err(|e| format!("failed to start runtime: {}", e))? + .block_on(async move { + send_agent_message_json(&cfg, &api_key, bearer_header.as_deref(), req).await + })? + } else { + let request = build_agent_request(prompt_text, &opts); + tokio::runtime::Runtime::new() + .map_err(|e| format!("failed to start runtime: {}", e))? + .block_on(async move { + send_agent_message(&cfg, &api_key, bearer_header.as_deref(), request).await + })? + }; + Ok(request) +} - let rendered = tokio::runtime::Runtime::new() - .map_err(|e| format!("failed to start runtime: {}", e))? - .block_on(async move { - send_agent_message(&cfg, &api_key, bearer_header.as_deref(), request).await - })?; - Ok(rendered) +async fn send_agent_message_json( + cfg: &Configuration, + api_key: &str, + bearer_opt: Option<&str>, + request: AgentMessageRequestWithoutNoInteraction, +) -> Result { + let url = format!("{}/agent/response/json", cfg.base_path); + let client = reqwest::Client::new(); + let mut req = client + .post(&url) + .header("x-api-key", api_key) + .header(ACCEPT, "text/event-stream, application/json") + .header(CONTENT_TYPE, "application/json"); + if let Some(b) = bearer_opt { + req = req.header(AUTHORIZATION, b); + } + let resp = req + .json(&request) + .send() + .await + .map_err(|e| format!("request failed: {}", e))?; + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(format!("request failed: status {} body: {}", status, body)); + } + let ctype = resp + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + let text = resp.text().await.unwrap_or_default(); + if ctype.starts_with("text/event-stream") { + let last = parse_last_json_result_from_sse(&text); + return Ok(last.unwrap_or(text)); + } + if ctype.contains("application/json") { + if let Ok(json) = serde_json::from_str::(&text) { + return Ok(serde_json::to_string_pretty(&json).unwrap_or(text)); + } + } + Ok(text) } async fn send_agent_message( @@ -90,7 +367,6 @@ async fn send_agent_message( .to_string(); if ctype.starts_with("text/event-stream") { - // Collect the full SSE stream and return raw text for display let text = resp.text().await.unwrap_or_default(); return Ok(text); } @@ -114,7 +390,7 @@ async fn send_agent_message_stream( let url = format!("{}/agent/response", cfg.base_path); let client = reqwest::Client::new(); let mut req = client - .post(url) + .post(&url) .header("x-api-key", api_key) .header(ACCEPT, "text/event-stream, application/json") .header(CONTENT_TYPE, "application/json"); @@ -151,13 +427,11 @@ async fn send_agent_message_stream( let mut inserted_gap = false; while let Some(chunk) = resp.chunk().await.map_err(|e| e.to_string())? { buffer.push_str(&String::from_utf8_lossy(&chunk)); - // Normalize line endings let normalized = buffer.replace("\r\n", "\n"); let mut parts = normalized .split("\n\n") .map(|s| s.to_string()) .collect::>(); - // If the buffer doesn't end with a full event (double newline), keep the last partial buffer = match parts.pop() { Some(tail) if !normalized.ends_with("\n\n") => tail, Some(_) => String::new(), @@ -189,9 +463,7 @@ async fn send_agent_message_stream( let _ = tx.send(delta.to_string()); } } - // Suppress all non-delta JSON events (no raw JSON output) } - // Suppress non-JSON or unexpected events silently as well } } } @@ -210,36 +482,181 @@ async fn send_agent_message_stream( Ok(()) } +fn parse_last_json_result_from_sse(sse_text: &str) -> Option { + let normalized = sse_text.replace("\r\n", "\n"); + let mut last_data: Option = None; + for ev in normalized.split("\n\n") { + let mut event_name: Option = None; + let mut data_lines: Vec<&str> = Vec::new(); + for line in ev.lines() { + if let Some(rest) = line.strip_prefix("event:") { + event_name = Some(rest.trim().to_string()); + } else if let Some(rest) = line.strip_prefix("data:") { + data_lines.push(rest.trim_start()); + } + } + let data_str = data_lines.join("\n"); + if event_name.as_deref().map_or(false, |n| n.contains("tellers.json_result")) { + if let Ok(v) = serde_json::from_str::(&data_str) { + last_data = Some(serde_json::to_string_pretty(&v).unwrap_or(data_str)); + } + } + } + last_data +} + +async fn send_agent_message_stream_json( + cfg: &Configuration, + api_key: &str, + bearer_opt: Option<&str>, + request: AgentMessageRequestWithoutNoInteraction, + tx: mpsc::Sender, +) -> Result<(), String> { + let url = format!("{}/agent/response/json", cfg.base_path); + let client = reqwest::Client::new(); + let mut req = client + .post(&url) + .header("x-api-key", api_key) + .header(ACCEPT, "text/event-stream, application/json") + .header(CONTENT_TYPE, "application/json"); + if let Some(b) = bearer_opt { + req = req.header(AUTHORIZATION, b); + } + + let mut resp = req + .json(&request) + .send() + .await + .map_err(|e| format!("request failed: {}", e))?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + let _ = tx.send(format!( + "request failed: status {} body: {}\n", + status, body + )); + return Err("request failed".to_string()); + } + + let ctype = resp + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + + if ctype.starts_with("text/event-stream") { + let mut buffer = String::new(); + let mut last_json_result: Option = None; + while let Some(chunk) = resp.chunk().await.map_err(|e| e.to_string())? { + buffer.push_str(&String::from_utf8_lossy(&chunk)); + let normalized = buffer.replace("\r\n", "\n"); + let mut parts = normalized + .split("\n\n") + .map(|s| s.to_string()) + .collect::>(); + buffer = match parts.pop() { + Some(tail) if !normalized.ends_with("\n\n") => tail, + Some(_) => String::new(), + None => String::new(), + }; + for ev in parts { + let mut event_name: Option = None; + let mut data_lines: Vec = Vec::new(); + for line in ev.lines() { + if let Some(rest) = line.strip_prefix("event:") { + event_name = Some(rest.trim().to_string()); + } else if let Some(rest) = line.strip_prefix("data:") { + data_lines.push(rest.trim_start().to_string()); + } + } + let data_str = data_lines.join("\n"); + if let Some(name) = &event_name { + if name.contains("tellers.json_result") { + if let Ok(v) = serde_json::from_str::(&data_str) { + let pretty = serde_json::to_string_pretty(&v).unwrap_or(data_str); + last_json_result = Some(pretty); + } + } + } + } + } + if let Some(pretty) = last_json_result { + let _ = tx.send(pretty); + let _ = tx.send("\n".to_string()); + } + return Ok(()); + } + + let text = resp.text().await.unwrap_or_default(); + if ctype.contains("application/json") { + if let Ok(json) = serde_json::from_str::(&text) { + let pretty = serde_json::to_string_pretty(&json).unwrap_or(text); + let _ = tx.send(pretty); + return Ok(()); + } + } + let _ = tx.send(text); + Ok(()) +} + fn stream_and_print( cfg: &Configuration, api_key: &str, bearer_opt: Option<&str>, message: String, + opts: &PromptOptions, ) -> Result<(), String> { let (tx, rx) = mpsc::channel::(); let cfg_clone = cfg.clone(); let api_key_clone = api_key.to_string(); let bearer_clone = bearer_opt.map(|s| s.to_string()); - thread::spawn(move || { - let rt = match tokio::runtime::Runtime::new() { - Ok(rt) => rt, - Err(err) => { - let _ = tx.send(format!("runtime error: {}\n", err)); - return; - } - }; - let request = AgentMessageRequest::new(message); - rt.block_on(async move { - let _ = send_agent_message_stream( - &cfg_clone, - &api_key_clone, - bearer_clone.as_deref(), - request, - tx, - ) - .await; + let opts_clone = opts.clone(); + + if opts.json_response { + let request = build_agent_request_json(message, opts); + thread::spawn(move || { + let rt = match tokio::runtime::Runtime::new() { + Ok(rt) => rt, + Err(err) => { + let _ = tx.send(format!("runtime error: {}\n", err)); + return; + } + }; + rt.block_on(async move { + let _ = send_agent_message_stream_json( + &cfg_clone, + &api_key_clone, + bearer_clone.as_deref(), + request, + tx, + ) + .await; + }); + }); + } else { + let request = build_agent_request(message, &opts_clone); + thread::spawn(move || { + let rt = match tokio::runtime::Runtime::new() { + Ok(rt) => rt, + Err(err) => { + let _ = tx.send(format!("runtime error: {}\n", err)); + return; + } + }; + rt.block_on(async move { + let _ = send_agent_message_stream( + &cfg_clone, + &api_key_clone, + bearer_clone.as_deref(), + request, + tx, + ) + .await; + }); }); - }); + } let mut stdout = io::stdout(); while let Ok(chunk) = rx.recv() { diff --git a/src/main.rs b/src/main.rs index 15c8619..605a1a9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -35,19 +35,31 @@ fn main() { } None => { if let Some(prompt_text) = cli.prompt { - if cli.background { - match commands::prompt::run_background(prompt_text, cli.full_auto) { - Ok(chat_id) => { - // Only print chat id in background mode - println!("{}", chat_id); + let mut opts = commands::prompt::PromptOptions::from_cli( + cli.no_interaction, + cli.json_response, + cli.tools.clone(), + cli.llm_model.clone(), + ); + if cli.interactive { + match commands::prompt::run_interactive_options(&opts) { + Ok(interactive_opts) => opts = interactive_opts, + Err(error) => { + eprintln!("error: {}", error); + std::process::exit(1); } + } + } + if cli.background { + match commands::prompt::run_background(prompt_text, cli.full_auto, opts) { + Ok(result) => println!("{}", result), Err(error) => { eprintln!("error: {}", error); std::process::exit(1); } } } else if let Err(error) = - commands::prompt::run_interactive(prompt_text, cli.full_auto) + commands::prompt::run_interactive(prompt_text, cli.full_auto, opts) { eprintln!("error: {}", error); std::process::exit(1); diff --git a/src/tellers_api/openapi.tellers_public_api.yaml b/src/tellers_api/openapi.tellers_public_api.yaml index 7e083fd..2366c77 100644 --- a/src/tellers_api/openapi.tellers_public_api.yaml +++ b/src/tellers_api/openapi.tellers_public_api.yaml @@ -554,6 +554,46 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' + /agent/available-llm-models: + get: + tags: + - accepts-api-key + summary: Get Available Llm Models + operationId: get_available_llm_models_agent_available_llm_models_get + parameters: + - name: x-api-key + in: header + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: X-Api-Key + - name: authorization + in: header + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Authorization + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: array + items: + type: string + title: Response Get Available Llm Models Agent Available Llm Models + Get + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /agent/response: post: tags: @@ -595,6 +635,55 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' + /agent/response/json: + post: + tags: + - accepts-api-key + - accepts-api-key + summary: Process Agent Message Json + description: 'Same as POST /response but runs with no_interaction=True. Emits + only SSE events + + `event: tellers.json_result` (progressing while running, then done with full + result). + + chat_id must be set by the client or is created at the start.' + operationId: process_agent_message_json_agent_response_json_post + parameters: + - name: x-api-key + in: header + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: X-Api-Key + - name: authorization + in: header + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Authorization + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AgentMessageRequestWithoutNoInteraction' + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /agent/use-tool: post: tags: @@ -905,6 +994,19 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' + /settings: + get: + tags: + - accepts-api-key + summary: Get Settings + operationId: get_settings_settings_get + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/AppSettings' components: schemas: AgentMessageRequest: @@ -956,18 +1058,24 @@ components: - $ref: '#/components/schemas/AppContext' - type: 'null' description: The app context when the response is sent. + llm_model: + type: string + enum: + - gpt-5.4-pro-2026-03-05 + - gpt-5.4-2026-03-05 + - gpt-5.3-codex + - gpt-5.2-2025-12-11 + - gpt-5-mini-2025-08-07 + title: Llm Model + description: The model to use to generate the response. + default: gpt-5.4-2026-03-05 tools: items: type: string type: array title: Tools - description: The tools to use to generate the response - default: - - generate_video - - create_project - - describe_video - - get_source_media_duration - - web_search_preview + description: The tools to use to generate the response. Defaults to the + same as GET /settings (AVAILABLE_AGENT_TOOLS if set, else all tools). custom_agent_id: anyOf: - type: string @@ -982,11 +1090,165 @@ components: will be executed in parallel. If False, the tool calls will be executed sequentially. default: false + override_llm_verbosity: + anyOf: + - type: string + enum: + - low + - medium + - high + - type: 'null' + title: Override Llm Verbosity + description: Override the verbosity of the LLM, range of number of tokens + that can be generated by the LLM, higher values yield more verbose responses. + override_llm_reasoning_effort: + anyOf: + - type: string + enum: + - none + - minimal + - low + - medium + - high + - xhigh + - type: 'null' + title: Override Llm Reasoning Effort + description: 'Override the reasoning effort of the LLM, controls reasoning + effort: higher values yield more precise but slower responses.' + max_mode: + type: boolean + title: Max Mode + description: Whether to enable max mode. If True, the agent will use the + maximum number of tokens allowed by the model. + default: false + no_interaction: + type: boolean + title: No Interaction + description: Whether to disable interaction with the agent. If True, the + agent will try to generate a response without interacting with the user. + default: false additionalProperties: false type: object required: - message title: AgentMessageRequest + AgentMessageRequestWithoutNoInteraction: + properties: + message: + type: string + title: Message + description: The message to send to the agent + chat_id: + anyOf: + - type: string + - type: 'null' + title: Chat Id + description: Optional chat ID for conversation tracking + previous_response_id: + anyOf: + - type: string + - type: 'null' + title: Previous Response Id + description: The ID of the previous response to the model. Use this to create + multi-turn conversations. + llm_verbosity: + type: string + enum: + - low + - medium + - high + title: Llm Verbosity + description: Range of number of tokens that can be generated by the LLM, + higher values yield more verbose responses. + default: low + llm_reasoning_effort: + anyOf: + - type: string + enum: + - none + - minimal + - low + - medium + - high + - xhigh + - type: 'null' + title: Llm Reasoning Effort + description: 'Controls reasoning effort: higher values yield more precise + but slower responses.' + default: low + context: + anyOf: + - $ref: '#/components/schemas/AppContext' + - type: 'null' + description: The app context when the response is sent. + llm_model: + type: string + enum: + - gpt-5.4-pro-2026-03-05 + - gpt-5.4-2026-03-05 + - gpt-5.3-codex + - gpt-5.2-2025-12-11 + - gpt-5-mini-2025-08-07 + title: Llm Model + description: The model to use to generate the response. + default: gpt-5.4-2026-03-05 + tools: + items: + type: string + type: array + title: Tools + description: The tools to use to generate the response. Defaults to the + same as GET /settings (AVAILABLE_AGENT_TOOLS if set, else all tools). + custom_agent_id: + anyOf: + - type: string + - type: 'null' + title: Custom Agent Id + description: The ID of the custom agent to use to generate the response. + If not provided, the default custom agent will be used. + parallel_tool_calls: + type: boolean + title: Parallel Tool Calls + description: Whether to allow parallel tool calls. If True, the tool calls + will be executed in parallel. If False, the tool calls will be executed + sequentially. + default: false + override_llm_verbosity: + anyOf: + - type: string + enum: + - low + - medium + - high + - type: 'null' + title: Override Llm Verbosity + description: Override the verbosity of the LLM, range of number of tokens + that can be generated by the LLM, higher values yield more verbose responses. + override_llm_reasoning_effort: + anyOf: + - type: string + enum: + - none + - minimal + - low + - medium + - high + - xhigh + - type: 'null' + title: Override Llm Reasoning Effort + description: 'Override the reasoning effort of the LLM, controls reasoning + effort: higher values yield more precise but slower responses.' + max_mode: + type: boolean + title: Max Mode + description: Whether to enable max mode. If True, the agent will use the + maximum number of tokens allowed by the model. + default: false + additionalProperties: false + type: object + required: + - message + title: AgentMessageRequestWithoutNoInteraction AppContext: properties: current_playback_time: @@ -1035,6 +1297,32 @@ components: additionalProperties: false type: object title: AppContext + AppSettings: + properties: + maintenance_mode: + type: boolean + title: Maintenance Mode + hd_enabled: + type: boolean + title: Hd Enabled + available_agent_tools: + items: + additionalProperties: true + type: object + type: array + title: Available Agent Tools + available_llm_models: + items: + type: string + type: array + title: Available Llm Models + type: object + required: + - maintenance_mode + - hd_enabled + - available_agent_tools + - available_llm_models + title: AppSettings AssetUploadRequest: properties: content_length: diff --git a/src/tui/checkbox_list.rs b/src/tui/checkbox_list.rs new file mode 100644 index 0000000..8685e56 --- /dev/null +++ b/src/tui/checkbox_list.rs @@ -0,0 +1,130 @@ +use crossterm::{ + event::{self, Event, KeyCode, KeyEventKind}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{ + backend::CrosstermBackend, + layout::{Constraint, Layout}, + style::{Modifier, Style}, + text::Line, + widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, + Frame, Terminal, +}; +use std::io; + +pub fn run_checkbox_list( + title: &str, + items: Vec, + default_checked: Option>, +) -> Result, String> { + if items.is_empty() { + return Ok(Vec::new()); + } + + let mut stdout = io::stdout(); + enable_raw_mode().map_err(|e| format!("terminal: {}", e))?; + execute!(stdout, EnterAlternateScreen).map_err(|e| format!("terminal: {}", e))?; + + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend).map_err(|e| format!("terminal: {}", e))?; + + let mut checked: Vec = if let Some(d) = default_checked { + (0..items.len()) + .map(|i| d.get(i).copied().unwrap_or(false)) + .collect() + } else { + items.iter().map(|_| false).collect() + }; + let mut list_state = ListState::default(); + list_state.select(Some(0)); + + let result = loop { + terminal + .draw(|f| draw_checkbox_list(f, title, &items, &checked, &mut list_state)) + .map_err(|e| format!("draw: {}", e))?; + + if let Event::Key(key) = event::read().map_err(|e| format!("event: {}", e))? { + if key.kind != KeyEventKind::Press { + continue; + } + match key.code { + KeyCode::Char('q') | KeyCode::Esc => break collect_selected(&items, &checked), + KeyCode::Enter => break collect_selected(&items, &checked), + KeyCode::Up => { + let i = list_state.selected().unwrap_or(0); + list_state.select(Some(i.saturating_sub(1))); + } + KeyCode::Down => { + let i = list_state.selected().unwrap_or(0); + let next = (i + 1).min(items.len().saturating_sub(1)); + list_state.select(Some(next)); + } + KeyCode::Char(' ') => { + if let Some(i) = list_state.selected() { + if i < checked.len() { + checked[i] = !checked[i]; + } + } + } + KeyCode::Char('a') | KeyCode::Char('A') => { + let all = checked.iter().all(|&c| c); + for c in &mut checked { + *c = !all; + } + } + _ => {} + } + } + }; + + execute!(io::stdout(), LeaveAlternateScreen).map_err(|e| format!("terminal: {}", e))?; + disable_raw_mode().map_err(|e| format!("terminal: {}", e))?; + terminal.show_cursor().map_err(|e| format!("terminal: {}", e))?; + + Ok(result) +} + +fn draw_checkbox_list( + f: &mut Frame, + title: &str, + items: &[String], + checked: &[bool], + list_state: &mut ListState, +) { + let area = f.area(); + let chunks = Layout::default() + .constraints([Constraint::Min(3), Constraint::Length(3)]) + .split(area); + + let list_items: Vec = items + .iter() + .enumerate() + .map(|(i, label)| { + let mark = if checked.get(i).copied().unwrap_or(false) { + "[x]" + } else { + "[ ]" + }; + ListItem::new(Line::from(format!("{} {}", mark, label))) + }) + .collect(); + + let list = List::new(list_items) + .block(Block::default().title(title).borders(Borders::ALL)) + .highlight_style(Style::default().add_modifier(Modifier::REVERSED)) + .highlight_symbol(">> "); + + f.render_stateful_widget(list, chunks[0], list_state); + + let help = Paragraph::new("↑/↓ move Space toggle a toggle all Enter confirm q quit"); + f.render_widget(help, chunks[1]); +} + +fn collect_selected(items: &[String], checked: &[bool]) -> Vec { + items + .iter() + .zip(checked.iter().copied()) + .filter_map(|(id, c)| if c { Some(id.clone()) } else { None }) + .collect() +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 1b53090..7cccae7 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -1,5 +1,7 @@ +mod checkbox_list; mod inline_progress; mod two_queue_progress; +pub use checkbox_list::run_checkbox_list; pub use inline_progress::{InlineProgress, ProgressHandle}; pub use two_queue_progress::{TwoQueueProgress, TwoQueueProgressHandle};