Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "nomifun-assistant"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
nomifun-common = { workspace = true }
|
||||
nomifun-api-types = { workspace = true }
|
||||
nomifun-db = { workspace = true }
|
||||
nomifun-extension = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
include_dir = "0.7"
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
nomifun-realtime = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
tower = { workspace = true, features = ["util"] }
|
||||
http-body-util = { workspace = true }
|
||||
@@ -0,0 +1,523 @@
|
||||
//! Built-in assistant registry — embeds the manifest + rule/skill/avatar
|
||||
//! assets into the binary via `include_dir`, with an optional filesystem
|
||||
//! fallback for E2E tests.
|
||||
//!
|
||||
//! This kills the "binary must live next to an on-disk assets/ sibling"
|
||||
//! assumption, which was fragile in two ways:
|
||||
//!
|
||||
//! 1. Dev: Electron launches the backend through a symlink
|
||||
//! (`~/.cargo/bin/nomicore` → `target/debug/nomicore`) and
|
||||
//! `std::env::current_exe().parent()` would resolve to the symlink's
|
||||
//! directory, not the real binary's, missing the `assets/` sibling.
|
||||
//! 2. Prod: `Nomi/scripts/prepareNomifunBackend.js` only copies the
|
||||
//! binary from GitHub releases — the `assets/` directory never shipped.
|
||||
//!
|
||||
//! Embedding avoids both. E2E tests that want to inject a custom fixture
|
||||
//! still can, via the `NOMIFUN_BUILTIN_ASSISTANTS_PATH` env var → disk
|
||||
//! fallback path.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use include_dir::{Dir, include_dir};
|
||||
use serde::Deserialize;
|
||||
use tracing::{error, warn};
|
||||
|
||||
/// Assets compiled into the binary at build time. Paths are relative to
|
||||
/// this embedded root, matching the on-disk layout under
|
||||
/// `crates/nomifun-app/assets/builtin-assistants/`.
|
||||
static BUILTIN_ASSETS: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../nomifun-app/assets/builtin-assistants");
|
||||
|
||||
/// Single built-in assistant entry, loaded from `assistants.json`.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct BuiltinAssistant {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub name_i18n: HashMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub description_i18n: HashMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub avatar: Option<String>,
|
||||
pub preset_agent_type: String,
|
||||
#[serde(default)]
|
||||
pub enabled_skills: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub custom_skill_names: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub disabled_builtin_skills: Vec<String>,
|
||||
/// Relative to the asset root; may contain `{locale}`.
|
||||
#[serde(default)]
|
||||
pub rule_file: Option<String>,
|
||||
/// Parallel to `rule_file`, for `/api/skills/assistant-skill/*` dispatch.
|
||||
#[serde(default)]
|
||||
pub skill_file: Option<String>,
|
||||
#[serde(default)]
|
||||
pub prompts: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub prompts_i18n: HashMap<String, Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub models: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub audience_tags: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub scenario_tags: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct BuiltinManifest {
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
version: String,
|
||||
#[serde(default)]
|
||||
assistants: Vec<BuiltinAssistant>,
|
||||
}
|
||||
|
||||
/// One built-in seed tag, loaded from `tags.json`.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct BuiltinTag {
|
||||
pub key: String,
|
||||
/// "audience" | "scenario".
|
||||
pub dimension: String,
|
||||
#[serde(default)]
|
||||
pub label: String,
|
||||
#[serde(default)]
|
||||
pub label_i18n: HashMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub sort_order: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
struct BuiltinTagManifest {
|
||||
#[serde(default)]
|
||||
tags: Vec<BuiltinTag>,
|
||||
}
|
||||
|
||||
/// An avatar asset loaded from either the embedded bundle or a disk override.
|
||||
///
|
||||
/// Carries the raw bytes plus the file extension (lower-case, without the
|
||||
/// leading dot) so the HTTP layer can set `Content-Type`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AvatarAsset {
|
||||
pub bytes: Vec<u8>,
|
||||
pub extension: Option<String>,
|
||||
}
|
||||
|
||||
/// Source of built-in asset content.
|
||||
///
|
||||
/// The disk branch exists for E2E tests that point
|
||||
/// `NOMIFUN_BUILTIN_ASSISTANTS_PATH` at a fixture directory.
|
||||
enum Source {
|
||||
Embedded,
|
||||
Disk(PathBuf),
|
||||
}
|
||||
|
||||
/// In-memory registry of built-in assistants.
|
||||
pub struct BuiltinAssistantRegistry {
|
||||
assistants: HashMap<String, BuiltinAssistant>,
|
||||
tags: Vec<BuiltinTag>,
|
||||
source: Source,
|
||||
}
|
||||
|
||||
impl BuiltinAssistantRegistry {
|
||||
/// Construct the registry.
|
||||
///
|
||||
/// If `NOMIFUN_BUILTIN_ASSISTANTS_PATH` is set and points to a readable
|
||||
/// directory, read from disk (test-only override). Otherwise use the
|
||||
/// assets embedded at compile time.
|
||||
pub fn load() -> Self {
|
||||
if let Ok(env) = std::env::var("NOMIFUN_BUILTIN_ASSISTANTS_PATH") {
|
||||
let p = PathBuf::from(env);
|
||||
if p.exists() {
|
||||
return Self::load_from_dir(p);
|
||||
}
|
||||
warn!(
|
||||
"NOMIFUN_BUILTIN_ASSISTANTS_PATH points to missing directory; \
|
||||
falling back to embedded assets"
|
||||
);
|
||||
}
|
||||
Self::load_embedded()
|
||||
}
|
||||
|
||||
/// Load the compiled-in assets.
|
||||
pub fn load_embedded() -> Self {
|
||||
let tags = BUILTIN_ASSETS
|
||||
.get_file("tags.json")
|
||||
.map(|f| parse_tags_bytes(f.contents()))
|
||||
.unwrap_or_default();
|
||||
let content = match BUILTIN_ASSETS.get_file("assistants.json") {
|
||||
Some(f) => f.contents(),
|
||||
None => {
|
||||
// This can only happen if the embedded bundle itself is
|
||||
// missing the manifest — treat as a build error, but stay
|
||||
// graceful at runtime.
|
||||
error!("Embedded built-in manifest missing (assistants.json)");
|
||||
return Self::with_assistants(HashMap::new(), tags, Source::Embedded);
|
||||
}
|
||||
};
|
||||
let assistants = parse_manifest_bytes(content);
|
||||
Self::with_assistants(assistants, tags, Source::Embedded)
|
||||
}
|
||||
|
||||
/// Load from an explicit on-disk directory. Preserved for
|
||||
/// `NOMIFUN_BUILTIN_ASSISTANTS_PATH` E2E fixtures — the three
|
||||
/// graceful-degradation branches below mirror the original filesystem
|
||||
/// behaviour.
|
||||
pub fn load_from_dir(assets_dir: PathBuf) -> Self {
|
||||
let tags = std::fs::read_to_string(assets_dir.join("tags.json"))
|
||||
.map(|c| parse_tags_str(&c))
|
||||
.unwrap_or_default();
|
||||
let manifest_path = assets_dir.join("assistants.json");
|
||||
let content = match std::fs::read_to_string(&manifest_path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
warn!("Built-in manifest missing at {}: {}", manifest_path.display(), e);
|
||||
return Self::with_assistants(HashMap::new(), tags, Source::Disk(assets_dir));
|
||||
}
|
||||
};
|
||||
let assistants = parse_manifest_str(&content);
|
||||
Self::with_assistants(assistants, tags, Source::Disk(assets_dir))
|
||||
}
|
||||
|
||||
fn with_assistants(
|
||||
assistants: HashMap<String, BuiltinAssistant>,
|
||||
tags: Vec<BuiltinTag>,
|
||||
source: Source,
|
||||
) -> Self {
|
||||
Self { assistants, tags, source }
|
||||
}
|
||||
|
||||
/// Construct an empty registry (safe fallback + test helper). Treated
|
||||
/// as embedded-source with zero entries; callers should prefer
|
||||
/// [`load`](Self::load) in production.
|
||||
pub fn empty() -> Self {
|
||||
Self::with_assistants(HashMap::new(), Vec::new(), Source::Embedded)
|
||||
}
|
||||
|
||||
pub fn has(&self, id: &str) -> bool {
|
||||
self.assistants.contains_key(id)
|
||||
}
|
||||
|
||||
pub fn get(&self, id: &str) -> Option<&BuiltinAssistant> {
|
||||
self.assistants.get(id)
|
||||
}
|
||||
|
||||
pub fn all(&self) -> impl Iterator<Item = &BuiltinAssistant> {
|
||||
self.assistants.values()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.assistants.is_empty()
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.assistants.len()
|
||||
}
|
||||
|
||||
/// The built-in seed tag vocabulary (from `tags.json`). Empty when the
|
||||
/// manifest is absent or malformed.
|
||||
pub fn tags(&self) -> &[BuiltinTag] {
|
||||
&self.tags
|
||||
}
|
||||
|
||||
/// Read the rule file bytes for a built-in assistant. Substitutes
|
||||
/// `{locale}` in the manifest-declared `rule_file` path. Returns `None`
|
||||
/// when the assistant has no declared rule or the file is missing.
|
||||
pub fn rule_bytes(&self, id: &str, locale: &str) -> Option<Vec<u8>> {
|
||||
let rel = self.assistants.get(id)?.rule_file.as_ref()?;
|
||||
self.read_asset(&rel.replace("{locale}", locale))
|
||||
}
|
||||
|
||||
/// Read the skill file bytes for a built-in assistant.
|
||||
pub fn skill_bytes(&self, id: &str, locale: &str) -> Option<Vec<u8>> {
|
||||
let rel = self.assistants.get(id)?.skill_file.as_ref()?;
|
||||
self.read_asset(&rel.replace("{locale}", locale))
|
||||
}
|
||||
|
||||
/// Read the avatar asset for a built-in assistant along with its
|
||||
/// extension (for Content-Type inference). Returns `None` when the
|
||||
/// manifest does not declare an avatar or the file is missing.
|
||||
///
|
||||
/// Note: when the manifest `avatar` field is an emoji string
|
||||
/// (like `"📝"`) rather than a relative path, no file is resolved and
|
||||
/// this method returns `None`. Callers treating an assistant without a
|
||||
/// shipped avatar should fall back to the text avatar on the client.
|
||||
pub fn avatar_asset(&self, id: &str) -> Option<AvatarAsset> {
|
||||
let a = self.assistants.get(id)?;
|
||||
let rel = a.avatar.as_ref()?;
|
||||
// Emoji / text avatars have no path separator and no extension.
|
||||
if !looks_like_relative_path(rel) {
|
||||
return None;
|
||||
}
|
||||
let bytes = self.read_asset(rel)?;
|
||||
let extension = Path::new(rel)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|s| s.to_ascii_lowercase());
|
||||
Some(AvatarAsset { bytes, extension })
|
||||
}
|
||||
|
||||
/// Dispatch read to embedded bundle or disk, depending on source.
|
||||
fn read_asset(&self, rel: &str) -> Option<Vec<u8>> {
|
||||
match &self.source {
|
||||
Source::Embedded => BUILTIN_ASSETS.get_file(rel).map(|f| f.contents().to_vec()),
|
||||
Source::Disk(root) => std::fs::read(root.join(rel)).ok(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for BuiltinAssistantRegistry {
|
||||
fn default() -> Self {
|
||||
Self::empty()
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_manifest_bytes(bytes: &[u8]) -> HashMap<String, BuiltinAssistant> {
|
||||
match serde_json::from_slice::<BuiltinManifest>(bytes) {
|
||||
Ok(m) => m.assistants.into_iter().map(|a| (a.id.clone(), a)).collect(),
|
||||
Err(e) => {
|
||||
error!("Embedded built-in manifest parse failed: {e}");
|
||||
HashMap::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_manifest_str(content: &str) -> HashMap<String, BuiltinAssistant> {
|
||||
match serde_json::from_str::<BuiltinManifest>(content) {
|
||||
Ok(m) => m.assistants.into_iter().map(|a| (a.id.clone(), a)).collect(),
|
||||
Err(e) => {
|
||||
error!("Built-in manifest parse failed: {e}");
|
||||
HashMap::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the embedded `tags.json` bytes. Missing/malformed → empty vec
|
||||
/// (built-in seed tags are optional; they ship in a later phase).
|
||||
fn parse_tags_bytes(bytes: &[u8]) -> Vec<BuiltinTag> {
|
||||
serde_json::from_slice::<BuiltinTagManifest>(bytes).map(|m| m.tags).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// String variant of [`parse_tags_bytes`] for the disk-source loader.
|
||||
fn parse_tags_str(content: &str) -> Vec<BuiltinTag> {
|
||||
serde_json::from_str::<BuiltinTagManifest>(content).map(|m| m.tags).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Heuristic for distinguishing a relative-path avatar (`"rules/x.svg"`)
|
||||
/// from an inline emoji/text avatar (`"📝"`). Path-like strings contain a
|
||||
/// `/` or at least one `.` extension separator.
|
||||
fn looks_like_relative_path(s: &str) -> bool {
|
||||
s.contains('/') || (Path::new(s).extension().is_some() && !s.starts_with('.'))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn write_manifest(dir: &Path, body: &str) {
|
||||
std::fs::write(dir.join("assistants.json"), body).unwrap();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Embedded-source sanity: the bundle shipped with the crate must be
|
||||
// well-formed and non-empty. Acts as a compile-time → runtime bridge
|
||||
// guard (if the manifest is ever broken or the include_dir path is
|
||||
// wrong, this test fails immediately rather than at user-hit-404 time).
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn load_embedded_has_expected_builtins() {
|
||||
let reg = BuiltinAssistantRegistry::load_embedded();
|
||||
assert!(!reg.is_empty(), "embedded registry should contain the shipped presets");
|
||||
// Sanity-check a couple of known ids from the committed manifest.
|
||||
assert!(reg.has("word-creator"));
|
||||
assert!(reg.has("cowork"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_embedded_tags_has_both_dimensions() {
|
||||
let reg = BuiltinAssistantRegistry::load_embedded();
|
||||
assert!(reg.tags().iter().any(|t| t.dimension == "audience"));
|
||||
assert!(reg.tags().iter().any(|t| t.dimension == "scenario"));
|
||||
assert!(reg.tags().iter().any(|t| t.key == "office"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_builtins_carry_tags() {
|
||||
let reg = BuiltinAssistantRegistry::load_embedded();
|
||||
let w = reg.get("word-creator").expect("word-creator present");
|
||||
assert!(!w.audience_tags.is_empty());
|
||||
assert!(!w.scenario_tags.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_embedded_rule_bytes_available_for_shipped_preset() {
|
||||
let reg = BuiltinAssistantRegistry::load_embedded();
|
||||
let bytes = reg
|
||||
.rule_bytes("word-creator", "en-US")
|
||||
.expect("shipped word-creator en-US rule should resolve from the embedded bundle");
|
||||
assert!(!bytes.is_empty());
|
||||
let text = std::str::from_utf8(&bytes).expect("rule file should be valid utf-8");
|
||||
assert!(text.len() > 100, "rule file should have real content");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_embedded_skill_bytes_available_for_cowork() {
|
||||
// cowork is one of the three presets that ships a skill_file too.
|
||||
let reg = BuiltinAssistantRegistry::load_embedded();
|
||||
let bytes = reg
|
||||
.skill_bytes("cowork", "en-US")
|
||||
.expect("cowork en-US skill should resolve from the embedded bundle");
|
||||
assert!(!bytes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_rule_missing_locale_returns_none() {
|
||||
let reg = BuiltinAssistantRegistry::load_embedded();
|
||||
// The manifest declares rule_file as "rules/{id}.{locale}.md"; a
|
||||
// made-up locale can't resolve.
|
||||
assert!(reg.rule_bytes("word-creator", "xx-YY").is_none());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Disk-source fallback (used by E2E fixtures via
|
||||
// NOMIFUN_BUILTIN_ASSISTANTS_PATH). Graceful-degradation semantics must
|
||||
// stay intact.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn load_from_dir_missing_dir_returns_empty() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let missing = tmp.path().join("nope");
|
||||
let reg = BuiltinAssistantRegistry::load_from_dir(missing);
|
||||
assert!(reg.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_from_dir_missing_manifest_returns_empty() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let reg = BuiltinAssistantRegistry::load_from_dir(tmp.path().to_path_buf());
|
||||
assert!(reg.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_from_dir_malformed_manifest_returns_empty() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
write_manifest(tmp.path(), "{not valid json");
|
||||
let reg = BuiltinAssistantRegistry::load_from_dir(tmp.path().to_path_buf());
|
||||
assert!(reg.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_from_dir_reads_bytes_from_disk() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let rules_dir = tmp.path().join("rules");
|
||||
std::fs::create_dir_all(&rules_dir).unwrap();
|
||||
std::fs::write(rules_dir.join("office.en-US.md"), "office rule body").unwrap();
|
||||
write_manifest(
|
||||
tmp.path(),
|
||||
r#"{
|
||||
"version": "1.0.0",
|
||||
"assistants": [{
|
||||
"id": "builtin-office",
|
||||
"name": "Office",
|
||||
"preset_agent_type": "gemini",
|
||||
"rule_file": "rules/office.{locale}.md"
|
||||
}]
|
||||
}"#,
|
||||
);
|
||||
|
||||
let reg = BuiltinAssistantRegistry::load_from_dir(tmp.path().to_path_buf());
|
||||
assert_eq!(reg.len(), 1);
|
||||
assert!(reg.has("builtin-office"));
|
||||
|
||||
let bytes = reg
|
||||
.rule_bytes("builtin-office", "en-US")
|
||||
.expect("disk-source rule_bytes should read the fixture");
|
||||
assert_eq!(bytes, b"office rule body");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_from_dir_missing_asset_returns_none() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
write_manifest(
|
||||
tmp.path(),
|
||||
r#"{
|
||||
"assistants": [{
|
||||
"id": "x",
|
||||
"name": "X",
|
||||
"preset_agent_type": "gemini",
|
||||
"rule_file": "rules/x.{locale}.md"
|
||||
}]
|
||||
}"#,
|
||||
);
|
||||
let reg = BuiltinAssistantRegistry::load_from_dir(tmp.path().to_path_buf());
|
||||
assert!(reg.rule_bytes("x", "en-US").is_none());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// load() env-var routing
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn load_respects_env_var_disk_override() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
write_manifest(
|
||||
tmp.path(),
|
||||
r#"{"assistants":[{"id":"env-only","name":"E","preset_agent_type":"gemini"}]}"#,
|
||||
);
|
||||
// SAFETY: env-var mutation is only unsafe if another thread reads
|
||||
// environment concurrently. This test is self-contained.
|
||||
// SAFETY: cargo test runs tests in parallel by default, so guard
|
||||
// against interference from other tests by using a unique env-var
|
||||
// value and checking via a dedicated loader call.
|
||||
let key = "NOMIFUN_BUILTIN_ASSISTANTS_PATH";
|
||||
let prev = std::env::var(key).ok();
|
||||
// SAFETY: set_var is sound when no other thread is concurrently
|
||||
// reading env. Tests within this module do not share mutation, and
|
||||
// the env key is not observed by other tests.
|
||||
unsafe {
|
||||
std::env::set_var(key, tmp.path());
|
||||
}
|
||||
let reg = BuiltinAssistantRegistry::load();
|
||||
assert!(reg.has("env-only"));
|
||||
assert!(!reg.has("word-creator"));
|
||||
match prev {
|
||||
Some(v) => unsafe { std::env::set_var(key, v) },
|
||||
None => unsafe { std::env::remove_var(key) },
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Avatar asset — emoji vs file
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn avatar_asset_is_none_for_emoji_avatar() {
|
||||
let reg = BuiltinAssistantRegistry::load_embedded();
|
||||
// word-creator ships with avatar: "📝" in the manifest.
|
||||
assert!(reg.avatar_asset("word-creator").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn avatar_asset_returns_bytes_and_extension_for_file_avatar() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("duck.svg"), b"<svg/>").unwrap();
|
||||
write_manifest(
|
||||
tmp.path(),
|
||||
r#"{"assistants":[{
|
||||
"id": "with-file-avatar",
|
||||
"name": "F",
|
||||
"preset_agent_type": "gemini",
|
||||
"avatar": "duck.svg"
|
||||
}]}"#,
|
||||
);
|
||||
let reg = BuiltinAssistantRegistry::load_from_dir(tmp.path().to_path_buf());
|
||||
let asset = reg.avatar_asset("with-file-avatar").unwrap();
|
||||
assert_eq!(asset.bytes, b"<svg/>");
|
||||
assert_eq!(asset.extension.as_deref(), Some("svg"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//! User-authored assistant management.
|
||||
//!
|
||||
//! Owns the `assistants` and `assistant_overrides` tables, built-in
|
||||
//! assistant loading from on-disk manifest, and merge logic for
|
||||
//! `GET /api/assistants` across builtin + user + extension sources.
|
||||
|
||||
pub mod builtin;
|
||||
pub mod routes;
|
||||
pub mod service;
|
||||
pub mod state;
|
||||
|
||||
pub use builtin::{AvatarAsset, BuiltinAssistant, BuiltinAssistantRegistry};
|
||||
pub use routes::{AssistantRouterState, assistant_routes};
|
||||
pub use service::AssistantService;
|
||||
@@ -0,0 +1,152 @@
|
||||
//! HTTP route handlers for `/api/assistants/*`.
|
||||
|
||||
use axum::Router;
|
||||
use axum::body::Body;
|
||||
use axum::extract::rejection::JsonRejection;
|
||||
use axum::extract::{Json, Path, State};
|
||||
use axum::http::{HeaderValue, StatusCode, header};
|
||||
use axum::response::Response;
|
||||
use axum::routing::{get, patch, post};
|
||||
|
||||
use nomifun_api_types::{
|
||||
ApiResponse, AssistantResponse, CreateAssistantRequest, ImportAssistantsRequest, ImportAssistantsResult,
|
||||
SetAssistantStateRequest, UpdateAssistantRequest,
|
||||
};
|
||||
use nomifun_common::AppError;
|
||||
|
||||
pub use crate::state::AssistantRouterState;
|
||||
|
||||
/// Build the router for `/api/assistants/*`.
|
||||
pub fn assistant_routes(state: AssistantRouterState) -> Router {
|
||||
Router::new()
|
||||
.route("/api/assistants", get(list).post(create))
|
||||
.route("/api/assistants/{id}", axum::routing::put(update).delete(delete_one))
|
||||
.route("/api/assistants/{id}/state", patch(set_state))
|
||||
.route("/api/assistants/{id}/avatar", get(get_avatar))
|
||||
.route("/api/assistants/import", post(import))
|
||||
.route("/api/assistant-tags", get(list_tags).post(create_tag))
|
||||
.route(
|
||||
"/api/assistant-tags/{key}",
|
||||
axum::routing::put(update_tag).delete(delete_tag),
|
||||
)
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
async fn list(
|
||||
State(state): State<AssistantRouterState>,
|
||||
) -> Result<Json<ApiResponse<Vec<AssistantResponse>>>, AppError> {
|
||||
let items = state.service.list().await?;
|
||||
Ok(Json(ApiResponse::ok(items)))
|
||||
}
|
||||
|
||||
async fn create(
|
||||
State(state): State<AssistantRouterState>,
|
||||
body: Result<Json<CreateAssistantRequest>, JsonRejection>,
|
||||
) -> Result<(StatusCode, Json<ApiResponse<AssistantResponse>>), AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
let created = state.service.create(req).await?;
|
||||
Ok((StatusCode::CREATED, Json(ApiResponse::ok(created))))
|
||||
}
|
||||
|
||||
async fn update(
|
||||
State(state): State<AssistantRouterState>,
|
||||
Path(id): Path<String>,
|
||||
body: Result<Json<UpdateAssistantRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<AssistantResponse>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
let updated = state.service.update(&id, req).await?;
|
||||
Ok(Json(ApiResponse::ok(updated)))
|
||||
}
|
||||
|
||||
async fn delete_one(
|
||||
State(state): State<AssistantRouterState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError> {
|
||||
state.service.delete(&id).await?;
|
||||
Ok(Json(ApiResponse::success()))
|
||||
}
|
||||
|
||||
async fn set_state(
|
||||
State(state): State<AssistantRouterState>,
|
||||
Path(id): Path<String>,
|
||||
body: Result<Json<SetAssistantStateRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<AssistantResponse>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
let resp = state.service.set_state(&id, req).await?;
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
}
|
||||
|
||||
async fn import(
|
||||
State(state): State<AssistantRouterState>,
|
||||
body: Result<Json<ImportAssistantsRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<ImportAssistantsResult>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
let result = state.service.import(req).await?;
|
||||
Ok(Json(ApiResponse::ok(result)))
|
||||
}
|
||||
|
||||
/// Serve the raw avatar bytes for an assistant. Content-Type inferred from the
|
||||
/// file extension (png/jpg/svg default). Extensions return 404 — the frontend
|
||||
/// serves those via `nomi-asset://`.
|
||||
async fn get_avatar(State(state): State<AssistantRouterState>, Path(id): Path<String>) -> Result<Response, AppError> {
|
||||
let asset = state
|
||||
.service
|
||||
.avatar_asset(&id)
|
||||
.await
|
||||
.ok_or_else(|| AppError::NotFound(format!("avatar '{id}' not found")))?;
|
||||
|
||||
let content_type = content_type_for_extension(asset.extension.as_deref());
|
||||
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, content_type)
|
||||
.body(Body::from(asset.bytes))
|
||||
.map_err(|e| AppError::Internal(e.to_string()))
|
||||
}
|
||||
|
||||
fn content_type_for_extension(ext: Option<&str>) -> HeaderValue {
|
||||
let mime = match ext {
|
||||
Some("svg") => "image/svg+xml",
|
||||
Some("png") => "image/png",
|
||||
Some("jpg") | Some("jpeg") => "image/jpeg",
|
||||
Some("gif") => "image/gif",
|
||||
Some("webp") => "image/webp",
|
||||
_ => "application/octet-stream",
|
||||
};
|
||||
HeaderValue::from_static(mime)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Assistant tags
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn list_tags(
|
||||
State(state): State<AssistantRouterState>,
|
||||
) -> Result<Json<ApiResponse<Vec<nomifun_api_types::AssistantTagResponse>>>, AppError> {
|
||||
Ok(Json(ApiResponse::ok(state.service.list_tags().await?)))
|
||||
}
|
||||
|
||||
async fn create_tag(
|
||||
State(state): State<AssistantRouterState>,
|
||||
body: Result<Json<nomifun_api_types::CreateAssistantTagRequest>, JsonRejection>,
|
||||
) -> Result<(StatusCode, Json<ApiResponse<nomifun_api_types::AssistantTagResponse>>), AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
Ok((StatusCode::CREATED, Json(ApiResponse::ok(state.service.create_tag(req).await?))))
|
||||
}
|
||||
|
||||
async fn update_tag(
|
||||
State(state): State<AssistantRouterState>,
|
||||
Path(key): Path<String>,
|
||||
body: Result<Json<nomifun_api_types::UpdateAssistantTagRequest>, JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<nomifun_api_types::AssistantTagResponse>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
Ok(Json(ApiResponse::ok(state.service.update_tag(&key, req).await?)))
|
||||
}
|
||||
|
||||
async fn delete_tag(
|
||||
State(state): State<AssistantRouterState>,
|
||||
Path(key): Path<String>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError> {
|
||||
state.service.delete_tag(&key).await?;
|
||||
Ok(Json(ApiResponse::success()))
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
//! Router state carrying the assistant service for axum handlers.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::service::AssistantService;
|
||||
|
||||
/// Shared state injected into `/api/assistants/*` handlers.
|
||||
#[derive(Clone)]
|
||||
pub struct AssistantRouterState {
|
||||
pub service: Arc<AssistantService>,
|
||||
}
|
||||
Reference in New Issue
Block a user