Compare commits

..

8 Commits

Author SHA1 Message Date
ZhenYi
108dd714d3 fix(room): include @user mentions in AI prompt context
Some checks are pending
CI / Rust Lint & Check (push) Waiting to run
CI / Rust Tests (push) Waiting to run
CI / Frontend Lint & Type Check (push) Waiting to run
CI / Frontend Build (push) Blocked by required conditions
- Extend extract_mention_context to handle user mentions
- Both @[repo:xxx] and @[user:xxx] are now included in AI context
2026-04-28 22:25:25 +08:00
ZhenYi
76e3d19cf5 fix(room): require @ai mention to trigger AI response
- process_message_ai now returns early if no @ai mention is found
- Verify mentioned AI exists in the room before responding
2026-04-28 22:21:12 +08:00
ZhenYi
55d33862f6 fix(room): support multiple AIs per room in should_ai_respond
- Add get_room_ai_configs() to fetch all AI configs for a room
- Check all AI model IDs against @ai mentions
2026-04-28 22:16:04 +08:00
ZhenYi
46a0bdc21e fix(room): should_ai_respond only triggers on @ai mention 2026-04-28 22:14:10 +08:00
ZhenYi
c2c079c74d fix(room): invert use_exact logic so it controls all-message mode
Previously: use_exact=false → respond to all messages (wrong default)
Now: use_exact=true → respond to all messages; use_exact=false → only @ai
2026-04-28 22:10:21 +08:00
ZhenYi
db0a2eca16 feat(ssh): add complete SSH server implementation for Git operations
- Implement SSHandle struct with comprehensive Git service handling capabilities
- Add support for multiple authentication methods including password, public key and certificate
- Integrate Git command parsing and execution with proper channel management
- Implement branch protection rules enforcement during Git operations
- Add robust error handling and logging for SSH connections and Git processes
- Create secure Git command execution with environment isolation
- Implement proper resource cleanup for channels and subprocesses
- Add support for receive-pack, upload-pack and upload-archive services
- Integrate with existing authz and database services for permission checks
- Implement proper data forwarding between SSH channels and Git processes

fix(config): improve environment loading with error reporting

- Replace silent dotenv loading failures with informative error messages
- Handle global config race conditions safely during application startup
- Improve config loading reliability and debugging capabilities

fix(link-unfurl): handle server-side rendering compatibility

- Add undefined window object check for SSR environments
- Prevent client-side only code from breaking server-side rendering

refactor(agent): improve tool registry error handling

- Replace panics with graceful error logging for duplicate tool registrations
- Add proper error type definitions for tool registry operations
- Implement safe merging of registries with duplicate detection

fix(room-context): enhance WebSocket connection reliability

- Add proper error handling for room subscription operations
- Improve connection management with better error suppression
- Add console warnings for debugging connection issues

feat(ws-client): add comprehensive WebSocket client implementation

- Create RoomWsClient class with complete WebSocket communication layer
- Implement request-response pattern with timeout handling
- Add support for various room-related events and actions
- Include proper connection status tracking and management
- Implement callback system for different event types
- Add automatic reconnection and error recovery mechanisms
2026-04-28 21:29:34 +08:00
ZhenYi
b3fb027848 fix(git): deduplicate skills by repo_id+blob_hash in hook sync
- Apply same deduplication logic as service scanner
- Keep latest version by commit_sha when duplicates found
- Fix type error: Ok("skill.md") → Some("skill.md".to_string())
2026-04-28 21:28:19 +08:00
ZhenYi
2db7934596 fix(skill): deduplicate skills by repo_id+blob_hash
- Change deduplication key from slug to {repo_id}+{blob_hash}
- Keep latest version by commit_sha when duplicates found
- Use git2 to open repos and get correct workdir and commit_sha
- Fix case-insensitive SKILL.md detection in scanner
2026-04-28 21:27:38 +08:00
14 changed files with 331 additions and 164 deletions

View File

