Add global knowledge base with RAG search
- KB module: fastembed (AllMiniLML6V2) for CPU embedding, SQLite for vector storage with brute-force cosine similarity search - Chunking by ## headings, embeddings stored as BLOB in kb_chunks table - API: GET/PUT /api/kb for full-text read/write with auto re-indexing - Agent tools: kb_search (top-5 semantic search) and kb_read (full text) available in both planning and execution phases - Frontend: Settings menu in sidebar footer, KB editor as independent view with markdown textarea and save button - Also: extract shared db_err/ApiResult to api/mod.rs, add context management design doc Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
80
src/agent.rs
80
src/agent.rs
@@ -87,10 +87,11 @@ pub struct AgentManager {
|
||||
next_port: AtomicU16,
|
||||
pool: SqlitePool,
|
||||
llm_config: LlmConfig,
|
||||
kb: Option<Arc<crate::kb::KbManager>>,
|
||||
}
|
||||
|
||||
impl AgentManager {
|
||||
pub fn new(pool: SqlitePool, llm_config: LlmConfig) -> Arc<Self> {
|
||||
pub fn new(pool: SqlitePool, llm_config: LlmConfig, kb: Option<Arc<crate::kb::KbManager>>) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
agents: RwLock::new(HashMap::new()),
|
||||
broadcast: RwLock::new(HashMap::new()),
|
||||
@@ -98,6 +99,7 @@ impl AgentManager {
|
||||
next_port: AtomicU16::new(9100),
|
||||
pool,
|
||||
llm_config,
|
||||
kb,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -146,6 +148,14 @@ impl AgentManager {
|
||||
}
|
||||
}
|
||||
|
||||
async fn ensure_venv(exec: &LocalExecutor, workdir: &str) {
|
||||
let _ = tokio::fs::create_dir_all(workdir).await;
|
||||
let venv_path = format!("{}/.venv", workdir);
|
||||
if !std::path::Path::new(&venv_path).exists() {
|
||||
let _ = exec.execute("uv venv .venv", workdir).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn agent_loop(
|
||||
project_id: String,
|
||||
mut rx: mpsc::Receiver<AgentEvent>,
|
||||
@@ -196,11 +206,7 @@ async fn agent_loop(
|
||||
.await;
|
||||
|
||||
// Ensure workspace and venv exist
|
||||
let _ = tokio::fs::create_dir_all(&workdir).await;
|
||||
let venv_path = format!("{}/.venv", workdir);
|
||||
if !std::path::Path::new(&venv_path).exists() {
|
||||
let _ = exec.execute("uv venv .venv", &workdir).await;
|
||||
}
|
||||
ensure_venv(&exec, &workdir).await;
|
||||
let _ = tokio::fs::write(format!("{}/requirement.md", workdir), &requirement).await;
|
||||
|
||||
tracing::info!("Starting agent loop for workflow {}", workflow_id);
|
||||
@@ -264,11 +270,7 @@ async fn agent_loop(
|
||||
let Some(wf) = wf else { continue };
|
||||
|
||||
// Ensure venv exists for comment re-runs too
|
||||
let _ = tokio::fs::create_dir_all(&workdir).await;
|
||||
let venv_path = format!("{}/.venv", workdir);
|
||||
if !std::path::Path::new(&venv_path).exists() {
|
||||
let _ = exec.execute("uv venv .venv", &workdir).await;
|
||||
}
|
||||
ensure_venv(&exec, &workdir).await;
|
||||
|
||||
// Clear old plan steps (keep log entries for history)
|
||||
let _ = sqlx::query("DELETE FROM plan_steps WHERE workflow_id = ? AND kind = 'plan'")
|
||||
@@ -375,6 +377,23 @@ fn tool_list_files() -> Tool {
|
||||
}))
|
||||
}
|
||||
|
||||
fn tool_kb_search() -> Tool {
|
||||
make_tool("kb_search", "搜索知识库中与查询相关的内容片段。返回最相关的 top-5 片段。", serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": { "type": "string", "description": "搜索查询" }
|
||||
},
|
||||
"required": ["query"]
|
||||
}))
|
||||
}
|
||||
|
||||
fn tool_kb_read() -> Tool {
|
||||
make_tool("kb_read", "读取知识库全文内容。", serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}))
|
||||
}
|
||||
|
||||
fn build_planning_tools() -> Vec<Tool> {
|
||||
vec![
|
||||
make_tool("update_plan", "设置高层执行计划。分析需求后调用此工具提交计划。每个步骤应是一个逻辑阶段(不是具体命令),包含简短标题和详细描述。调用后自动进入执行阶段。", serde_json::json!({
|
||||
@@ -397,6 +416,8 @@ fn build_planning_tools() -> Vec<Tool> {
|
||||
})),
|
||||
tool_list_files(),
|
||||
tool_read_file(),
|
||||
tool_kb_search(),
|
||||
tool_kb_read(),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -451,6 +472,8 @@ fn build_execution_tools() -> Vec<Tool> {
|
||||
},
|
||||
"required": ["content"]
|
||||
})),
|
||||
tool_kb_search(),
|
||||
tool_kb_read(),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -481,6 +504,7 @@ fn build_planning_prompt(project_id: &str) -> String {
|
||||
- 因此前端 HTML 中的所有 API 请求必须使用【不带开头 / 的相对路径】\n\
|
||||
- 正确示例:fetch('todos') 或 fetch('./todos') 错误示例:fetch('/todos') 或 fetch('/api/todos')\n\
|
||||
- HTML 中的 <base> 标签不需要设置,只要不用绝对路径就行\n\
|
||||
- 知识库工具:kb_search(query) 搜索相关片段,kb_read() 读取全文\n\
|
||||
\n\
|
||||
请使用中文回复。",
|
||||
project_id,
|
||||
@@ -511,6 +535,7 @@ fn build_execution_prompt(project_id: &str) -> String {
|
||||
- 静态文件访问:/api/projects/{0}/files/{{filename}}\n\
|
||||
- 后台服务访问:/api/projects/{0}/app/(启动命令需监听 0.0.0.0:$PORT)\n\
|
||||
- 【重要】应用通过反向代理访问,前端 HTML/JS 中的 fetch/XHR 请求必须使用相对路径(如 fetch('todos')),绝对不能用 / 开头的路径(如 fetch('/todos')),否则会 404\n\
|
||||
- 知识库工具:kb_search(query) 搜索相关片段,kb_read() 读取全文\n\
|
||||
\n\
|
||||
请使用中文回复。",
|
||||
project_id,
|
||||
@@ -951,6 +976,37 @@ async fn run_agent_loop(
|
||||
}
|
||||
}
|
||||
|
||||
"kb_search" => {
|
||||
let query = args["query"].as_str().unwrap_or("");
|
||||
let result = if let Some(kb) = &mgr.kb {
|
||||
match kb.search(query).await {
|
||||
Ok(results) if results.is_empty() => "知识库为空或没有匹配结果。".to_string(),
|
||||
Ok(results) => {
|
||||
results.iter().enumerate().map(|(i, r)| {
|
||||
format!("--- 片段 {} (相似度: {:.2}) ---\n{}", i + 1, r.score, r.content)
|
||||
}).collect::<Vec<_>>().join("\n\n")
|
||||
}
|
||||
Err(e) => format!("Error: {}", e),
|
||||
}
|
||||
} else {
|
||||
"知识库未初始化。".to_string()
|
||||
};
|
||||
state.step_messages.push(ChatMessage::tool_result(&tc.id, &result));
|
||||
}
|
||||
|
||||
"kb_read" => {
|
||||
let result: String = match sqlx::query_scalar::<_, String>("SELECT content FROM kb_content WHERE id = 1")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
{
|
||||
Ok(content) => {
|
||||
if content.is_empty() { "知识库为空。".to_string() } else { content }
|
||||
}
|
||||
Err(e) => format!("Error: {}", e),
|
||||
};
|
||||
state.step_messages.push(ChatMessage::tool_result(&tc.id, &result));
|
||||
}
|
||||
|
||||
// IO tools: execute, read_file, write_file, list_files
|
||||
_ => {
|
||||
let current_plan_step_id = match &state.phase {
|
||||
@@ -968,6 +1024,8 @@ async fn run_agent_loop(
|
||||
"read_file" => format!("Read: {}", args["path"].as_str().unwrap_or("?")),
|
||||
"write_file" => format!("Write: {}", args["path"].as_str().unwrap_or("?")),
|
||||
"list_files" => format!("List: {}", args["path"].as_str().unwrap_or(".")),
|
||||
"kb_search" => format!("KB Search: {}", args["query"].as_str().unwrap_or("?")),
|
||||
"kb_read" => "KB Read".to_string(),
|
||||
other => other.to_string(),
|
||||
};
|
||||
|
||||
|
||||
53
src/api/kb.rs
Normal file
53
src/api/kb.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
use std::sync::Arc;
|
||||
use axum::{
|
||||
extract::State,
|
||||
routing::get,
|
||||
Json, Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::AppState;
|
||||
use super::{ApiResult, db_err};
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct KbContent {
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
pub fn router(state: Arc<AppState>) -> Router {
|
||||
Router::new()
|
||||
.route("/kb", get(get_kb).put(put_kb))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
async fn get_kb(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> ApiResult<KbContent> {
|
||||
let content: String = sqlx::query_scalar("SELECT content FROM kb_content WHERE id = 1")
|
||||
.fetch_one(&state.db.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
Ok(Json(KbContent { content }))
|
||||
}
|
||||
|
||||
async fn put_kb(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(input): Json<KbContent>,
|
||||
) -> ApiResult<KbContent> {
|
||||
sqlx::query("UPDATE kb_content SET content = ?, updated_at = datetime('now') WHERE id = 1")
|
||||
.bind(&input.content)
|
||||
.execute(&state.db.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
// Re-index
|
||||
if let Some(kb) = &state.kb {
|
||||
if let Err(e) = kb.index(&input.content).await {
|
||||
tracing::error!("KB indexing failed: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(KbContent {
|
||||
content: input.content,
|
||||
}))
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
mod kb;
|
||||
mod projects;
|
||||
mod timers;
|
||||
mod workflows;
|
||||
@@ -9,16 +10,24 @@ use axum::{
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
routing::{get, any},
|
||||
Router,
|
||||
Json, Router,
|
||||
};
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
pub(crate) type ApiResult<T> = Result<Json<T>, Response>;
|
||||
|
||||
pub(crate) fn db_err(e: sqlx::Error) -> Response {
|
||||
tracing::error!("Database error: {}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response()
|
||||
}
|
||||
|
||||
pub fn router(state: Arc<AppState>) -> Router {
|
||||
Router::new()
|
||||
.merge(projects::router(state.clone()))
|
||||
.merge(workflows::router(state.clone()))
|
||||
.merge(timers::router(state.clone()))
|
||||
.merge(kb::router(state.clone()))
|
||||
.route("/projects/{id}/files/{*path}", get(serve_project_file))
|
||||
.route("/projects/{id}/app/{*path}", any(proxy_to_service).with_state(state.clone()))
|
||||
.route("/projects/{id}/app/", any(proxy_to_service_root).with_state(state))
|
||||
|
||||
@@ -1,21 +1,13 @@
|
||||
use std::sync::Arc;
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
routing::get,
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use crate::AppState;
|
||||
use crate::db::Project;
|
||||
|
||||
type ApiResult<T> = Result<Json<T>, Response>;
|
||||
|
||||
fn db_err(e: sqlx::Error) -> Response {
|
||||
tracing::error!("Database error: {}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response()
|
||||
}
|
||||
use super::{ApiResult, db_err};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateProject {
|
||||
|
||||
@@ -9,13 +9,7 @@ use axum::{
|
||||
use serde::Deserialize;
|
||||
use crate::AppState;
|
||||
use crate::db::Timer;
|
||||
|
||||
type ApiResult<T> = Result<Json<T>, Response>;
|
||||
|
||||
fn db_err(e: sqlx::Error) -> Response {
|
||||
tracing::error!("Database error: {}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response()
|
||||
}
|
||||
use super::{ApiResult, db_err};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateTimer {
|
||||
|
||||
@@ -10,19 +10,13 @@ use serde::Deserialize;
|
||||
use crate::AppState;
|
||||
use crate::agent::AgentEvent;
|
||||
use crate::db::{Workflow, PlanStep, Comment};
|
||||
use super::{ApiResult, db_err};
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct ReportResponse {
|
||||
report: String,
|
||||
}
|
||||
|
||||
type ApiResult<T> = Result<Json<T>, Response>;
|
||||
|
||||
fn db_err(e: sqlx::Error) -> Response {
|
||||
tracing::error!("Database error: {}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateWorkflow {
|
||||
pub requirement: String,
|
||||
|
||||
29
src/db.rs
29
src/db.rs
@@ -101,6 +101,35 @@ impl Database {
|
||||
.execute(&self.pool)
|
||||
.await;
|
||||
|
||||
// KB tables
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS kb_content (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)"
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
// Insert default row if not exists
|
||||
let _ = sqlx::query(
|
||||
"INSERT OR IGNORE INTO kb_content (id, content) VALUES (1, '')"
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS kb_chunks (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
content TEXT NOT NULL,
|
||||
embedding BLOB NOT NULL
|
||||
)"
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS timers (
|
||||
id TEXT PRIMARY KEY,
|
||||
|
||||
167
src/kb.rs
Normal file
167
src/kb.rs
Normal file
@@ -0,0 +1,167 @@
|
||||
use anyhow::Result;
|
||||
use sqlx::sqlite::SqlitePool;
|
||||
use std::sync::Mutex;
|
||||
|
||||
const TOP_K: usize = 5;
|
||||
|
||||
pub struct KbManager {
|
||||
embedder: Mutex<fastembed::TextEmbedding>,
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SearchResult {
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
pub score: f32,
|
||||
}
|
||||
|
||||
/// A chunk of KB content split by heading
|
||||
#[derive(Debug, Clone)]
|
||||
struct Chunk {
|
||||
title: String,
|
||||
content: String,
|
||||
}
|
||||
|
||||
impl KbManager {
|
||||
pub fn new(pool: SqlitePool) -> Result<Self> {
|
||||
let embedder = fastembed::TextEmbedding::try_new(
|
||||
fastembed::InitOptions::new(fastembed::EmbeddingModel::AllMiniLML6V2)
|
||||
.with_show_download_progress(true),
|
||||
)?;
|
||||
Ok(Self { embedder: Mutex::new(embedder), pool })
|
||||
}
|
||||
|
||||
/// Re-index: chunk the content, embed, store in SQLite
|
||||
pub async fn index(&self, content: &str) -> Result<()> {
|
||||
// Clear old chunks
|
||||
sqlx::query("DELETE FROM kb_chunks")
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
let chunks = split_chunks(content);
|
||||
if chunks.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let texts: Vec<String> = chunks.iter().map(|c| c.content.clone()).collect();
|
||||
let embeddings = self.embedder.lock().unwrap().embed(texts, None)?;
|
||||
|
||||
for (chunk, embedding) in chunks.iter().zip(embeddings.into_iter()) {
|
||||
let vec_bytes = embedding_to_bytes(&embedding);
|
||||
sqlx::query(
|
||||
"INSERT INTO kb_chunks (id, title, content, embedding) VALUES (?, ?, ?, ?)",
|
||||
)
|
||||
.bind(uuid::Uuid::new_v4().to_string())
|
||||
.bind(&chunk.title)
|
||||
.bind(&chunk.content)
|
||||
.bind(&vec_bytes)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tracing::info!("KB indexed: {} chunks", chunks.len());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Search KB by query, returns top-k results
|
||||
pub async fn search(&self, query: &str) -> Result<Vec<SearchResult>> {
|
||||
let query_embeddings = self.embedder.lock().unwrap().embed(vec![query.to_string()], None)?;
|
||||
let query_vec = query_embeddings
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("Failed to embed query"))?;
|
||||
|
||||
// Fetch all chunks with embeddings
|
||||
let rows: Vec<(String, String, Vec<u8>)> =
|
||||
sqlx::query_as("SELECT title, content, embedding FROM kb_chunks")
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
// Compute cosine similarity and rank
|
||||
let mut scored: Vec<(f32, String, String)> = rows
|
||||
.into_iter()
|
||||
.filter_map(|(title, content, blob)| {
|
||||
let emb = bytes_to_embedding(&blob);
|
||||
let score = cosine_similarity(&query_vec, &emb);
|
||||
Some((score, title, content))
|
||||
})
|
||||
.collect();
|
||||
|
||||
scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
|
||||
scored.truncate(TOP_K);
|
||||
|
||||
Ok(scored
|
||||
.into_iter()
|
||||
.map(|(score, title, content)| SearchResult {
|
||||
title,
|
||||
content,
|
||||
score,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
|
||||
let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
|
||||
let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
if norm_a == 0.0 || norm_b == 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
dot / (norm_a * norm_b)
|
||||
}
|
||||
|
||||
fn embedding_to_bytes(embedding: &[f32]) -> Vec<u8> {
|
||||
embedding.iter().flat_map(|f| f.to_le_bytes()).collect()
|
||||
}
|
||||
|
||||
fn bytes_to_embedding(bytes: &[u8]) -> Vec<f32> {
|
||||
bytes
|
||||
.chunks_exact(4)
|
||||
.map(|chunk| f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Split markdown content into chunks by ## headings
|
||||
fn split_chunks(content: &str) -> Vec<Chunk> {
|
||||
let mut chunks = Vec::new();
|
||||
let mut current_title = String::new();
|
||||
let mut current_lines: Vec<&str> = Vec::new();
|
||||
|
||||
for line in content.lines() {
|
||||
if line.starts_with("## ") {
|
||||
// Save previous chunk
|
||||
let text = current_lines.join("\n").trim().to_string();
|
||||
if !text.is_empty() {
|
||||
chunks.push(Chunk {
|
||||
title: current_title.clone(),
|
||||
content: if current_title.is_empty() {
|
||||
text
|
||||
} else {
|
||||
format!("## {}\n{}", current_title, text)
|
||||
},
|
||||
});
|
||||
}
|
||||
current_title = line.trim_start_matches("## ").trim().to_string();
|
||||
current_lines.clear();
|
||||
} else {
|
||||
current_lines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
// Last chunk
|
||||
let text = current_lines.join("\n").trim().to_string();
|
||||
if !text.is_empty() {
|
||||
chunks.push(Chunk {
|
||||
title: current_title.clone(),
|
||||
content: if current_title.is_empty() {
|
||||
text
|
||||
} else {
|
||||
format!("## {}\n{}", current_title, text)
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
chunks
|
||||
}
|
||||
16
src/main.rs
16
src/main.rs
@@ -1,6 +1,7 @@
|
||||
mod api;
|
||||
mod agent;
|
||||
mod db;
|
||||
mod kb;
|
||||
mod llm;
|
||||
mod exec;
|
||||
mod timer;
|
||||
@@ -15,6 +16,7 @@ pub struct AppState {
|
||||
pub db: db::Database,
|
||||
pub config: Config,
|
||||
pub agent_mgr: Arc<agent::AgentManager>,
|
||||
pub kb: Option<Arc<kb::KbManager>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
@@ -56,9 +58,22 @@ async fn main() -> anyhow::Result<()> {
|
||||
let database = db::Database::new(&config.database.path).await?;
|
||||
database.migrate().await?;
|
||||
|
||||
// Initialize KB manager
|
||||
let kb_arc = match kb::KbManager::new(database.pool.clone()) {
|
||||
Ok(kb) => {
|
||||
tracing::info!("KB manager initialized");
|
||||
Some(Arc::new(kb))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("KB manager init failed (will retry on use): {}", e);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let agent_mgr = agent::AgentManager::new(
|
||||
database.pool.clone(),
|
||||
config.llm.clone(),
|
||||
kb_arc.clone(),
|
||||
);
|
||||
|
||||
timer::start_timer_runner(database.pool.clone(), agent_mgr.clone());
|
||||
@@ -67,6 +82,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
db: database,
|
||||
config: config.clone(),
|
||||
agent_mgr: agent_mgr.clone(),
|
||||
kb: kb_arc,
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
|
||||
Reference in New Issue
Block a user