Compare commits
8 Commits
18917b6de1
...
108dd714d3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
108dd714d3 | ||
|
|
76e3d19cf5 | ||
|
|
55d33862f6 | ||
|
|
46a0bdc21e | ||
|
|
c2c079c74d | ||
|
|
db0a2eca16 | ||
|
|
b3fb027848 | ||
|
|
2db7934596 |
@ -11,6 +11,13 @@ use super::call::ToolError;
|
||||
use super::context::ToolContext;
|
||||
use super::definition::ToolDefinition;
|
||||
|
||||
/// Error type for tool registry operations.
|
||||
#[derive(Debug, Clone, thiserror::Error)]
|
||||
pub enum ToolRegistryError {
|
||||
#[error("tool already registered: {0}")]
|
||||
AlreadyRegistered(String),
|
||||
}
|
||||
|
||||
/// Inner function pointer type for tool handlers.
|
||||
type InnerHandlerFn = dyn Fn(
|
||||
ToolContext,
|
||||
@ -75,7 +82,8 @@ impl ToolRegistry {
|
||||
pub fn register(&mut self, def: ToolDefinition, handler: ToolHandler) -> &mut Self {
|
||||
let name = def.name.clone();
|
||||
if self.handlers.contains_key(&name) {
|
||||
panic!("tool already registered: {}", name);
|
||||
tracing::warn!("tool already registered (skipping duplicate): {}", name);
|
||||
return self;
|
||||
}
|
||||
self.handlers.insert(name.clone(), handler);
|
||||
self.definitions.insert(name, def);
|
||||
@ -107,11 +115,12 @@ impl ToolRegistry {
|
||||
}
|
||||
|
||||
/// Merges another registry's tools into this one.
|
||||
/// Panics if a tool with the same name already exists.
|
||||
/// Skips tools with duplicate names and logs a warning.
|
||||
pub fn merge(&mut self, other: ToolRegistry) {
|
||||
for (name, handler) in other.handlers {
|
||||
if self.handlers.contains_key(&name) {
|
||||
panic!("tool already registered: {}", name);
|
||||
tracing::warn!("merge skipped duplicate tool: {}", name);
|
||||
continue;
|
||||
}
|
||||
self.handlers.insert(name, handler);
|
||||
}
|
||||
|
||||
@ -13,7 +13,9 @@ impl AppConfig {
|
||||
pub fn load() -> AppConfig {
|
||||
let mut env = HashMap::new();
|
||||
for env_file in AppConfig::ENV_FILES {
|
||||
dotenvy::from_path(env_file).ok();
|
||||
if let Err(e) = dotenvy::from_path(env_file) {
|
||||
eprintln!("dotenv skipped: {} ({})", env_file, e);
|
||||
}
|
||||
if let Ok(env_file_content) = std::fs::read_to_string(env_file) {
|
||||
for line in env_file_content.lines() {
|
||||
if let Some((key, value)) = line.split_once('=') {
|
||||
@ -25,13 +27,13 @@ impl AppConfig {
|
||||
// Environment variables (e.g. K8s injected APP_DOMAIN_URL) take precedence over .env files
|
||||
env = std::env::vars().chain(env).collect();
|
||||
let this = AppConfig { env };
|
||||
if let Err(config) = GLOBAL_CONFIG.set(this) {
|
||||
eprintln!("Failed to set global config: {:?}", config);
|
||||
}
|
||||
if let Some(config) = GLOBAL_CONFIG.get() {
|
||||
config.clone()
|
||||
// Handle the race condition: if another thread already set the global, return it.
|
||||
// This is safe because config is immutable after load.
|
||||
if GLOBAL_CONFIG.get().is_some() {
|
||||
GLOBAL_CONFIG.get().unwrap().clone()
|
||||
} else {
|
||||
panic!("Failed to get global config");
|
||||
let _ = GLOBAL_CONFIG.set(this);
|
||||
GLOBAL_CONFIG.get().expect("global config should be set after load").clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,11 +8,11 @@ pub mod tag;
|
||||
|
||||
use db::cache::AppCache;
|
||||
use db::database::AppDatabase;
|
||||
use models::projects::project_skill::{Column as SkillCol, Entity as SkillEntity};
|
||||
use models::projects::project_skill::ActiveModel as SkillActiveModel;
|
||||
use models::projects::project_skill::{Column as SkillCol, Entity as SkillEntity};
|
||||
use models::repos::repo::Model as RepoModel;
|
||||
use models::RepoId;
|
||||
use models::ActiveModelTrait;
|
||||
use models::RepoId;
|
||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, Set};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
@ -43,8 +43,14 @@ fn scan_skills_from_dir(
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
stack.push(path);
|
||||
} else if path.file_name().and_then(|n| n.to_str()) == Some("SKILL.md") {
|
||||
if let Some(dir_name) = path.parent()
|
||||
} else if path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|x| x.to_lowercase())
|
||||
== Some("skill.md".to_string())
|
||||
{
|
||||
if let Some(dir_name) = path
|
||||
.parent()
|
||||
.and_then(|p| p.file_name())
|
||||
.and_then(|n| n.to_str())
|
||||
.filter(|s| !s.starts_with('.'))
|
||||
@ -135,11 +141,7 @@ pub struct HookMetaDataSync {
|
||||
}
|
||||
|
||||
impl HookMetaDataSync {
|
||||
pub fn new(
|
||||
db: AppDatabase,
|
||||
cache: AppCache,
|
||||
repo: RepoModel,
|
||||
) -> Result<Self, crate::GitError> {
|
||||
pub fn new(db: AppDatabase, cache: AppCache, repo: RepoModel) -> Result<Self, crate::GitError> {
|
||||
let domain = GitDomain::from_model(repo.clone())?;
|
||||
Ok(Self {
|
||||
db,
|
||||
@ -185,18 +187,16 @@ impl HookMetaDataSync {
|
||||
|
||||
/// Full sync pipeline (no locking — caller is responsible).
|
||||
async fn sync_work(&self) -> Result<(), crate::GitError> {
|
||||
let mut txn = self
|
||||
.db
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|e| crate::GitError::IoError(format!("failed to begin transaction: {}", e)))?;
|
||||
let mut txn =
|
||||
self.db.begin().await.map_err(|e| {
|
||||
crate::GitError::IoError(format!("failed to begin transaction: {}", e))
|
||||
})?;
|
||||
|
||||
self.sync_refs(&mut txn).await?;
|
||||
self.sync_commits(&mut txn).await?;
|
||||
self.sync_tags(&mut txn).await?;
|
||||
self.sync_lfs_objects(&mut txn).await?;
|
||||
self.run_fsck_and_rollback_if_corrupt(&mut txn)
|
||||
.await?;
|
||||
self.run_fsck_and_rollback_if_corrupt(&mut txn).await?;
|
||||
|
||||
txn.commit().await.map_err(|e| {
|
||||
crate::GitError::IoError(format!("failed to commit transaction: {}", e))
|
||||
@ -210,14 +210,12 @@ impl HookMetaDataSync {
|
||||
|
||||
/// Fsck only work (no locking — caller is responsible).
|
||||
async fn fsck_work(&self) -> Result<(), crate::GitError> {
|
||||
let mut txn = self
|
||||
.db
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|e| crate::GitError::IoError(format!("failed to begin transaction: {}", e)))?;
|
||||
let mut txn =
|
||||
self.db.begin().await.map_err(|e| {
|
||||
crate::GitError::IoError(format!("failed to begin transaction: {}", e))
|
||||
})?;
|
||||
|
||||
self.run_fsck_and_rollback_if_corrupt(&mut txn)
|
||||
.await?;
|
||||
self.run_fsck_and_rollback_if_corrupt(&mut txn).await?;
|
||||
|
||||
txn.commit().await.map_err(|e| {
|
||||
crate::GitError::IoError(format!("failed to commit transaction: {}", e))
|
||||
@ -332,22 +330,40 @@ impl HookMetaDataSync {
|
||||
}
|
||||
};
|
||||
|
||||
let existing_by_slug: HashMap<_, _> = existing
|
||||
// Deduplicate by {repo_id}+{blob_hash}, keep latest by commit_sha
|
||||
let mut deduped: std::collections::HashMap<String, DiscoveredSkill> = std::collections::HashMap::new();
|
||||
for skill in discovered {
|
||||
let key = format!("{}:{}", self.repo.id, skill.blob_hash.as_ref().unwrap_or(&skill.slug));
|
||||
match deduped.get(&key) {
|
||||
Some(existing) => {
|
||||
if skill.commit_sha.as_ref().unwrap_or(&String::new()) > existing.commit_sha.as_ref().unwrap_or(&String::new()) {
|
||||
deduped.insert(key, skill);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
deduped.insert(key, skill);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let existing_by_hash: HashMap<_, _> = existing
|
||||
.into_iter()
|
||||
.map(|s| (s.slug.clone(), s))
|
||||
.map(|s| {
|
||||
let key = format!("{}:{}", s.repo_id.unwrap_or_default(), s.blob_hash.clone().unwrap_or_default());
|
||||
(key, s)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut seen_slugs = std::collections::HashSet::new();
|
||||
let mut seen_keys = std::collections::HashSet::new();
|
||||
|
||||
for skill in discovered {
|
||||
seen_slugs.insert(skill.slug.clone());
|
||||
for (key, skill) in deduped {
|
||||
seen_keys.insert(key.clone());
|
||||
let json_meta = serde_json::to_value(&skill.metadata).unwrap_or_default();
|
||||
|
||||
if let Some(existing_skill) = existing_by_slug.get(&skill.slug) {
|
||||
if let Some(existing_skill) = existing_by_hash.get(&key) {
|
||||
if existing_skill.content != skill.content
|
||||
|| existing_skill.metadata != json_meta
|
||||
|| existing_skill.commit_sha.as_ref() != skill.commit_sha.as_ref()
|
||||
|| existing_skill.blob_hash.as_ref() != skill.blob_hash.as_ref()
|
||||
|| existing_skill.commit_sha != skill.commit_sha
|
||||
{
|
||||
let mut active: SkillActiveModel = existing_skill.clone().into();
|
||||
active.content = Set(skill.content);
|
||||
@ -383,16 +399,25 @@ impl HookMetaDataSync {
|
||||
}
|
||||
}
|
||||
|
||||
for (slug, old_skill) in existing_by_slug {
|
||||
if !seen_slugs.contains(&slug) {
|
||||
if SkillEntity::delete_by_id(old_skill.id).exec(&self.db).await.is_ok() {
|
||||
for (key, old_skill) in existing_by_hash {
|
||||
if !seen_keys.contains(&key) {
|
||||
if SkillEntity::delete_by_id(old_skill.id)
|
||||
.exec(&self.db)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if created > 0 || updated > 0 || removed > 0 {
|
||||
tracing::info!("skills synced created={} updated={} removed={}", created, updated, removed);
|
||||
tracing::info!(
|
||||
"skills synced created={} updated={} removed={}",
|
||||
created,
|
||||
updated,
|
||||
removed
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -118,7 +118,9 @@ impl SSHandle {
|
||||
if let Some(mut stdin) = self.stdin.remove(&channel_id) {
|
||||
tokio::spawn(async move {
|
||||
let _ = tokio::time::timeout(Duration::from_secs(5), async {
|
||||
stdin.flush().await.ok();
|
||||
if let Err(e) = stdin.flush().await {
|
||||
tracing::warn!(error = %e, "ssh_cleanup_flush_failed channel={:?}", channel_id);
|
||||
}
|
||||
let _ = stdin.shutdown().await;
|
||||
})
|
||||
.await;
|
||||
@ -296,7 +298,9 @@ impl russh::server::Handler for SSHandle {
|
||||
tracing::info!("Closing stdin channel={:?} client={:?}", channel, self.client_addr);
|
||||
// Use timeout so we never block the SSH event loop waiting for git.
|
||||
let _ = tokio::time::timeout(Duration::from_secs(5), async {
|
||||
stdin.flush().await.ok();
|
||||
if let Err(e) = stdin.flush().await {
|
||||
tracing::warn!(error = %e, "ssh_eof_flush_failed channel={:?}", channel);
|
||||
}
|
||||
let _ = stdin.shutdown().await;
|
||||
})
|
||||
.await;
|
||||
@ -318,7 +322,9 @@ impl russh::server::Handler for SSHandle {
|
||||
.map(|addr| format!("{}", addr))
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
tracing::info!("channel_open_session channel={:?} client={}", channel, client_info);
|
||||
let _ = session.flush().ok();
|
||||
if let Err(e) = session.flush() {
|
||||
tracing::warn!(error = %e, "ssh_session_flush_failed");
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
@ -334,7 +340,9 @@ impl russh::server::Handler for SSHandle {
|
||||
session: &mut Session,
|
||||
) -> Result<(), Self::Error> {
|
||||
tracing::warn!("pty_request not supported channel={:?} term={} cols={} rows={}", channel, term, col_width, row_height);
|
||||
let _ = session.flush().ok();
|
||||
if let Err(e) = session.flush() {
|
||||
tracing::warn!(error = %e, "ssh_session_flush_failed");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -347,7 +355,9 @@ impl russh::server::Handler for SSHandle {
|
||||
tracing::info!("subsystem_request channel={:?} subsystem={}", channel, name);
|
||||
// git-clients may send "subsystem" for git protocol over ssh.
|
||||
// We don't use subsystem; exec_request handles it directly.
|
||||
let _ = session.flush().ok();
|
||||
if let Err(e) = session.flush() {
|
||||
tracing::warn!(error = %e, "ssh_session_flush_failed");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
async fn data(
|
||||
@ -467,23 +477,21 @@ impl russh::server::Handler for SSHandle {
|
||||
);
|
||||
|
||||
tracing::info!("shell_request user={}", user.username);
|
||||
session
|
||||
.data(channel_id, CryptoVec::from_slice(welcome_msg.as_bytes()))
|
||||
.ok();
|
||||
session.exit_status_request(channel_id, 0).ok();
|
||||
session.eof(channel_id).ok();
|
||||
session.close(channel_id).ok();
|
||||
let _ = session.flush().ok();
|
||||
let _ = session
|
||||
.data(channel_id, CryptoVec::from_slice(welcome_msg.as_bytes()));
|
||||
let _ = session.exit_status_request(channel_id, 0);
|
||||
let _ = session.eof(channel_id);
|
||||
let _ = session.close(channel_id);
|
||||
let _ = session.flush();
|
||||
} else {
|
||||
tracing::warn!("shell_request_unauthenticated channel={:?}", channel_id);
|
||||
let msg = "Authentication required\r\n";
|
||||
session
|
||||
.data(channel_id, CryptoVec::from_slice(msg.as_bytes()))
|
||||
.ok();
|
||||
session.exit_status_request(channel_id, 1).ok();
|
||||
session.eof(channel_id).ok();
|
||||
session.close(channel_id).ok();
|
||||
let _ = session.flush().ok();
|
||||
let _ = session
|
||||
.data(channel_id, CryptoVec::from_slice(msg.as_bytes()));
|
||||
let _ = session.exit_status_request(channel_id, 1);
|
||||
let _ = session.eof(channel_id);
|
||||
let _ = session.close(channel_id);
|
||||
let _ = session.flush();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@ -504,13 +512,12 @@ impl russh::server::Handler for SSHandle {
|
||||
Ok(cmd) => cmd.trim(),
|
||||
Err(e) => {
|
||||
tracing::error!("invalid_command_encoding error={}", e);
|
||||
session
|
||||
let _ = session
|
||||
.disconnect(
|
||||
Disconnect::ServiceNotAvailable,
|
||||
"Invalid command encoding",
|
||||
"",
|
||||
)
|
||||
.ok();
|
||||
);
|
||||
return Err(russh::Error::Disconnect);
|
||||
}
|
||||
};
|
||||
@ -519,9 +526,8 @@ impl russh::server::Handler for SSHandle {
|
||||
None => {
|
||||
tracing::error!("invalid_git_command command={}", git_shell_cmd);
|
||||
let msg = format!("Invalid git command: {}", git_shell_cmd);
|
||||
session
|
||||
.disconnect(Disconnect::ServiceNotAvailable, &msg, "")
|
||||
.ok();
|
||||
let _ = session
|
||||
.disconnect(Disconnect::ServiceNotAvailable, &msg, "");
|
||||
return Err(russh::Error::Disconnect);
|
||||
}
|
||||
};
|
||||
@ -531,9 +537,8 @@ impl russh::server::Handler for SSHandle {
|
||||
None => {
|
||||
let msg = format!("Invalid repository path: {}", path);
|
||||
tracing::error!("invalid_repo_path path={}", path);
|
||||
session
|
||||
.disconnect(Disconnect::ServiceNotAvailable, &msg, "")
|
||||
.ok();
|
||||
let _ = session
|
||||
.disconnect(Disconnect::ServiceNotAvailable, &msg, "");
|
||||
return Err(russh::Error::Disconnect);
|
||||
}
|
||||
};
|
||||
@ -544,9 +549,8 @@ impl russh::server::Handler for SSHandle {
|
||||
Err(e) => {
|
||||
// Log the detailed error internally; client receives generic message.
|
||||
tracing::error!("repo_fetch_error error={}", e);
|
||||
session
|
||||
.disconnect(Disconnect::ServiceNotAvailable, "Repository not found", "")
|
||||
.ok();
|
||||
let _ = session
|
||||
.disconnect(Disconnect::ServiceNotAvailable, "Repository not found", "");
|
||||
return Err(russh::Error::Disconnect);
|
||||
}
|
||||
};
|
||||
@ -557,7 +561,7 @@ impl russh::server::Handler for SSHandle {
|
||||
None => {
|
||||
let msg = "Authentication error: no authenticated user";
|
||||
tracing::error!("exec_no_authenticated_user channel={:?}", channel_id);
|
||||
session.disconnect(Disconnect::ByApplication, msg, "").ok();
|
||||
let _ = session.disconnect(Disconnect::ByApplication, msg, "");
|
||||
return Err(russh::Error::Disconnect);
|
||||
}
|
||||
};
|
||||
@ -576,7 +580,7 @@ impl russh::server::Handler for SSHandle {
|
||||
repo.repo_name
|
||||
);
|
||||
tracing::error!("access_denied user={} repo={} is_write={}", operator.username, repo.repo_name, is_write);
|
||||
session.disconnect(Disconnect::ByApplication, &msg, "").ok();
|
||||
let _ = session.disconnect(Disconnect::ByApplication, &msg, "");
|
||||
return Err(russh::Error::Disconnect);
|
||||
}
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@ use db::database::AppDatabase;
|
||||
use models::repos::repo;
|
||||
use models::rooms::room_ai;
|
||||
use models::rooms::room_message::{Column as RmCol, Entity as RoomMessage};
|
||||
use models::users::user;
|
||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect};
|
||||
use uuid::Uuid;
|
||||
|
||||
@ -59,6 +60,18 @@ pub async fn get_room_ai_config(
|
||||
Ok(ai_config)
|
||||
}
|
||||
|
||||
pub async fn get_room_ai_configs(
|
||||
db: &AppDatabase,
|
||||
room_id: Uuid,
|
||||
) -> Result<Vec<room_ai::Model>, RoomError> {
|
||||
let ai_configs = room_ai::Entity::find()
|
||||
.filter(room_ai::Column::Room.eq(room_id))
|
||||
.all(db)
|
||||
.await?;
|
||||
|
||||
Ok(ai_configs)
|
||||
}
|
||||
|
||||
pub async fn extract_mention_context(
|
||||
db: &AppDatabase,
|
||||
project_id: Uuid,
|
||||
@ -66,24 +79,43 @@ pub async fn extract_mention_context(
|
||||
) -> Vec<agent::chat::Mention> {
|
||||
let mut mentions: Vec<agent::chat::Mention> = Vec::new();
|
||||
let mut seen_repos: Vec<String> = Vec::new();
|
||||
let mut seen_users: Vec<String> = Vec::new();
|
||||
|
||||
for cap in mention_bracket_re().captures_iter(content) {
|
||||
if let (Some(type_m), Some(id_m)) = (cap.get(1), cap.get(2)) {
|
||||
if type_m.as_str() == "repo" {
|
||||
let repo_name = id_m.as_str().trim().to_string();
|
||||
if repo_name.is_empty() || seen_repos.contains(&repo_name) {
|
||||
continue;
|
||||
}
|
||||
seen_repos.push(repo_name.clone());
|
||||
let type_str = type_m.as_str();
|
||||
let id = id_m.as_str().trim();
|
||||
|
||||
if let Ok(Some(repo_model)) = repo::Entity::find()
|
||||
.filter(repo::Column::Project.eq(project_id))
|
||||
.filter(repo::Column::RepoName.eq(&repo_name))
|
||||
.one(db)
|
||||
.await
|
||||
{
|
||||
mentions.push(agent::chat::Mention::Repo(repo_model));
|
||||
match type_str {
|
||||
"repo" => {
|
||||
let repo_name = id.to_string();
|
||||
if repo_name.is_empty() || seen_repos.contains(&repo_name) {
|
||||
continue;
|
||||
}
|
||||
seen_repos.push(repo_name.clone());
|
||||
|
||||
if let Ok(Some(repo_model)) = repo::Entity::find()
|
||||
.filter(repo::Column::Project.eq(project_id))
|
||||
.filter(repo::Column::RepoName.eq(&repo_name))
|
||||
.one(db)
|
||||
.await
|
||||
{
|
||||
mentions.push(agent::chat::Mention::Repo(repo_model));
|
||||
}
|
||||
}
|
||||
"user" => {
|
||||
if seen_users.contains(&id.to_string()) {
|
||||
continue;
|
||||
}
|
||||
seen_users.push(id.to_string());
|
||||
|
||||
if let Ok(uuid) = Uuid::parse_str(id) {
|
||||
if let Ok(Some(user_model)) = user::Entity::find_by_id(uuid).one(db).await {
|
||||
mentions.push(agent::chat::Mention::User(user_model));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -258,22 +258,20 @@ impl RoomService {
|
||||
}
|
||||
|
||||
pub async fn should_ai_respond(&self, room_id: Uuid, content: &str) -> Result<bool, RoomError> {
|
||||
let ai_config = history::get_room_ai_config(&self.db, room_id).await?;
|
||||
|
||||
let config = match ai_config {
|
||||
Some(c) => c,
|
||||
None => return Ok(false),
|
||||
};
|
||||
|
||||
if !config.use_exact {
|
||||
return Ok(true);
|
||||
let ai_configs = history::get_room_ai_configs(&self.db, room_id).await?;
|
||||
if ai_configs.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let model_id_str = config.model.to_string();
|
||||
// Collect all model IDs in this room
|
||||
let model_ids: std::collections::HashSet<String> = ai_configs
|
||||
.iter()
|
||||
.map(|c| c.model.to_string())
|
||||
.collect();
|
||||
|
||||
for cap in mention_bracket_re().captures_iter(content) {
|
||||
if let (Some(type_m), Some(id_m)) = (cap.get(1), cap.get(2)) {
|
||||
if type_m.as_str() == "ai" && id_m.as_str().trim() == model_id_str {
|
||||
if type_m.as_str() == "ai" && model_ids.contains(id_m.as_str().trim()) {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
@ -281,7 +279,7 @@ impl RoomService {
|
||||
|
||||
for cap in mention_tag_re().captures_iter(content) {
|
||||
if let (Some(type_m), Some(id_m)) = (cap.get(1), cap.get(2)) {
|
||||
if type_m.as_str() == "ai" && id_m.as_str().trim() == model_id_str {
|
||||
if type_m.as_str() == "ai" && model_ids.contains(id_m.as_str().trim()) {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
@ -323,8 +321,48 @@ impl RoomService {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let Some(ai_config) = self.get_room_ai_config(room_id).await? else {
|
||||
return Ok(());
|
||||
// Extract mentioned AI model ID from content
|
||||
let mentioned_model_id = {
|
||||
let mut found = None;
|
||||
for cap in mention_bracket_re().captures_iter(&content) {
|
||||
if let (Some(type_m), Some(id_m)) = (cap.get(1), cap.get(2)) {
|
||||
if type_m.as_str() == "ai" {
|
||||
if let Ok(uuid) = Uuid::parse_str(id_m.as_str().trim()) {
|
||||
found = Some(uuid);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if found.is_none() {
|
||||
for cap in mention_tag_re().captures_iter(&content) {
|
||||
if let (Some(type_m), Some(id_m)) = (cap.get(1), cap.get(2)) {
|
||||
if type_m.as_str() == "ai" {
|
||||
if let Ok(uuid) = Uuid::parse_str(id_m.as_str().trim()) {
|
||||
found = Some(uuid);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
found
|
||||
};
|
||||
|
||||
let model_id = match mentioned_model_id {
|
||||
Some(id) => id,
|
||||
None => return Ok(()), // No @ai mention, don't respond
|
||||
};
|
||||
|
||||
// Verify the mentioned AI exists in this room
|
||||
let ai_config = match room_ai::Entity::find()
|
||||
.filter(room_ai::Column::Room.eq(room_id))
|
||||
.filter(room_ai::Column::Model.eq(model_id))
|
||||
.one(&self.db)
|
||||
.await?
|
||||
{
|
||||
Some(c) => c,
|
||||
None => return Ok(()), // Mentioned AI not in this room
|
||||
};
|
||||
|
||||
let Some(lock_guard) =
|
||||
@ -339,23 +377,6 @@ impl RoomService {
|
||||
.one(&self.db)
|
||||
.await?
|
||||
.ok_or_else(|| RoomError::NotFound("Project not found".to_string()))?;
|
||||
|
||||
let mentioned_model_id = {
|
||||
let mut found = None;
|
||||
for cap in mention_bracket_re().captures_iter(&content) {
|
||||
if let (Some(type_m), Some(id_m)) = (cap.get(1), cap.get(2)) {
|
||||
if type_m.as_str() == "ai" {
|
||||
if let Ok(uuid) = Uuid::parse_str(id_m.as_str().trim()) {
|
||||
found = Some(uuid);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
found
|
||||
};
|
||||
|
||||
let model_id = mentioned_model_id.unwrap_or(ai_config.model);
|
||||
let model = models::agents::model::Entity::find_by_id(model_id)
|
||||
.one(&self.db)
|
||||
.await?
|
||||
|
||||
@ -24,17 +24,16 @@ impl AppService {
|
||||
project_uid: Uuid,
|
||||
_caller_uid: Uuid,
|
||||
) -> Result<scanner::ScanSyncResult, AppError> {
|
||||
// Collect all repo IDs for this project
|
||||
let repos: Vec<_> = RepoEntity::find()
|
||||
.filter(RCol::Project.eq(project_uid))
|
||||
.all(&self.db)
|
||||
.await?;
|
||||
|
||||
let mut total_created = 0i64;
|
||||
let mut total_updated = 0i64;
|
||||
let mut total_removed = 0i64;
|
||||
let mut total_discovered = 0i64;
|
||||
|
||||
let repos: Vec<_> = RepoEntity::find()
|
||||
.filter(RCol::Project.eq(project_uid))
|
||||
.all(&self.db)
|
||||
.await?;
|
||||
|
||||
for repo in repos {
|
||||
let result = scanner::scan_and_sync_skills(&self.db, project_uid, &repo).await?;
|
||||
total_created += result.created;
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
use crate::error::AppError;
|
||||
use chrono::Utc;
|
||||
use git2::Repository;
|
||||
use models::ActiveModelTrait;
|
||||
use models::projects::project_skill::ActiveModel as SkillActiveModel;
|
||||
use models::projects::project_skill::Column as C;
|
||||
@ -108,7 +109,12 @@ pub fn scan_repo_for_skills(
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
stack.push(path);
|
||||
} else if path.file_name().and_then(|n| n.to_str()) == Some("SKILL.md") {
|
||||
} else if path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|s| s.to_lowercase())
|
||||
== Some("skill.md".to_string())
|
||||
{
|
||||
if let Some(dir_name) = path.parent()
|
||||
.and_then(|p| p.file_name())
|
||||
.and_then(|n| n.to_str())
|
||||
@ -136,10 +142,40 @@ pub async fn scan_and_sync_skills(
|
||||
project_uuid: Uuid,
|
||||
repo: &RepoModel,
|
||||
) -> Result<ScanSyncResult, AppError> {
|
||||
// Resolve the repo path
|
||||
let storage_path = Path::new(&repo.storage_path);
|
||||
let discovered = scan_repo_for_skills(storage_path, repo.id)?;
|
||||
// Open with git2 to get the actual workdir
|
||||
let git_repo = match Repository::open(&repo.storage_path) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::warn!("failed to open git repo {}: {:?}", repo.storage_path, e);
|
||||
return Ok(ScanSyncResult {
|
||||
discovered: 0,
|
||||
created: 0,
|
||||
updated: 0,
|
||||
removed: 0,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let workdir = git_repo.workdir().map(|p| p.to_path_buf()).unwrap_or_else(|| Path::new(&repo.storage_path).to_path_buf());
|
||||
let commit_sha = git_repo.head().ok().and_then(|h| h.target()).map(|oid| oid.to_string());
|
||||
|
||||
let mut discovered = scan_repo_for_skills(&workdir, repo.id)?;
|
||||
|
||||
// Fill in commit_sha for discovered skills
|
||||
for skill in &mut discovered {
|
||||
skill.commit_sha = commit_sha.clone();
|
||||
}
|
||||
|
||||
sync_discovered_skills(db, project_uuid, repo.id, discovered).await
|
||||
}
|
||||
|
||||
/// Sync discovered skills with deduplication by {repo_id}+{blob_hash}.
|
||||
async fn sync_discovered_skills(
|
||||
db: &db::database::AppDatabase,
|
||||
project_uuid: Uuid,
|
||||
repo_id: Uuid,
|
||||
discovered: Vec<DiscoveredSkill>,
|
||||
) -> Result<ScanSyncResult, AppError> {
|
||||
if discovered.is_empty() {
|
||||
return Ok(ScanSyncResult {
|
||||
discovered: 0,
|
||||
@ -153,37 +189,56 @@ pub async fn scan_and_sync_skills(
|
||||
let mut created = 0i64;
|
||||
let mut updated = 0i64;
|
||||
|
||||
// Collect all repo-sourced skills in this repo for this project
|
||||
// Deduplicate by {repo_id}+{blob_hash}, keep latest by commit_sha
|
||||
let mut deduped: std::collections::HashMap<String, DiscoveredSkill> = std::collections::HashMap::new();
|
||||
for skill in discovered {
|
||||
let key = format!("{}:{}", repo_id, skill.blob_hash.as_ref().unwrap_or(&skill.slug));
|
||||
match deduped.get(&key) {
|
||||
Some(existing) => {
|
||||
// Keep the one with the later commit_sha
|
||||
if skill.commit_sha.as_ref().unwrap_or(&String::new()) > existing.commit_sha.as_ref().unwrap_or(&String::new()) {
|
||||
deduped.insert(key, skill);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
deduped.insert(key, skill);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Query existing skills for this repo
|
||||
let existing: Vec<_> = SkillEntity::find()
|
||||
.filter(C::ProjectUuid.eq(project_uuid))
|
||||
.filter(C::Source.eq("repo"))
|
||||
.filter(C::RepoId.eq(repo.id))
|
||||
.filter(C::RepoId.eq(repo_id))
|
||||
.all(db)
|
||||
.await?;
|
||||
|
||||
let existing_by_slug: std::collections::HashMap<_, _> = existing
|
||||
let existing_by_hash: std::collections::HashMap<_, _> = existing
|
||||
.into_iter()
|
||||
.map(|s| (s.slug.clone(), s))
|
||||
.map(|s| {
|
||||
let key = format!("{}:{}", s.repo_id.unwrap_or_default(), s.blob_hash.clone().unwrap_or_default());
|
||||
(key, s)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut seen_slugs = std::collections::HashSet::new();
|
||||
|
||||
let discovered_count = discovered.len() as i64;
|
||||
for skill in discovered {
|
||||
seen_slugs.insert(skill.slug.clone());
|
||||
let mut seen_keys = std::collections::HashSet::new();
|
||||
|
||||
let discovered_count = deduped.len() as i64;
|
||||
for (key, skill) in deduped {
|
||||
seen_keys.insert(key.clone());
|
||||
let json_meta = serde_json::to_value(&skill.metadata).unwrap_or_default();
|
||||
|
||||
if let Some(existing_skill) = existing_by_slug.get(&skill.slug) {
|
||||
if let Some(existing_skill) = existing_by_hash.get(&key) {
|
||||
if existing_skill.content != skill.content
|
||||
|| existing_skill.metadata != json_meta
|
||||
|| existing_skill.blob_hash != skill.blob_hash
|
||||
|| existing_skill.commit_sha != skill.commit_sha
|
||||
{
|
||||
let mut active: SkillActiveModel = existing_skill.clone().into();
|
||||
active.content = Set(skill.content);
|
||||
active.metadata = Set(json_meta);
|
||||
active.commit_sha = Set(skill.commit_sha.clone());
|
||||
active.blob_hash = Set(skill.blob_hash.clone());
|
||||
active.commit_sha = Set(skill.commit_sha);
|
||||
active.blob_hash = Set(skill.blob_hash);
|
||||
active.updated_at = Set(now);
|
||||
active.update(db).await?;
|
||||
updated += 1;
|
||||
@ -192,13 +247,13 @@ pub async fn scan_and_sync_skills(
|
||||
let active = SkillActiveModel {
|
||||
id: Set(0),
|
||||
project_uuid: Set(project_uuid),
|
||||
slug: Set(skill.slug.clone()),
|
||||
slug: Set(skill.slug),
|
||||
name: Set(skill.name),
|
||||
description: Set(skill.description),
|
||||
source: Set("repo".to_string()),
|
||||
repo_id: Set(Some(repo.id)),
|
||||
commit_sha: Set(skill.commit_sha.clone()),
|
||||
blob_hash: Set(skill.blob_hash.clone()),
|
||||
repo_id: Set(Some(repo_id)),
|
||||
commit_sha: Set(skill.commit_sha),
|
||||
blob_hash: Set(skill.blob_hash),
|
||||
content: Set(skill.content),
|
||||
metadata: Set(json_meta),
|
||||
enabled: Set(true),
|
||||
@ -213,8 +268,8 @@ pub async fn scan_and_sync_skills(
|
||||
|
||||
// Remove skills that no longer exist in the repo
|
||||
let mut removed = 0i64;
|
||||
for (slug, old_skill) in existing_by_slug {
|
||||
if !seen_slugs.contains(&slug) {
|
||||
for (key, old_skill) in existing_by_hash {
|
||||
if !seen_keys.contains(&key) {
|
||||
SkillEntity::delete_by_id(old_skill.id).exec(db).await?;
|
||||
removed += 1;
|
||||
}
|
||||
|
||||
@ -313,10 +313,14 @@ export function RoomProvider({
|
||||
|
||||
// Subscribe to room events. connect() is already called at the provider
|
||||
// level — subscribe/unsubscribe only manage per-room event routing.
|
||||
client.subscribeRoom(activeRoomId).catch(() => {});
|
||||
client.subscribeRoom(activeRoomId).catch((err) => {
|
||||
console.warn('[RoomContext] subscribeRoom failed:', err);
|
||||
});
|
||||
|
||||
return () => {
|
||||
client.unsubscribeRoom(activeRoomId).catch(() => {});
|
||||
client.unsubscribeRoom(activeRoomId).catch((err) => {
|
||||
console.warn('[RoomContext] unsubscribeRoom failed:', err);
|
||||
});
|
||||
};
|
||||
}, [activeRoomId, wsClient]);
|
||||
|
||||
@ -357,7 +361,9 @@ export function RoomProvider({
|
||||
);
|
||||
}
|
||||
};
|
||||
doLoad().catch(() => {});
|
||||
doLoad().catch((err) => {
|
||||
console.warn('[RoomContext] loadReactions failed:', err);
|
||||
});
|
||||
};
|
||||
|
||||
const loadMore = useCallback(
|
||||
@ -853,7 +859,9 @@ export function RoomProvider({
|
||||
const client = wsClientRef.current;
|
||||
if (client && client.getStatus() !== 'open') {
|
||||
console.debug('[RoomContext] Tab visible, reconnecting WS...');
|
||||
client.connect().catch(() => {});
|
||||
client.connect().catch(() => {
|
||||
// connect() has its own retry logic; ignore here to avoid duplicate warnings
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -9,8 +9,10 @@ interface ThemeContextType {
|
||||
setTheme: (theme: ThemePreference) => void;
|
||||
}
|
||||
|
||||
const getSystemTheme = (): ResolvedTheme =>
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
const getSystemTheme = (): ResolvedTheme => {
|
||||
if (typeof window === 'undefined') return 'light';
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
};
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
|
||||
@ -40,6 +42,7 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const root = window.document.documentElement;
|
||||
root.classList.remove('light', 'dark');
|
||||
root.classList.add(resolvedTheme);
|
||||
|
||||
@ -6,6 +6,7 @@ export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
|
||||
@ -94,7 +94,9 @@ export function detectLinkType(url: string): UnfurlResult | null {
|
||||
// External URL
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const isExternal = !parsed.hostname.includes(window.location.hostname);
|
||||
const isExternal = typeof window === 'undefined'
|
||||
? true
|
||||
: !parsed.hostname.includes(window.location.hostname);
|
||||
return {
|
||||
type: 'external',
|
||||
url,
|
||||
|
||||
@ -235,7 +235,9 @@ export class RoomWsClient {
|
||||
this.reconnectAttempt = 0;
|
||||
this.setStatus('open');
|
||||
this.startHeartbeat();
|
||||
this.resubscribeAll().catch(() => {});
|
||||
this.resubscribeAll().catch((err) => {
|
||||
console.warn('[RoomWs] resubscribe failed:', err);
|
||||
});
|
||||
resolve();
|
||||
};
|
||||
|
||||
@ -1002,7 +1004,9 @@ export class RoomWsClient {
|
||||
sendTyping(roomId: string, action: 'start' | 'stop'): void {
|
||||
if (this.ws && this.status === 'open') {
|
||||
const wsAction = action === 'start' ? 'typing.start' as WsAction : 'typing.stop' as WsAction;
|
||||
this.requestWs<void>(wsAction, { room_id: roomId, typing: action }).catch(() => {});
|
||||
this.requestWs<void>(wsAction, { room_id: roomId, typing: action }).catch((err) => {
|
||||
console.debug('[RoomWs] typing event failed:', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -1222,7 +1226,9 @@ export class RoomWsClient {
|
||||
this.wsToken = null;
|
||||
console.debug('[RoomWs] Clearing token after 3 reconnect failures, will fetch fresh');
|
||||
}
|
||||
this.connect().catch(() => {});
|
||||
this.connect().catch(() => {
|
||||
// connect() has its own retry logic; ignore here to avoid duplicate warnings
|
||||
});
|
||||
}, delay);
|
||||
}
|
||||
}
|
||||
|
||||
@ -64,8 +64,8 @@ export class UniversalWsClient {
|
||||
|
||||
// Re-subscribe to rooms after reconnect
|
||||
for (const roomId of this.subscribedRooms) {
|
||||
this.request('room.subscribe', { room_id: roomId }).catch(() => {
|
||||
// ignore re-subscribe errors
|
||||
this.request('room.subscribe', { room_id: roomId }).catch((err) => {
|
||||
console.warn('[UniversalWs] re-subscribe failed:', err);
|
||||
});
|
||||
}
|
||||
|
||||
@ -202,7 +202,7 @@ export class UniversalWsClient {
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
this.connect().catch(() => {
|
||||
// connect() will retry on its own
|
||||
// connect() has its own retry logic; ignore here to avoid duplicate warnings
|
||||
});
|
||||
}, delay);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user