Skip to content
This repository was archived by the owner on Apr 1, 2026. It is now read-only.

Commit 41b7133

Browse files
author
FreeSynergy
committed
feat(fs-ai): complete G2.9 migration — FsView, gRPC, REST, CLI, fix clippy
1 parent c22e6e3 commit 41b7133

12 files changed

Lines changed: 283 additions & 106 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/fs-ai/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ web = ["dioxus/web"]
1919

2020
[dependencies]
2121
fs-manager-ai = { workspace = true }
22+
fs-render = { path = "../.././../fs-render" }
2223
fs-i18n = { workspace = true }
2324
dioxus = { workspace = true }
2425

crates/fs-ai/build.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,6 @@ fn main() {
33
tonic_build::configure()
44
.build_server(true)
55
.build_client(false)
6-
.compile_protos(&["proto/ai.proto"], &["proto"])
6+
.compile_protos(&["proto/ai_app.proto"], &["proto"])
77
.unwrap_or_else(|e| panic!("tonic_build failed: {e}"));
88
}

crates/fs-ai/proto/ai_app.proto

Lines changed: 34 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,54 @@
11
syntax = "proto3";
22

3-
package ai_app;
3+
package ai;
44

5-
service AiAppService {
6-
// Ask the AI a question.
7-
rpc Ask(AskRequest) returns (AskResponse);
5+
service AiService {
86
// List available AI models.
9-
rpc Models(ModelsRequest) returns (ModelsResponse);
7+
rpc ListModels(ListModelsRequest) returns (ListModelsResponse);
8+
// Get current engine status.
9+
rpc GetStatus(GetStatusRequest) returns (GetStatusResponse);
10+
// Start the LLM engine.
11+
rpc StartEngine(StartEngineRequest) returns (StartEngineResponse);
12+
// Stop the LLM engine.
13+
rpc StopEngine(StopEngineRequest) returns (StopEngineResponse);
1014
// Service liveness check.
1115
rpc Health(HealthRequest) returns (HealthResponse);
1216
}
1317

14-
message AskRequest {
15-
string prompt = 1;
16-
}
17-
18-
message AskResponse {
19-
string answer = 1;
20-
}
21-
22-
message ModelsRequest {}
18+
message ListModelsRequest {}
2319

2420
message ModelProto {
2521
string id = 1;
2622
string name = 2;
2723
}
2824

29-
message ModelsResponse {
25+
message ListModelsResponse {
3026
repeated ModelProto models = 1;
3127
}
3228

29+
message GetStatusRequest {}
30+
31+
message GetStatusResponse {
32+
bool running = 1;
33+
uint32 port = 2;
34+
string api_url = 3;
35+
}
36+
37+
message StartEngineRequest {
38+
string model_id = 1;
39+
}
40+
41+
message StartEngineResponse {
42+
bool ok = 1;
43+
string error = 2;
44+
}
45+
46+
message StopEngineRequest {}
47+
48+
message StopEngineResponse {
49+
bool ok = 1;
50+
}
51+
3352
message HealthRequest {}
3453

3554
message HealthResponse {

crates/fs-ai/src/cli.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ use clap::{Parser, Subcommand};
44

55
/// `FreeSynergy` AI assistant — manage the local LLM engine.
66
#[derive(Parser)]
7-
#[command(name = "fs-ai", version, about = "FreeSynergy AI assistant daemon and CLI")]
7+
#[command(
8+
name = "fs-ai",
9+
version,
10+
about = "FreeSynergy AI assistant daemon and CLI"
11+
)]
812
pub struct Cli {
913
#[command(subcommand)]
1014
pub command: Command,

crates/fs-ai/src/controller.rs

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,8 @@ impl AiController {
5252
);
5353
engine.start().map_err(|e| e.to_string())?;
5454

55-
let port = match engine.status() {
56-
EngineStatus::Running { port } => port,
57-
_ => return Err("engine did not start".into()),
55+
let EngineStatus::Running { port } = engine.status() else {
56+
return Err("engine did not start".into());
5857
};
5958

6059
let mut state = self.state.lock().unwrap();
@@ -66,10 +65,7 @@ impl AiController {
6665
/// Stop the LLM engine.
6766
pub fn stop(&self) -> Result<(), String> {
6867
let snapshot = self.snapshot();
69-
let model_id = snapshot
70-
.active_model
71-
.as_deref()
72-
.ok_or("no active model")?;
68+
let model_id = snapshot.active_model.as_deref().ok_or("no active model")?;
7369

7470
let llm_model = Self::model_from_id(model_id)?;
7571
let config = LlmConfig {

crates/fs-ai/src/grpc.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,10 @@ impl AiService for GrpcAiApp {
3838
.ctrl
3939
.list_models()
4040
.into_iter()
41-
.map(|m| ModelProto { id: m.id, name: m.name })
41+
.map(|m| ModelProto {
42+
id: m.id,
43+
name: m.name,
44+
})
4245
.collect();
4346
Ok(Response::new(ListModelsResponse { models }))
4447
}
@@ -50,7 +53,7 @@ impl AiService for GrpcAiApp {
5053
let snap = self.ctrl.snapshot();
5154
Ok(Response::new(GetStatusResponse {
5255
running: snap.running,
53-
port: snap.port.map(u32::from).unwrap_or(0),
56+
port: snap.port.map_or(0, u32::from),
5457
api_url: snap.api_url().unwrap_or_default(),
5558
}))
5659
}

crates/fs-ai/src/lib.rs

Lines changed: 23 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,72 +1,30 @@
1+
//! `fs-ai` — FreeSynergy AI assistant.
2+
//!
3+
//! Facade Pattern: [`AiController`] wraps `fs-manager-ai` (LLM engine management).
4+
//!
5+
//! - [`AiController`] — start/stop/status (knows only `AiEngine` trait)
6+
//! - [`AiView`] — `FsView` impl (in `view.rs`, only file importing fs-render)
7+
//! - [`GrpcAiApp`] — gRPC service
8+
//! - REST router via [`rest::router`]
9+
//! - CLI via [`cli::Cli`]
10+
111
#![deny(clippy::all, clippy::pedantic, warnings)]
212
#![allow(clippy::must_use_candidate)]
313
#![allow(clippy::missing_errors_doc)]
414
#![allow(clippy::doc_markdown)]
515
#![allow(clippy::ignored_unit_patterns)]
616
#![allow(clippy::needless_pass_by_value)]
717
#![allow(clippy::return_self_not_must_use)]
8-
#![allow(clippy::struct_excessive_bools)]
9-
pub mod app;
10-
11-
pub use app::AiManagerApp;
12-
13-
const I18N_SNIPPETS: &[(&str, &str)] = &[
14-
("en", include_str!("../assets/i18n/en.toml")),
15-
("de", include_str!("../assets/i18n/de.toml")),
16-
];
17-
18-
/// i18n plugin for fs-ai (`ai.*` keys). Pass to [`fs_i18n::init_with_plugins`].
19-
pub struct I18nPlugin;
20-
21-
impl fs_i18n::SnippetPlugin for I18nPlugin {
22-
fn name(&self) -> &'static str {
23-
"fs-ai"
24-
}
25-
fn snippets(&self) -> &[(&str, &str)] {
26-
I18N_SNIPPETS
27-
}
28-
}
29-
30-
// ── AiStatus ─────────────────────────────────────────────────────────────────
31-
32-
use fs_manager_ai::{AiEngine, LlmConfig, LlmEngine, LlmModel};
33-
34-
pub struct AiStatus;
35-
36-
impl AiStatus {
37-
fn engine() -> LlmEngine {
38-
LlmEngine::new(
39-
LlmConfig {
40-
model: LlmModel::Qwen3_4B,
41-
..LlmConfig::default()
42-
},
43-
LlmEngine::default_binary(),
44-
LlmEngine::default_data_dir(),
45-
)
46-
}
47-
48-
/// Returns `true` if the LLM engine binary is installed.
49-
pub fn is_installed() -> bool {
50-
Self::engine().is_installed()
51-
}
52-
53-
/// Returns the OpenAI-compatible API base URL if the engine is running,
54-
/// e.g. `"http://127.0.0.1:1234/v1"`.
55-
pub fn api_url() -> Option<String> {
56-
match Self::engine().status() {
57-
fs_manager_ai::EngineStatus::Running { port } => {
58-
Some(format!("http://127.0.0.1:{port}/v1"))
59-
}
60-
_ => None,
61-
}
62-
}
63-
}
64-
65-
// ── Public shims ──────────────────────────────────────────────────────────────
66-
67-
pub fn is_ai_installed() -> bool {
68-
AiStatus::is_installed()
69-
}
70-
pub fn ai_api_url() -> Option<String> {
71-
AiStatus::api_url()
72-
}
18+
#![allow(clippy::missing_panics_doc)]
19+
#![allow(clippy::needless_for_each)]
20+
21+
pub mod cli;
22+
pub mod controller;
23+
pub mod grpc;
24+
pub mod model;
25+
pub mod rest;
26+
pub mod view;
27+
28+
pub use controller::AiController;
29+
pub use model::{AiModel, KnownModel};
30+
pub use view::AiView;

crates/fs-ai/src/main.rs

Lines changed: 107 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,109 @@
1-
// fs-ai — FreeSynergy AI Assistant daemon + CLI.
2-
//
3-
// Wraps fs-manager-ai (LLM engine management) with a gRPC/REST API and CLI.
1+
//! `fs-ai` — FreeSynergy AI assistant daemon and CLI.
2+
//!
3+
//! # Environment variables
4+
//!
5+
//! | Variable | Default |
6+
//! |----------------|---------|
7+
//! | `FS_GRPC_PORT` | `50095` |
8+
//! | `FS_REST_PORT` | `8095` |
49
5-
fn main() {
6-
eprintln!("fs-ai: not yet implemented — G2.9 pending");
7-
std::process::exit(1);
10+
#![deny(clippy::all, clippy::pedantic, warnings)]
11+
#![allow(clippy::must_use_candidate)]
12+
#![allow(clippy::missing_errors_doc)]
13+
#![allow(clippy::doc_markdown)]
14+
#![allow(clippy::ignored_unit_patterns)]
15+
#![allow(clippy::needless_pass_by_value)]
16+
17+
use clap::Parser as _;
18+
use tracing_subscriber::{fmt, EnvFilter};
19+
20+
use fs_ai::{
21+
cli::{Cli, Command},
22+
controller::AiController,
23+
grpc::{AiServiceServer, GrpcAiApp},
24+
rest,
25+
};
26+
27+
#[tokio::main]
28+
async fn main() -> Result<(), Box<dyn std::error::Error>> {
29+
fmt().with_env_filter(EnvFilter::from_default_env()).init();
30+
31+
let args = Cli::parse();
32+
let ctrl = AiController::new();
33+
34+
match args.command {
35+
Command::Daemon => run_daemon(ctrl).await?,
36+
ref cmd => run_cli(cmd, &ctrl),
37+
}
38+
Ok(())
39+
}
40+
41+
async fn run_daemon(ctrl: AiController) -> Result<(), Box<dyn std::error::Error>> {
42+
let grpc_port: u16 = std::env::var("FS_GRPC_PORT")
43+
.ok()
44+
.and_then(|p| p.parse().ok())
45+
.unwrap_or(50_095);
46+
let rest_port: u16 = std::env::var("FS_REST_PORT")
47+
.ok()
48+
.and_then(|p| p.parse().ok())
49+
.unwrap_or(8_095);
50+
51+
let grpc_addr: std::net::SocketAddr = ([0, 0, 0, 0], grpc_port).into();
52+
let rest_addr: std::net::SocketAddr = ([0, 0, 0, 0], rest_port).into();
53+
54+
tracing::info!("gRPC on {grpc_addr}, REST on {rest_addr}");
55+
56+
let grpc_ctrl = ctrl.clone();
57+
let grpc_task = tokio::spawn(async move {
58+
tonic::transport::Server::builder()
59+
.add_service(AiServiceServer::new(GrpcAiApp::new(grpc_ctrl)))
60+
.serve(grpc_addr)
61+
.await
62+
.unwrap();
63+
});
64+
65+
let rest_task = tokio::spawn(async move {
66+
let listener = tokio::net::TcpListener::bind(rest_addr).await.unwrap();
67+
axum::serve(listener, rest::router(ctrl)).await.unwrap();
68+
});
69+
70+
tokio::try_join!(grpc_task, rest_task)?;
71+
Ok(())
72+
}
73+
74+
fn run_cli(cmd: &Command, ctrl: &AiController) {
75+
match cmd {
76+
Command::Daemon => unreachable!(),
77+
Command::Models => {
78+
for m in ctrl.list_models() {
79+
println!("{:30} {}", m.id, m.name);
80+
}
81+
}
82+
Command::Status => {
83+
let snap = ctrl.snapshot();
84+
if snap.running {
85+
println!(
86+
"running (port {}, API: {})",
87+
snap.port.unwrap_or(0),
88+
snap.api_url().unwrap_or_default()
89+
);
90+
} else {
91+
println!("stopped");
92+
}
93+
}
94+
Command::Start { model } => match ctrl.start(model) {
95+
Ok(port) => println!("Engine started on port {port}"),
96+
Err(e) => {
97+
eprintln!("Failed to start: {e}");
98+
std::process::exit(1);
99+
}
100+
},
101+
Command::Stop => match ctrl.stop() {
102+
Ok(()) => println!("Engine stopped"),
103+
Err(e) => {
104+
eprintln!("Failed to stop: {e}");
105+
std::process::exit(1);
106+
}
107+
},
108+
}
8109
}

crates/fs-ai/src/model.rs

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,7 @@ impl AiModel {
2626
/// OpenAI-compatible API base URL, if the engine is running.
2727
#[must_use]
2828
pub fn api_url(&self) -> Option<String> {
29-
self.port
30-
.map(|p| format!("http://127.0.0.1:{p}/v1"))
29+
self.port.map(|p| format!("http://127.0.0.1:{p}/v1"))
3130
}
3231

3332
pub fn set_running(&mut self, port: u16) {
@@ -55,9 +54,18 @@ pub struct KnownModel {
5554
impl KnownModel {
5655
pub fn all() -> Vec<Self> {
5756
vec![
58-
Self { id: "qwen3-4b".into(), name: "Qwen 3 4B".into() },
59-
Self { id: "qwen3-8b".into(), name: "Qwen 3 8B".into() },
60-
Self { id: "qwen2.5-coder-7b".into(), name: "Qwen 2.5 Coder 7B".into() },
57+
Self {
58+
id: "qwen3-4b".into(),
59+
name: "Qwen 3 4B".into(),
60+
},
61+
Self {
62+
id: "qwen3-8b".into(),
63+
name: "Qwen 3 8B".into(),
64+
},
65+
Self {
66+
id: "qwen2.5-coder-7b".into(),
67+
name: "Qwen 2.5 Coder 7B".into(),
68+
},
6169
]
6270
}
6371
}

0 commit comments

Comments
 (0)