@ -11,6 +11,13 @@ use super::call::ToolError;
use super::context::ToolContext; use super::context::ToolContext;
use super::definition::ToolDefinition; 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. /// Inner function pointer type for tool handlers.
type InnerHandlerFn = dyn Fn( type InnerHandlerFn = dyn Fn(
ToolContext, ToolContext,
@ -75,7 +82,8 @@ impl ToolRegistry {
pub fn register(&mut self, def: ToolDefinition, handler: ToolHandler) -> &mut Self { pub fn register(&mut self, def: ToolDefinition, handler: ToolHandler) -> &mut Self {
let name = def.name.clone(); let name = def.name.clone();
if self.handlers.contains_key(&name) { 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.handlers.insert(name.clone(), handler);
self.definitions.insert(name, def); self.definitions.insert(name, def);
@ -107,11 +115,12 @@ impl ToolRegistry {
} }
/// Merges another registry's tools into this one. /// 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) { pub fn merge(&mut self, other: ToolRegistry) {
for (name, handler) in other.handlers { for (name, handler) in other.handlers {
if self.handlers.contains_key(&name) { if self.handlers.contains_key(&name) {
panic!("tool already registered: {}", name); tracing::warn!("merge skipped duplicate tool: {}", name);
continue;
} }
self.handlers.insert(name, handler); self.handlers.insert(name, handler);
} }

View File

@ -13,7 +13,9 @@ impl AppConfig {
pub fn load() -> AppConfig { pub fn load() -> AppConfig {
let mut env = HashMap::new(); let mut env = HashMap::new();
for env_file in AppConfig::ENV_FILES { 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) { if let Ok(env_file_content) = std::fs::read_to_string(env_file) {
for line in env_file_content.lines() { for line in env_file_content.lines() {
if let Some((key, value)) = line.split_once('=') { 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 // Environment variables (e.g. K8s injected APP_DOMAIN_URL) take precedence over .env files
env = std::env::vars().chain(env).collect(); env = std::env::vars().chain(env).collect();
let this = AppConfig { env }; let this = AppConfig { env };
if let Err(config) = GLOBAL_CONFIG.set(this) { // Handle the race condition: if another thread already set the global, return it.
eprintln!("Failed to set global config: {:?}", config); // This is safe because config is immutable after load.
} if GLOBAL_CONFIG.get().is_some() {
if let Some(config) = GLOBAL_CONFIG.get() { GLOBAL_CONFIG.get().unwrap().clone()
config.clone()
} else { } else {
panic!("Failed to get global config"); let _ = GLOBAL_CONFIG.set(this);
GLOBAL_CONFIG.get().expect("global config should be set after load").clone()
} }
} }
} }

View File

@ -8,11 +8,11 @@ pub mod tag;
use db::cache::AppCache; use db::cache::AppCache;
use db::database::AppDatabase; 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::ActiveModel as SkillActiveModel;
use models::projects::project_skill::{Column as SkillCol, Entity as SkillEntity};
use models::repos::repo::Model as RepoModel; use models::repos::repo::Model as RepoModel;
use models::RepoId;
use models::ActiveModelTrait; use models::ActiveModelTrait;
use models::RepoId;
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, Set}; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, Set};
use std::collections::HashMap; use std::collections::HashMap;
use std::path::Path; use std::path::Path;
@ -43,8 +43,14 @@ fn scan_skills_from_dir(
let path = entry.path(); let path = entry.path();
if path.is_dir() { if path.is_dir() {
stack.push(path); stack.push(path);
} else if path.file_name().and_then(|n| n.to_str()) == Some("SKILL.md") { } else if path
if let Some(dir_name) = path.parent() .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(|p| p.file_name())
.and_then(|n| n.to_str()) .and_then(|n| n.to_str())
.filter(|s| !s.starts_with('.')) .filter(|s| !s.starts_with('.'))
@ -135,11 +141,7 @@ pub struct HookMetaDataSync {
} }
impl HookMetaDataSync { impl HookMetaDataSync {
pub fn new( pub fn new(db: AppDatabase, cache: AppCache, repo: RepoModel) -> Result<Self, crate::GitError> {
db: AppDatabase,
cache: AppCache,
repo: RepoModel,
) -> Result<Self, crate::GitError> {
let domain = GitDomain::from_model(repo.clone())?; let domain = GitDomain::from_model(repo.clone())?;
Ok(Self { Ok(Self {
db, db,
@ -185,18 +187,16 @@ impl HookMetaDataSync {
/// Full sync pipeline (no locking — caller is responsible). /// Full sync pipeline (no locking — caller is responsible).
async fn sync_work(&self) -> Result<(), crate::GitError> { async fn sync_work(&self) -> Result<(), crate::GitError> {
let mut txn = self let mut txn =
.db self.db.begin().await.map_err(|e| {
.begin() crate::GitError::IoError(format!("failed to begin transaction: {}", e))
.await })?;
.map_err(|e| crate::GitError::IoError(format!("failed to begin transaction: {}", e)))?;
self.sync_refs(&mut txn).await?; self.sync_refs(&mut txn).await?;
self.sync_commits(&mut txn).await?; self.sync_commits(&mut txn).await?;
self.sync_tags(&mut txn).await?; self.sync_tags(&mut txn).await?;
self.sync_lfs_objects(&mut txn).await?; self.sync_lfs_objects(&mut txn).await?;
self.run_fsck_and_rollback_if_corrupt(&mut txn) self.run_fsck_and_rollback_if_corrupt(&mut txn).await?;
.await?;
txn.commit().await.map_err(|e| { txn.commit().await.map_err(|e| {
crate::GitError::IoError(format!("failed to commit transaction: {}", e)) crate::GitError::IoError(format!("failed to commit transaction: {}", e))
@ -210,14 +210,12 @@ impl HookMetaDataSync {
/// Fsck only work (no locking — caller is responsible). /// Fsck only work (no locking — caller is responsible).
async fn fsck_work(&self) -> Result<(), crate::GitError> { async fn fsck_work(&self) -> Result<(), crate::GitError> {
let mut txn = self let mut txn =
.db self.db.begin().await.map_err(|e| {
.begin() crate::GitError::IoError(format!("failed to begin transaction: {}", e))
.await })?;
.map_err(|e| crate::GitError::IoError(format!("failed to begin transaction: {}", e)))?;
self.run_fsck_and_rollback_if_corrupt(&mut txn) self.run_fsck_and_rollback_if_corrupt(&mut txn).await?;
.await?;
txn.commit().await.map_err(|e| { txn.commit().await.map_err(|e| {
crate::GitError::IoError(format!("failed to commit transaction: {}", 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() .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(); .collect();
let mut seen_slugs = std::collections::HashSet::new(); let mut seen_keys = std::collections::HashSet::new();
for skill in discovered { for (key, skill) in deduped {
seen_slugs.insert(skill.slug.clone()); seen_keys.insert(key.clone());
let json_meta = serde_json::to_value(&skill.metadata).unwrap_or_default(); 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 if existing_skill.content != skill.content
|| existing_skill.metadata != json_meta || existing_skill.metadata != json_meta
|| existing_skill.commit_sha.as_ref() != skill.commit_sha.as_ref() || existing_skill.commit_sha != skill.commit_sha
|| existing_skill.blob_hash.as_ref() != skill.blob_hash.as_ref()
{ {
let mut active: SkillActiveModel = existing_skill.clone().into(); let mut active: SkillActiveModel = existing_skill.clone().into();
active.content = Set(skill.content); active.content = Set(skill.content);
@ -383,16 +399,25 @@ impl HookMetaDataSync {
} }
} }
for (slug, old_skill) in existing_by_slug { for (key, old_skill) in existing_by_hash {
if !seen_slugs.contains(&slug) { if !seen_keys.contains(&key) {
if SkillEntity::delete_by_id(old_skill.id).exec(&self.db).await.is_ok() { if SkillEntity::delete_by_id(old_skill.id)
.exec(&self.db)
.await
.is_ok()
{
removed += 1; removed += 1;
} }
} }
} }
if created > 0 || updated > 0 || removed > 0 { 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
);
} }
} }
} }

View File

@ -118,7 +118,9 @@ impl SSHandle {
if let Some(mut stdin) = self.stdin.remove(&channel_id) { if let Some(mut stdin) = self.stdin.remove(&channel_id) {
tokio::spawn(async move { tokio::spawn(async move {
let _ = tokio::time::timeout(Duration::from_secs(5), async { 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; let _ = stdin.shutdown().await;
}) })
.await; .await;
@ -296,7 +298,9 @@ impl russh::server::Handler for SSHandle {
tracing::info!("Closing stdin channel={:?} client={:?}", channel, self.client_addr); tracing::info!("Closing stdin channel={:?} client={:?}", channel, self.client_addr);
// Use timeout so we never block the SSH event loop waiting for git. // Use timeout so we never block the SSH event loop waiting for git.
let _ = tokio::time::timeout(Duration::from_secs(5), async { 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; let _ = stdin.shutdown().await;
}) })
.await; .await;
@ -318,7 +322,9 @@ impl russh::server::Handler for SSHandle {
.map(|addr| format!("{}", addr)) .map(|addr| format!("{}", addr))
.unwrap_or_else(|| "unknown".to_string()); .unwrap_or_else(|| "unknown".to_string());
tracing::info!("channel_open_session channel={:?} client={}", channel, client_info); 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) Ok(true)
} }
@ -334,7 +340,9 @@ impl russh::server::Handler for SSHandle {
session: &mut Session, session: &mut Session,
) -> Result<(), Self::Error> { ) -> Result<(), Self::Error> {
tracing::warn!("pty_request not supported channel={:?} term={} cols={} rows={}", channel, term, col_width, row_height); 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(()) Ok(())
} }
@ -347,7 +355,9 @@ impl russh::server::Handler for SSHandle {
tracing::info!("subsystem_request channel={:?} subsystem={}", channel, name); tracing::info!("subsystem_request channel={:?} subsystem={}", channel, name);
// git-clients may send "subsystem" for git protocol over ssh. // git-clients may send "subsystem" for git protocol over ssh.
// We don't use subsystem; exec_request handles it directly. // 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(()) Ok(())
} }
async fn data( async fn data(
@ -467,23 +477,21 @@ impl russh::server::Handler for SSHandle {
); );
tracing::info!("shell_request user={}", user.username); tracing::info!("shell_request user={}", user.username);
session let _ = session
.data(channel_id, CryptoVec::from_slice(welcome_msg.as_bytes())) .data(channel_id, CryptoVec::from_slice(welcome_msg.as_bytes()));
.ok(); let _ = session.exit_status_request(channel_id, 0);
session.exit_status_request(channel_id, 0).ok(); let _ = session.eof(channel_id);
session.eof(channel_id).ok(); let _ = session.close(channel_id);
session.close(channel_id).ok(); let _ = session.flush();
let _ = session.flush().ok();
} else { } else {
tracing::warn!("shell_request_unauthenticated channel={:?}", channel_id); tracing::warn!("shell_request_unauthenticated channel={:?}", channel_id);
let msg = "Authentication required\r\n"; let msg = "Authentication required\r\n";
session let _ = session
.data(channel_id, CryptoVec::from_slice(msg.as_bytes())) .data(channel_id, CryptoVec::from_slice(msg.as_bytes()));
.ok(); let _ = session.exit_status_request(channel_id, 1);
session.exit_status_request(channel_id, 1).ok(); let _ = session.eof(channel_id);
session.eof(channel_id).ok(); let _ = session.close(channel_id);
session.close(channel_id).ok(); let _ = session.flush();
let _ = session.flush().ok();
} }
Ok(()) Ok(())
} }
@ -504,13 +512,12 @@ impl russh::server::Handler for SSHandle {
Ok(cmd) => cmd.trim(), Ok(cmd) => cmd.trim(),
Err(e) => { Err(e) => {
tracing::error!("invalid_command_encoding error={}", e); tracing::error!("invalid_command_encoding error={}", e);
session let _ = session
.disconnect( .disconnect(
Disconnect::ServiceNotAvailable, Disconnect::ServiceNotAvailable,
"Invalid command encoding", "Invalid command encoding",
"", "",
) );
.ok();
return Err(russh::Error::Disconnect); return Err(russh::Error::Disconnect);
} }
}; };
@ -519,9 +526,8 @@ impl russh::server::Handler for SSHandle {
None => { None => {
tracing::error!("invalid_git_command command={}", git_shell_cmd); tracing::error!("invalid_git_command command={}", git_shell_cmd);
let msg = format!("Invalid git command: {}", git_shell_cmd); let msg = format!("Invalid git command: {}", git_shell_cmd);
session let _ = session
.disconnect(Disconnect::ServiceNotAvailable, &msg, "") .disconnect(Disconnect::ServiceNotAvailable, &msg, "");
.ok();
return Err(russh::Error::Disconnect); return Err(russh::Error::Disconnect);
} }
}; };
@ -531,9 +537,8 @@ impl russh::server::Handler for SSHandle {
None => { None => {
let msg = format!("Invalid repository path: {}", path); let msg = format!("Invalid repository path: {}", path);
tracing::error!("invalid_repo_path path={}", path); tracing::error!("invalid_repo_path path={}", path);
session let _ = session
.disconnect(Disconnect::ServiceNotAvailable, &msg, "") .disconnect(Disconnect::ServiceNotAvailable, &msg, "");
.ok();
return Err(russh::Error::Disconnect); return Err(russh::Error::Disconnect);
} }
}; };
@ -544,9 +549,8 @@ impl russh::server::Handler for SSHandle {
Err(e) => { Err(e) => {
// Log the detailed error internally; client receives generic message. // Log the detailed error internally; client receives generic message.
tracing::error!("repo_fetch_error error={}", e); tracing::error!("repo_fetch_error error={}", e);
session let _ = session
.disconnect(Disconnect::ServiceNotAvailable, "Repository not found", "") .disconnect(Disconnect::ServiceNotAvailable, "Repository not found", "");
.ok();
return Err(russh::Error::Disconnect); return Err(russh::Error::Disconnect);
} }
}; };
@ -557,7 +561,7 @@ impl russh::server::Handler for SSHandle {
None => { None => {
let msg = "Authentication error: no authenticated user"; let msg = "Authentication error: no authenticated user";
tracing::error!("exec_no_authenticated_user channel={:?}", channel_id); 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); return Err(russh::Error::Disconnect);
} }
}; };
@ -576,7 +580,7 @@ impl russh::server::Handler for SSHandle {
repo.repo_name repo.repo_name
); );
tracing::error!("access_denied user={} repo={} is_write={}", operator.username, repo.repo_name, is_write); 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); return Err(russh::Error::Disconnect);
} }

View File

@ -2,6 +2,7 @@ use db::database::AppDatabase;
use models::repos::repo; use models::repos::repo;
use models::rooms::room_ai; use models::rooms::room_ai;
use models::rooms::room_message::{Column as RmCol, Entity as RoomMessage}; use models::rooms::room_message::{Column as RmCol, Entity as RoomMessage};
use models::users::user;
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect}; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect};
use uuid::Uuid; use uuid::Uuid;
@ -59,6 +60,18 @@ pub async fn get_room_ai_config(
Ok(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( pub async fn extract_mention_context(
db: &AppDatabase, db: &AppDatabase,
project_id: Uuid, project_id: Uuid,
@ -66,24 +79,43 @@ pub async fn extract_mention_context(
) -> Vec<agent::chat::Mention> { ) -> Vec<agent::chat::Mention> {
let mut mentions: Vec<agent::chat::Mention> = Vec::new(); let mut mentions: Vec<agent::chat::Mention> = Vec::new();
let mut seen_repos: Vec<String> = 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) { for cap in mention_bracket_re().captures_iter(content) {
if let (Some(type_m), Some(id_m)) = (cap.get(1), cap.get(2)) { if let (Some(type_m), Some(id_m)) = (cap.get(1), cap.get(2)) {
if type_m.as_str() == "repo" { let type_str = type_m.as_str();
let repo_name = id_m.as_str().trim().to_string(); let id = id_m.as_str().trim();
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() match type_str {
.filter(repo::Column::Project.eq(project_id)) "repo" => {
.filter(repo::Column::RepoName.eq(&repo_name)) let repo_name = id.to_string();
.one(db) if repo_name.is_empty() || seen_repos.contains(&repo_name) {
.await continue;
{ }
mentions.push(agent::chat::Mention::Repo(repo_model)); 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));
}
}
}
_ => {}
} }
} }
} }

View File

@ -258,22 +258,20 @@ impl RoomService {
} }
pub async fn should_ai_respond(&self, room_id: Uuid, content: &str) -> Result<bool, RoomError> { 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 ai_configs = history::get_room_ai_configs(&self.db, room_id).await?;
if ai_configs.is_empty() {
let config = match ai_config { return Ok(false);
Some(c) => c,
None => return Ok(false),
};
if !config.use_exact {
return Ok(true);
} }
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) { for cap in mention_bracket_re().captures_iter(content) {
if let (Some(type_m), Some(id_m)) = (cap.get(1), cap.get(2)) { 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); return Ok(true);
} }
} }
@ -281,7 +279,7 @@ impl RoomService {
for cap in mention_tag_re().captures_iter(content) { for cap in mention_tag_re().captures_iter(content) {
if let (Some(type_m), Some(id_m)) = (cap.get(1), cap.get(2)) { 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); return Ok(true);
} }
} }
@ -323,8 +321,48 @@ impl RoomService {
return Ok(()); return Ok(());
}; };
let Some(ai_config) = self.get_room_ai_config(room_id).await? else { // Extract mentioned AI model ID from content
return Ok(()); 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) = let Some(lock_guard) =
@ -339,23 +377,6 @@ impl RoomService {
.one(&self.db) .one(&self.db)
.await? .await?
.ok_or_else(|| RoomError::NotFound("Project not found".to_string()))?; .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) let model = models::agents::model::Entity::find_by_id(model_id)
.one(&self.db) .one(&self.db)
.await? .await?

View File

@ -24,17 +24,16 @@ impl AppService {
project_uid: Uuid, project_uid: Uuid,
_caller_uid: Uuid, _caller_uid: Uuid,
) -> Result<scanner::ScanSyncResult, AppError> { ) -> 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_created = 0i64;
let mut total_updated = 0i64; let mut total_updated = 0i64;
let mut total_removed = 0i64; let mut total_removed = 0i64;
let mut total_discovered = 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 { for repo in repos {
let result = scanner::scan_and_sync_skills(&self.db, project_uid, &repo).await?; let result = scanner::scan_and_sync_skills(&self.db, project_uid, &repo).await?;
total_created += result.created; total_created += result.created;

View File

@ -4,6 +4,7 @@
use crate::error::AppError; use crate::error::AppError;
use chrono::Utc; use chrono::Utc;
use git2::Repository;
use models::ActiveModelTrait; use models::ActiveModelTrait;
use models::projects::project_skill::ActiveModel as SkillActiveModel; use models::projects::project_skill::ActiveModel as SkillActiveModel;
use models::projects::project_skill::Column as C; use models::projects::project_skill::Column as C;
@ -108,7 +109,12 @@ pub fn scan_repo_for_skills(
let path = entry.path(); let path = entry.path();
if path.is_dir() { if path.is_dir() {
stack.push(path); 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() if let Some(dir_name) = path.parent()
.and_then(|p| p.file_name()) .and_then(|p| p.file_name())
.and_then(|n| n.to_str()) .and_then(|n| n.to_str())
@ -136,10 +142,40 @@ pub async fn scan_and_sync_skills(
project_uuid: Uuid, project_uuid: Uuid,
repo: &RepoModel, repo: &RepoModel,
) -> Result<ScanSyncResult, AppError> { ) -> Result<ScanSyncResult, AppError> {
// Resolve the repo path // Open with git2 to get the actual workdir
let storage_path = Path::new(&repo.storage_path); let git_repo = match Repository::open(&repo.storage_path) {
let discovered = scan_repo_for_skills(storage_path, repo.id)?; 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() { if discovered.is_empty() {
return Ok(ScanSyncResult { return Ok(ScanSyncResult {
discovered: 0, discovered: 0,
@ -153,37 +189,56 @@ pub async fn scan_and_sync_skills(
let mut created = 0i64; let mut created = 0i64;
let mut updated = 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() let existing: Vec<_> = SkillEntity::find()
.filter(C::ProjectUuid.eq(project_uuid)) .filter(C::ProjectUuid.eq(project_uuid))
.filter(C::Source.eq("repo")) .filter(C::Source.eq("repo"))
.filter(C::RepoId.eq(repo.id)) .filter(C::RepoId.eq(repo_id))
.all(db) .all(db)
.await?; .await?;
let existing_by_slug: std::collections::HashMap<_, _> = existing let existing_by_hash: std::collections::HashMap<_, _> = existing
.into_iter() .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(); .collect();
let mut seen_slugs = std::collections::HashSet::new(); let mut seen_keys = std::collections::HashSet::new();
let discovered_count = discovered.len() as i64;
for skill in discovered {
seen_slugs.insert(skill.slug.clone());
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(); 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 if existing_skill.content != skill.content
|| existing_skill.metadata != json_meta || 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(); let mut active: SkillActiveModel = existing_skill.clone().into();
active.content = Set(skill.content); active.content = Set(skill.content);
active.metadata = Set(json_meta); active.metadata = Set(json_meta);
active.commit_sha = Set(skill.commit_sha.clone()); active.commit_sha = Set(skill.commit_sha);
active.blob_hash = Set(skill.blob_hash.clone()); active.blob_hash = Set(skill.blob_hash);
active.updated_at = Set(now); active.updated_at = Set(now);
active.update(db).await?; active.update(db).await?;
updated += 1; updated += 1;
@ -192,13 +247,13 @@ pub async fn scan_and_sync_skills(
let active = SkillActiveModel { let active = SkillActiveModel {
id: Set(0), id: Set(0),
project_uuid: Set(project_uuid), project_uuid: Set(project_uuid),
slug: Set(skill.slug.clone()), slug: Set(skill.slug),
name: Set(skill.name), name: Set(skill.name),
description: Set(skill.description), description: Set(skill.description),
source: Set("repo".to_string()), source: Set("repo".to_string()),
repo_id: Set(Some(repo.id)), repo_id: Set(Some(repo_id)),
commit_sha: Set(skill.commit_sha.clone()), commit_sha: Set(skill.commit_sha),
blob_hash: Set(skill.blob_hash.clone()), blob_hash: Set(skill.blob_hash),
content: Set(skill.content), content: Set(skill.content),
metadata: Set(json_meta), metadata: Set(json_meta),
enabled: Set(true), enabled: Set(true),
@ -213,8 +268,8 @@ pub async fn scan_and_sync_skills(
// Remove skills that no longer exist in the repo // Remove skills that no longer exist in the repo
let mut removed = 0i64; let mut removed = 0i64;
for (slug, old_skill) in existing_by_slug { for (key, old_skill) in existing_by_hash {
if !seen_slugs.contains(&slug) { if !seen_keys.contains(&key) {
SkillEntity::delete_by_id(old_skill.id).exec(db).await?; SkillEntity::delete_by_id(old_skill.id).exec(db).await?;
removed += 1; removed += 1;
} }

View File

@ -313,10 +313,14 @@ export function RoomProvider({
// Subscribe to room events. connect() is already called at the provider // Subscribe to room events. connect() is already called at the provider
// level — subscribe/unsubscribe only manage per-room event routing. // 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 () => { return () => {
client.unsubscribeRoom(activeRoomId).catch(() => {}); client.unsubscribeRoom(activeRoomId).catch((err) => {
console.warn('[RoomContext] unsubscribeRoom failed:', err);
});
}; };
}, [activeRoomId, wsClient]); }, [activeRoomId, wsClient]);
@ -357,7 +361,9 @@ export function RoomProvider({
); );
} }
}; };
doLoad().catch(() => {}); doLoad().catch((err) => {
console.warn('[RoomContext] loadReactions failed:', err);
});
}; };
const loadMore = useCallback( const loadMore = useCallback(
@ -853,7 +859,9 @@ export function RoomProvider({
const client = wsClientRef.current; const client = wsClientRef.current;
if (client && client.getStatus() !== 'open') { if (client && client.getStatus() !== 'open') {
console.debug('[RoomContext] Tab visible, reconnecting WS...'); 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
});
} }
} }
}; };

View File

@ -9,8 +9,10 @@ interface ThemeContextType {
setTheme: (theme: ThemePreference) => void; setTheme: (theme: ThemePreference) => void;
} }
const getSystemTheme = (): ResolvedTheme => const getSystemTheme = (): ResolvedTheme => {
window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; if (typeof window === 'undefined') return 'light';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
};
const ThemeContext = createContext<ThemeContextType | undefined>(undefined); const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
@ -40,6 +42,7 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
); );
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') return;
const root = window.document.documentElement; const root = window.document.documentElement;
root.classList.remove('light', 'dark'); root.classList.remove('light', 'dark');
root.classList.add(resolvedTheme); root.classList.add(resolvedTheme);

View File

@ -6,6 +6,7 @@ export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined) const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => { React.useEffect(() => {
if (typeof window === 'undefined') return;
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => { const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)

View File

@ -94,7 +94,9 @@ export function detectLinkType(url: string): UnfurlResult | null {
// External URL // External URL
try { try {
const parsed = new URL(url); 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 { return {
type: 'external', type: 'external',
url, url,

View File

@ -235,7 +235,9 @@ export class RoomWsClient {
this.reconnectAttempt = 0; this.reconnectAttempt = 0;
this.setStatus('open'); this.setStatus('open');
this.startHeartbeat(); this.startHeartbeat();
this.resubscribeAll().catch(() => {}); this.resubscribeAll().catch((err) => {
console.warn('[RoomWs] resubscribe failed:', err);
});
resolve(); resolve();
}; };
@ -1002,7 +1004,9 @@ export class RoomWsClient {
sendTyping(roomId: string, action: 'start' | 'stop'): void { sendTyping(roomId: string, action: 'start' | 'stop'): void {
if (this.ws && this.status === 'open') { if (this.ws && this.status === 'open') {
const wsAction = action === 'start' ? 'typing.start' as WsAction : 'typing.stop' as WsAction; 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; this.wsToken = null;
console.debug('[RoomWs] Clearing token after 3 reconnect failures, will fetch fresh'); 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); }, delay);
} }
} }

View File

@ -64,8 +64,8 @@ export class UniversalWsClient {
// Re-subscribe to rooms after reconnect // Re-subscribe to rooms after reconnect
for (const roomId of this.subscribedRooms) { for (const roomId of this.subscribedRooms) {
this.request('room.subscribe', { room_id: roomId }).catch(() => { this.request('room.subscribe', { room_id: roomId }).catch((err) => {
// ignore re-subscribe errors console.warn('[UniversalWs] re-subscribe failed:', err);
}); });
} }
@ -202,7 +202,7 @@ export class UniversalWsClient {
this.reconnectTimer = setTimeout(() => { this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null; this.reconnectTimer = null;
this.connect().catch(() => { this.connect().catch(() => {
// connect() will retry on its own // connect() has its own retry logic; ignore here to avoid duplicate warnings
}); });
}, delay); }, delay);
} }