feat(ai): 对接本地 Ollama qwen3:4b 模型
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

- default_provider 从 claude 切换到 ollama
- main.rs 支持 ollama/openai/claude 三种 provider 动态选择
- 新增 [ai.providers.ollama] 配置段(base_url/model/temperature)
- 前端 SSE AI 分析全链路验证通过
This commit is contained in:
iven
2026-05-05 19:12:55 +08:00
parent 7dac749eff
commit 8a0c9670e6
3 changed files with 47 additions and 10 deletions

2
Cargo.lock generated
View File

@@ -1411,10 +1411,12 @@ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
"chrono", "chrono",
"dashmap",
"erp-core", "erp-core",
"futures", "futures",
"handlebars", "handlebars",
"hex", "hex",
"redis",
"reqwest", "reqwest",
"sea-orm", "sea-orm",
"serde", "serde",

View File

@@ -40,15 +40,23 @@ hmac_key = "__MUST_SET_VIA_ENV__"
kek = "__MUST_SET_VIA_ENV__" kek = "__MUST_SET_VIA_ENV__"
[ai] [ai]
default_provider = "claude" default_provider = "ollama"
# AI API 密钥。留空则禁用 AI 功能;生产环境必须通过 ERP__AI__API_KEY 设置。 # AI API 密钥。留空则禁用 AI 功能;生产环境必须通过 ERP__AI__API_KEY 设置。
api_key = "" api_key = ""
model = "claude-sonnet-4-6" model = "qwen3:4b"
max_tokens = 2048 max_tokens = 2048
temperature = 0.3 temperature = 0.3
cache_ttl_seconds = 604800 cache_ttl_seconds = 604800
rate_limit_patient_daily = 10 rate_limit_patient_daily = 10
[ai.providers.ollama]
provider_type = "ollama"
base_url = "http://localhost:11434"
default_model = "qwen3:4b"
max_tokens = 2048
temperature = 0.3
is_enabled = true
[storage] [storage]
upload_dir = "./uploads" upload_dir = "./uploads"
max_file_size = "10MB" max_file_size = "10MB"

View File

@@ -511,16 +511,43 @@ async fn main() -> anyhow::Result<()> {
tracing::info!(providers = ?registry.provider_names(), "AI Provider 注册完成"); tracing::info!(providers = ?registry.provider_names(), "AI Provider 注册完成");
// 构建默认 provider 用于 AnalysisService(保持 Box<dyn AiProvider> 签名) // 根据 default_provider 配置构建 AnalysisService 的默认 provider
let mut default_claude = erp_ai::provider::claude::ClaudeProvider::new( let default_provider: Box<dyn erp_ai::provider::AiProvider> =
match config.ai.default_provider.as_str() {
"ollama" => {
let pcfg = config.ai.providers.get("ollama");
let base_url = pcfg.and_then(|c| c.base_url.clone())
.unwrap_or_else(|| "http://localhost:11434".to_string());
let model = pcfg.map(|c| c.default_model.clone())
.unwrap_or_else(|| config.ai.model.clone());
tracing::info!(base_url = %base_url, model = %model, "AnalysisService 使用 Ollama 提供商");
Box::new(erp_ai::provider::ollama::OllamaProvider::new(base_url, model))
}
"openai" => {
let pcfg = config.ai.providers.get("openai");
let api_key = pcfg.and_then(|c| c.api_key_env.as_ref())
.and_then(|env| std::env::var(env).ok())
.unwrap_or_default();
let base_url = pcfg.and_then(|c| c.base_url.clone())
.unwrap_or_else(|| "https://api.openai.com".to_string());
let model = pcfg.map(|c| c.default_model.clone())
.unwrap_or_else(|| config.ai.model.clone());
Box::new(erp_ai::provider::openai::OpenAIProvider::new(api_key, base_url, model))
}
_ => {
// 默认 Claude
let mut claude = erp_ai::provider::claude::ClaudeProvider::new(
config.ai.api_key.clone(), config.ai.api_key.clone(),
); );
if let Some(ref base_url) = config.ai.base_url { if let Some(ref base_url) = config.ai.base_url {
default_claude = default_claude.with_base_url(base_url.clone()); claude = claude.with_base_url(base_url.clone());
} }
Box::new(claude)
}
};
let analysis_svc = erp_ai::service::analysis::AnalysisService::new( let analysis_svc = erp_ai::service::analysis::AnalysisService::new(
Box::new(default_claude), default_provider,
db.clone(), db.clone(),
).with_knowledge_source(std::sync::Arc::new( ).with_knowledge_source(std::sync::Arc::new(
erp_ai::knowledge::structured_source::StructuredKnowledgeSource::new(db.clone()), erp_ai::knowledge::structured_source::StructuredKnowledgeSource::new(db.clone()),