Add terminal client, memory, model fallback, version and install improvements
- codecraft: full-screen terminal client (ratatui) – local tools and files, models via the CodeCraft server gateway or own providers (standalone with a guided setup), slash commands, @files, permission dialogs, resume, update - Static Linux CLI builds (x86_64/aarch64, cargo-zigbuild) served by the server: /cli/install.sh and /cli/download/<target> - Project memory (remember tool, .codecraft/memory.md) and personal memory - Model fallback when a host is unreachable or a budget is used up - Version/commit in UI and health, daily release check, "What's new" dialog - PWA: install button, maskable icon, manifest id and screenshots - Case-insensitive detection of connection errors for retries Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
aa1f8cf016
commit
6dffcb501a
52 files changed
+6477
-765
No files matched your search
@@ -0,0 +1,152 @@
|
||||
//! Model catalog: the server's models ("server:provider/model") and the user's
|
||||
//! own providers ("provider/model"), and streaming requests to either.
|
||||
|
||||
use crate::conf::Config;
|
||||
use crate::server::Server;
|
||||
use anyhow::{Result, anyhow};
|
||||
use codecraft_core::config::ModelConfig;
|
||||
use codecraft_core::provider::{ChatRequest, StreamEvent, ToolDef};
|
||||
use codecraft_core::types::{Message, Usage};
|
||||
use serde_json::Value;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
pub const SERVER_PREFIX: &str = "server:";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ModelInfo {
|
||||
/// Selection key, e.g. "server:gpu/qwen-coder" or "ollama/llama3".
|
||||
pub spec: String,
|
||||
pub label: String,
|
||||
pub config: ModelConfig,
|
||||
}
|
||||
|
||||
impl ModelInfo {
|
||||
pub fn cost(&self, u: &Usage) -> f64 {
|
||||
self.config.cost(u)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
pub struct Catalog {
|
||||
pub models: Vec<ModelInfo>,
|
||||
pub currency: String,
|
||||
}
|
||||
|
||||
fn model_from_json(v: &Value) -> ModelConfig {
|
||||
serde_json::from_value(v.clone()).unwrap_or_else(|_| ModelConfig { id: v["id"].as_str().unwrap_or("").into(), ..Default::default() })
|
||||
}
|
||||
|
||||
impl Catalog {
|
||||
/// Collect models from the server config (if signed in) and local providers.
|
||||
pub async fn load(cfg: &Config, server_cfg: Option<&Value>) -> Self {
|
||||
let mut models = Vec::new();
|
||||
let mut currency = String::new();
|
||||
if let Some(sc) = server_cfg {
|
||||
currency = sc["currency"].as_str().unwrap_or("").to_string();
|
||||
for p in sc["models"].as_array().into_iter().flatten() {
|
||||
let provider = p["provider"].as_str().unwrap_or("");
|
||||
for m in p["models"].as_array().into_iter().flatten() {
|
||||
let config = model_from_json(m);
|
||||
let name = m["name"].as_str().unwrap_or(&config.id).to_string();
|
||||
models.push(ModelInfo { spec: format!("{SERVER_PREFIX}{provider}/{}", config.id), label: format!("server · {provider} · {name}"), config });
|
||||
}
|
||||
}
|
||||
}
|
||||
for (name, pc) in &cfg.providers {
|
||||
if !pc.enabled {
|
||||
continue;
|
||||
}
|
||||
let mut list: Vec<ModelConfig> = pc.models.iter().map(|m| m.resolved()).collect();
|
||||
if list.is_empty() {
|
||||
// Discover (e.g. Ollama, LM Studio) – quick, failures leave the list empty.
|
||||
let provider = codecraft_core::provider::build(codecraft_core::provider::http_client(), pc);
|
||||
if let Ok(Ok(found)) = tokio::time::timeout(std::time::Duration::from_secs(5), provider.list_models()).await {
|
||||
list = found;
|
||||
}
|
||||
}
|
||||
for m in list {
|
||||
let label = format!("{name} · {}", m.name.clone().unwrap_or_else(|| m.id.clone()));
|
||||
models.push(ModelInfo { spec: format!("{name}/{}", m.id), label, config: m });
|
||||
}
|
||||
}
|
||||
Self { models, currency }
|
||||
}
|
||||
|
||||
pub fn find(&self, spec: &str) -> Option<&ModelInfo> {
|
||||
self.models.iter().find(|m| m.spec == spec)
|
||||
}
|
||||
}
|
||||
|
||||
/// Stream one model request to the right backend.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn stream(
|
||||
cfg: &Config,
|
||||
server: Option<&Server>,
|
||||
model: &ModelInfo,
|
||||
system: String,
|
||||
messages: Vec<Message>,
|
||||
tools: Vec<ToolDef>,
|
||||
effort: Option<String>,
|
||||
session: &str,
|
||||
agent: &str,
|
||||
tx: mpsc::Sender<StreamEvent>,
|
||||
cancel: CancellationToken,
|
||||
) -> Result<()> {
|
||||
if let Some(rest) = model.spec.strip_prefix(SERVER_PREFIX) {
|
||||
let server = server.ok_or_else(|| anyhow!("not signed in to a CodeCraft server (codecraft login)"))?;
|
||||
return server.stream(rest, system, messages, tools, effort, session, agent, tx, cancel).await;
|
||||
}
|
||||
let (pname, _) = model.spec.split_once('/').ok_or_else(|| anyhow!("invalid model '{}'", model.spec))?;
|
||||
let pc = cfg.providers.get(pname).ok_or_else(|| anyhow!("unknown provider '{pname}' – run /setup"))?;
|
||||
let provider = codecraft_core::provider::build(codecraft_core::provider::http_client(), pc);
|
||||
let mut mc = model.config.clone();
|
||||
if let Some(e) = effort {
|
||||
mc.effort = Some(e);
|
||||
}
|
||||
let max_tokens = mc.max_output.unwrap_or(16_000);
|
||||
let req = ChatRequest { model: mc, system, messages, tools, max_tokens, reasoning: true };
|
||||
provider.stream(req, tx, cancel).await
|
||||
}
|
||||
|
||||
/// Known endpoints for the setup wizard.
|
||||
pub struct Preset {
|
||||
pub id: &'static str,
|
||||
pub label: &'static str,
|
||||
pub kind: codecraft_core::config::ProviderKind,
|
||||
pub base_url: &'static str,
|
||||
pub needs_key: bool,
|
||||
pub key_hint: &'static str,
|
||||
}
|
||||
|
||||
pub fn presets() -> Vec<Preset> {
|
||||
use codecraft_core::config::ProviderKind::*;
|
||||
vec![
|
||||
Preset { id: "anthropic", label: "Anthropic (Claude)", kind: Anthropic, base_url: "", needs_key: true, key_hint: "sk-ant-… from console.anthropic.com" },
|
||||
Preset { id: "openai", label: "OpenAI", kind: Openai, base_url: "https://api.openai.com/v1", needs_key: true, key_hint: "sk-… from platform.openai.com" },
|
||||
Preset { id: "gemini", label: "Google Gemini", kind: Openai, base_url: "https://generativelanguage.googleapis.com/v1beta/openai", needs_key: true, key_hint: "key from aistudio.google.com" },
|
||||
Preset { id: "openrouter", label: "OpenRouter", kind: Openai, base_url: "https://openrouter.ai/api/v1", needs_key: true, key_hint: "sk-or-… from openrouter.ai" },
|
||||
Preset { id: "opencode", label: "opencode Zen / Go", kind: Openai, base_url: "https://opencode.ai/zen/v1", needs_key: true, key_hint: "key from opencode.ai" },
|
||||
Preset { id: "ollama", label: "Ollama (local)", kind: Openai, base_url: "http://localhost:11434/v1", needs_key: false, key_hint: "" },
|
||||
Preset { id: "lmstudio", label: "LM Studio (local)", kind: Openai, base_url: "http://localhost:1234/v1", needs_key: false, key_hint: "" },
|
||||
Preset { id: "llamacpp", label: "llama.cpp / vLLM (local)", kind: Openai, base_url: "http://localhost:8080/v1", needs_key: false, key_hint: "" },
|
||||
Preset { id: "custom", label: "Other OpenAI-compatible endpoint", kind: Openai, base_url: "", needs_key: false, key_hint: "optional" },
|
||||
]
|
||||
}
|
||||
|
||||
/// Local model servers that answer right now (for the wizard).
|
||||
pub async fn detect_local() -> Vec<&'static str> {
|
||||
let mut found = Vec::new();
|
||||
for p in presets().into_iter().filter(|p| p.base_url.starts_with("http://localhost")) {
|
||||
let ok = codecraft_core::provider::http_client()
|
||||
.get(format!("{}/models", p.base_url))
|
||||
.timeout(std::time::Duration::from_millis(800))
|
||||
.send()
|
||||
.await
|
||||
.is_ok_and(|r| r.status().is_success());
|
||||
if ok {
|
||||
found.push(p.id);
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
Reference in new issue
Block a user