Update: 将子项目从 submodule 转为完整内容

- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
This commit is contained in:
freedak
2026-07-04 19:20:46 +08:00
parent 54d6465fa7
commit f7a720204a
3360 changed files with 802660 additions and 3 deletions
@@ -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>,
}