Compare commits

...

8 Commits

Author SHA1 Message Date
ZhenYi
3f1f0d5e23 chore(service/git): minor fixes in service layer git operations
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
Small adjustments to commit, init, refs, star, and watch operations
in the service layer.
2026-04-27 08:28:27 +08:00
ZhenYi
64dc27161b chore(git): minor fixes and improvements across git library modules
Apply small fixes across multiple git ops files: handle errors, improve
type safety, and refine HTTP handler and SSH git operations.
2026-04-27 08:28:09 +08:00
ZhenYi
a26551343c fix(frontend): refresh WS token after connection failures and handle AI/repo events
Clear wsToken on auth-related close codes (3000-4999), connection
timeout, and after 3 consecutive reconnect failures so the next connect
attempt fetches a fresh token. Add onRoomAiUpdated and onRepoChanged
callbacks that re-fetch AI configs and repo list when pushed via WS.
Fix AI member list to never display raw UUID.
2026-04-26 23:59:07 +08:00
ZhenYi
c8eba28e7a feat(frontend): add repo type to mention autocomplete system
Add 'repo' to MentionType across all editor types, include repos in the
@ trigger pool, add repo badge (green chip), Repos section in the
mention dropdown, and MentionBadge styles. Wire projectRepos from
room context into IMEditor mentionItems.
2026-04-26 23:58:59 +08:00
ZhenYi
adbc0705db feat(room): inject repository details into AI system prompt on mention
When a user mentions a repository in room chat, extract the repo name
from @[repo:name:label] brackets, look up the full repo model from the
database, and inject its details (name, description, default branch,
visibility) into the AI message context. Works independently of
embed_service availability.
2026-04-26 23:58:52 +08:00
ZhenYi
d72019e39f feat(room): add WS events for AI config and repo lifecycle changes
Add RoomAiUpdated, RepoCreated, RepoUpdated, RepoDeleted event types.
Publish RoomAiUpdated after room_ai upsert/delete and repo events
after repo create/update. Always set model_name in AI list response
(fallback to "AI {uuid}" when model lookup fails) so frontend never
displays a raw UUID.
2026-04-26 23:58:33 +08:00
ZhenYi
283835eb26 fix(agent/sync): avoid double /v1/ prefix in model sync URL
When APP_AI_BASIC_URL already ends with /v1 (e.g. openrouter.ai/api/v1),
appending /v1/models produces /v1/v1/models. Detect trailing /v1 and
only append /models in that case.
2026-04-26 23:58:25 +08:00
ZhenYi
c7a8bc0458 refactor(fctool): extract tool modules into standalone fctool crate
Move git_tools, file_tools, and project_tools from libs/service into a
new libs/fctool crate with correct workspace dependencies. Fixes the
rev.len() >= 40 bug in all git tool resolve functions (OID check needs
exact 40-char hex, not just >= 40). Adds 4 new git blob tools
(blob_get, blob_exists, blob_content, blob_create). Fixes parameter
naming inconsistency in repos.rs and adds project_name to list_repos
output. Removes unused excel/pdf/ppt/word file tools.
2026-04-26 23:58:16 +08:00
67 changed files with 950 additions and 1065 deletions

26
Cargo.lock generated
View File

@ -2757,6 +2757,31 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "fctool"
version = "0.2.9"
dependencies = [
"agent",
"base64 0.22.1",
"chrono",
"csv",
"db",
"git",
"git2",
"models",
"pulldown-cmark 0.12.2",
"quick-xml 0.37.5",
"regex",
"reqwest 0.13.2",
"sea-orm",
"serde",
"serde_json",
"sqlparser",
"tokio",
"tracing",
"uuid",
]
[[package]]
name = "fdeflate"
version = "0.3.7"
@ -7705,6 +7730,7 @@ dependencies = [
"db",
"deadpool-redis",
"email",
"fctool",
"flate2",
"futures",
"git",

View File

@ -19,6 +19,7 @@ members = [
"libs/agent",
"libs/migrate",
"libs/agent-tool-derive",
"libs/fctool",
"apps/migrate",
"apps/app",
"apps/adminrpc",
@ -50,6 +51,7 @@ observability = { path = "libs/observability" }
avatar = { path = "libs/avatar" }
migrate = { path = "libs/migrate" }
session_manager = { path = "libs/session_manager" }
fctool = { path = "libs/fctool" }
sea-query = "1.0.0-rc.31"

View File

@ -605,19 +605,37 @@ impl ChatService {
}
}
if let Some(embed_service) = &self.embed_service {
for mention in &request.mention {
match mention {
Mention::Repo(repo) => {
for mention in &request.mention {
match mention {
Mention::Repo(repo) => {
// Inject repo details into system prompt so AI knows the repo context
let mut parts = vec![
format!("Name: {}", repo.repo_name),
format!("ID: {}", repo.id),
];
if let Some(ref desc) = repo.description {
parts.push(format!("Description: {}", desc));
}
parts.push(format!("Default branch: {}", repo.default_branch));
parts.push(format!("Private: {}", if repo.is_private { "yes" } else { "no" }));
parts.push(format!("Created: {}", repo.created_at.format("%Y-%m-%d")));
messages.push(ChatRequestMessage::system(format!(
"Mentioned repository:\n{}",
parts.join("\n")
)));
// Vector search for related issues and repos (enhancement, optional)
if let Some(embed_service) = &self.embed_service {
let query = format!(
"{} {}",
repo.repo_name,
repo.description.as_deref().unwrap_or_default()
);
match embed_service.search_issues(&query, 5).await {
Ok(issues) if !issues.is_empty() => {
if let Ok(issues) = embed_service.search_issues(&query, 5).await {
if !issues.is_empty() {
let context = format!(
"Related issues:\n{}",
"Related issues for repo {}:\n{}",
repo.repo_name,
issues
.iter()
.map(|i| format!("- {}", i.payload.text))
@ -626,15 +644,11 @@ impl ChatService {
);
messages.push(ChatRequestMessage::system(context));
}
Err(e) => {
let _ = e;
}
_ => {}
}
match embed_service.search_repos(&query, 3).await {
Ok(repos) if !repos.is_empty() => {
if let Ok(repos) = embed_service.search_repos(&query, 3).await {
if !repos.is_empty() {
let context = format!(
"Related repositories:\n{}",
"Similar repositories:\n{}",
repos
.iter()
.map(|r| format!("- {}", r.payload.text))
@ -643,28 +657,24 @@ impl ChatService {
);
messages.push(ChatRequestMessage::system(context));
}
Err(e) => {
let _ = e;
}
_ => {}
}
}
Mention::User(user) => {
let mut profile_parts = vec![format!("Username: {}", user.username)];
if let Some(ref display_name) = user.display_name {
profile_parts.push(format!("Display name: {}", display_name));
}
if let Some(ref org) = user.organization {
profile_parts.push(format!("Organization: {}", org));
}
if let Some(ref website) = user.website_url {
profile_parts.push(format!("Website: {}", website));
}
messages.push(ChatRequestMessage::system(format!(
"Mentioned user profile:\n{}",
profile_parts.join("\n")
)));
}
Mention::User(user) => {
let mut profile_parts = vec![format!("Username: {}", user.username)];
if let Some(ref display_name) = user.display_name {
profile_parts.push(format!("Display name: {}", display_name));
}
if let Some(ref org) = user.organization {
profile_parts.push(format!("Organization: {}", org));
}
if let Some(ref website) = user.website_url {
profile_parts.push(format!("Website: {}", website));
}
messages.push(ChatRequestMessage::system(format!(
"Mentioned user profile:\n{}",
profile_parts.join("\n")
)));
}
}
}

View File

@ -19,7 +19,12 @@ pub async fn list_accessible_models(
base_url: &str,
api_key: &str,
) -> Result<std::collections::HashSet<String>, AgentError> {
let url = format!("{}/v1/models", base_url.trim_end_matches('/'));
let base = base_url.trim_end_matches('/');
let url = if base.ends_with("/v1") {
format!("{}/models", base)
} else {
format!("{}/v1/models", base)
};
let resp = client
.get(&url)
.header("Authorization", format!("Bearer {}", api_key))

39
libs/fctool/Cargo.toml Normal file
View File

@ -0,0 +1,39 @@
[package]
name = "fctool"
version.workspace = true
edition.workspace = true
authors.workspace = true
description.workspace = true
repository.workspace = true
readme.workspace = true
homepage.workspace = true
license.workspace = true
keywords.workspace = true
categories.workspace = true
documentation.workspace = true
[lib]
path = "src/lib.rs"
name = "fctool"
[dependencies]
agent = { workspace = true }
git = { workspace = true }
models = { workspace = true }
db = { workspace = true }
sea-orm = { workspace = true, features = [] }
git2 = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
base64 = { workspace = true }
chrono = { workspace = true, features = ["serde"] }
uuid = { workspace = true, features = ["serde", "v7"] }
reqwest = { workspace = true, features = ["json", "native-tls"] }
regex = { workspace = true }
csv = { workspace = true }
quick-xml = { workspace = true }
sqlparser = { workspace = true }
pulldown-cmark = { workspace = true }
tokio = { workspace = true, features = ["rt", "rt-multi-thread"] }
tracing = { workspace = true }

View File

@ -51,7 +51,7 @@ async fn read_csv_exec(
let domain = ctx.open_repo(project_name, repo_name).await?;
let commit_oid = if rev.len() >= 40 {
let commit_oid = if rev.len() == 40 && rev.chars().all(|c| c.is_ascii_hexdigit()) {
git::commit::types::CommitOid::new(&rev)
} else {
domain

View File

@ -67,7 +67,7 @@ async fn git_grep_exec(
let domain = ctx.open_repo(project_name, repo_name).await?;
// Resolve revision to commit oid
let commit_oid = if rev.len() >= 40 {
let commit_oid = if rev.len() == 40 && rev.chars().all(|c| c.is_ascii_hexdigit()) {
git::commit::types::CommitOid::new(&rev)
} else {
domain

View File

@ -130,7 +130,7 @@ async fn read_json_exec(
let domain = ctx.open_repo(project_name, repo_name).await?;
let commit_oid = if rev.len() >= 40 {
let commit_oid = if rev.len() == 40 && rev.chars().all(|c| c.is_ascii_hexdigit()) {
git::commit::types::CommitOid::new(&rev)
} else {
domain

View File

@ -41,7 +41,7 @@ async fn read_markdown_exec(
let domain = ctx.open_repo(project_name, repo_name).await?;
let commit_oid = if rev.len() >= 40 {
let commit_oid = if rev.len() == 40 && rev.chars().all(|c| c.is_ascii_hexdigit()) {
git::commit::types::CommitOid::new(&rev)
} else {
domain

View File

@ -33,7 +33,7 @@ async fn read_sql_exec(
let domain = ctx.open_repo(project_name, repo_name).await?;
let commit_oid = if rev.len() >= 40 {
let commit_oid = if rev.len() == 40 && rev.chars().all(|c| c.is_ascii_hexdigit()) {
git::commit::types::CommitOid::new(&rev)
} else {
domain

View File

@ -0,0 +1,273 @@
//! Git blob tools — raw object-level operations on blob OIDs.
use super::ctx::GitToolCtx;
use agent::{ToolDefinition, ToolHandler, ToolParam, ToolRegistry, ToolSchema};
use base64::Engine;
use std::collections::HashMap;
async fn git_blob_info_exec(
ctx: GitToolCtx,
args: serde_json::Value,
) -> Result<serde_json::Value, String> {
let p: serde_json::Map<String, serde_json::Value> =
serde_json::from_value(args).map_err(|e| e.to_string())?;
let project_name = p.get("project_name").and_then(|v| v.as_str()).ok_or("missing project_name")?;
let repo_name = p.get("repo_name").and_then(|v| v.as_str()).ok_or("missing repo_name")?;
let oid = p.get("oid").and_then(|v| v.as_str()).ok_or("missing oid")?;
let domain = ctx.open_repo(project_name, repo_name).await?;
let commit_oid = resolve_oid(&domain, oid)?;
let info = domain.blob_get(&commit_oid).map_err(|e| e.to_string())?;
Ok(serde_json::json!({
"oid": info.oid.to_string(),
"size": info.size,
"is_binary": info.is_binary,
}))
}
async fn git_blob_exists_exec(
ctx: GitToolCtx,
args: serde_json::Value,
) -> Result<serde_json::Value, String> {
let p: serde_json::Map<String, serde_json::Value> =
serde_json::from_value(args).map_err(|e| e.to_string())?;
let project_name = p.get("project_name").and_then(|v| v.as_str()).ok_or("missing project_name")?;
let repo_name = p.get("repo_name").and_then(|v| v.as_str()).ok_or("missing repo_name")?;
let oid = p.get("oid").and_then(|v| v.as_str()).ok_or("missing oid")?;
let domain = ctx.open_repo(project_name, repo_name).await?;
let commit_oid = resolve_oid(&domain, oid)?;
let exists = domain.blob_exists(&commit_oid);
Ok(serde_json::json!({ "oid": commit_oid.to_string(), "exists": exists }))
}
async fn git_blob_content_exec(
ctx: GitToolCtx,
args: serde_json::Value,
) -> Result<serde_json::Value, String> {
let p: serde_json::Map<String, serde_json::Value> =
serde_json::from_value(args).map_err(|e| e.to_string())?;
let project_name = p.get("project_name").and_then(|v| v.as_str()).ok_or("missing project_name")?;
let repo_name = p.get("repo_name").and_then(|v| v.as_str()).ok_or("missing repo_name")?;
let oid = p.get("oid").and_then(|v| v.as_str()).ok_or("missing oid")?;
let max_size = p.get("max_size").and_then(|v| v.as_u64()).unwrap_or(1_048_576) as usize; // 1MB default
let domain = ctx.open_repo(project_name, repo_name).await?;
let commit_oid = resolve_oid(&domain, oid)?;
let blob = domain.blob_content(&commit_oid).map_err(|e| e.to_string())?;
if blob.size > max_size {
return Err(format!(
"blob too large ({} bytes), max {} bytes. Use a smaller max_size or retrieve the raw OID.",
blob.size, max_size
));
}
let (content, is_binary) = if blob.is_binary {
(base64::engine::general_purpose::STANDARD.encode(&blob.content), true)
} else {
(String::from_utf8_lossy(&blob.content).to_string(), false)
};
Ok(serde_json::json!({
"oid": blob.oid.to_string(),
"size": blob.size,
"is_binary": is_binary,
"content": content,
}))
}
async fn git_blob_create_exec(
ctx: GitToolCtx,
args: serde_json::Value,
) -> Result<serde_json::Value, String> {
let p: serde_json::Map<String, serde_json::Value> =
serde_json::from_value(args).map_err(|e| e.to_string())?;
let project_name = p.get("project_name").and_then(|v| v.as_str()).ok_or("missing project_name")?;
let repo_name = p.get("repo_name").and_then(|v| v.as_str()).ok_or("missing repo_name")?;
let content = p.get("content").and_then(|v| v.as_str()).ok_or("missing content")?;
let encoding = p.get("encoding").and_then(|v| v.as_str()).unwrap_or("utf-8");
let data = match encoding {
"base64" => base64::engine::general_purpose::STANDARD
.decode(content)
.map_err(|e| format!("invalid base64: {}", e))?,
"utf-8" => content.as_bytes().to_vec(),
other => return Err(format!("unsupported encoding '{}'. Use 'utf-8' or 'base64'.", other)),
};
let domain = ctx.open_repo(project_name, repo_name).await?;
let oid = domain.blob_create(&data).map_err(|e| e.to_string())?;
let info = domain.blob_get(&oid).map_err(|e| e.to_string())?;
Ok(serde_json::json!({
"oid": info.oid.to_string(),
"size": info.size,
"is_binary": info.is_binary,
}))
}
fn resolve_oid(
domain: &git::GitDomain,
rev: &str,
) -> Result<git::commit::types::CommitOid, String> {
if rev.len() == 40 && rev.chars().all(|c| c.is_ascii_hexdigit()) {
Ok(git::commit::types::CommitOid::new(rev))
} else {
domain.commit_get_prefix(rev).map_err(|e| e.to_string()).map(|m| m.oid)
}
}
pub fn register_git_tools(registry: &mut ToolRegistry) {
// git_blob_info
let p = HashMap::from([
("project_name".into(), ToolParam {
name: "project_name".into(), param_type: "string".into(),
description: Some("Project name (slug)".into()),
required: true, properties: None, items: None,
}),
("repo_name".into(), ToolParam {
name: "repo_name".into(), param_type: "string".into(),
description: Some("Repository name".into()),
required: true, properties: None, items: None,
}),
("oid".into(), ToolParam {
name: "oid".into(), param_type: "string".into(),
description: Some("Blob OID (full 40-char hex or short prefix)".into()),
required: true, properties: None, items: None,
}),
]);
let schema = ToolSchema {
schema_type: "object".into(),
properties: Some(p),
required: Some(vec!["project_name".into(), "repo_name".into(), "oid".into()]),
};
registry.register(
ToolDefinition::new("git_blob_info")
.description("Get metadata about a git blob by its OID. Returns size and whether the blob is binary.")
.parameters(schema),
ToolHandler::new(|ctx, args| {
let gctx = GitToolCtx::new(ctx);
Box::pin(async move {
git_blob_info_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError)
})
}),
);
// git_blob_exists
let p = HashMap::from([
("project_name".into(), ToolParam {
name: "project_name".into(), param_type: "string".into(),
description: Some("Project name (slug)".into()),
required: true, properties: None, items: None,
}),
("repo_name".into(), ToolParam {
name: "repo_name".into(), param_type: "string".into(),
description: Some("Repository name".into()),
required: true, properties: None, items: None,
}),
("oid".into(), ToolParam {
name: "oid".into(), param_type: "string".into(),
description: Some("Blob OID to check".into()),
required: true, properties: None, items: None,
}),
]);
let schema = ToolSchema {
schema_type: "object".into(),
properties: Some(p),
required: Some(vec!["project_name".into(), "repo_name".into(), "oid".into()]),
};
registry.register(
ToolDefinition::new("git_blob_exists")
.description("Check whether a git blob exists in the repository by its OID.")
.parameters(schema),
ToolHandler::new(|ctx, args| {
let gctx = GitToolCtx::new(ctx);
Box::pin(async move {
git_blob_exists_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError)
})
}),
);
// git_blob_content
let p = HashMap::from([
("project_name".into(), ToolParam {
name: "project_name".into(), param_type: "string".into(),
description: Some("Project name (slug)".into()),
required: true, properties: None, items: None,
}),
("repo_name".into(), ToolParam {
name: "repo_name".into(), param_type: "string".into(),
description: Some("Repository name".into()),
required: true, properties: None, items: None,
}),
("oid".into(), ToolParam {
name: "oid".into(), param_type: "string".into(),
description: Some("Blob OID to retrieve content for".into()),
required: true, properties: None, items: None,
}),
("max_size".into(), ToolParam {
name: "max_size".into(), param_type: "integer".into(),
description: Some("Maximum blob size in bytes (default: 1MB)".into()),
required: false, properties: None, items: None,
}),
]);
let schema = ToolSchema {
schema_type: "object".into(),
properties: Some(p),
required: Some(vec!["project_name".into(), "repo_name".into(), "oid".into()]),
};
registry.register(
ToolDefinition::new("git_blob_content")
.description("Retrieve the raw content of a git blob by its OID. Binary content is base64-encoded. Limits to 1MB by default.")
.parameters(schema),
ToolHandler::new(|ctx, args| {
let gctx = GitToolCtx::new(ctx);
Box::pin(async move {
git_blob_content_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError)
})
}),
);
// git_blob_create
let p = HashMap::from([
("project_name".into(), ToolParam {
name: "project_name".into(), param_type: "string".into(),
description: Some("Project name (slug)".into()),
required: true, properties: None, items: None,
}),
("repo_name".into(), ToolParam {
name: "repo_name".into(), param_type: "string".into(),
description: Some("Repository name".into()),
required: true, properties: None, items: None,
}),
("content".into(), ToolParam {
name: "content".into(), param_type: "string".into(),
description: Some("Blob content (utf-8 string or base64-encoded bytes)".into()),
required: true, properties: None, items: None,
}),
("encoding".into(), ToolParam {
name: "encoding".into(), param_type: "string".into(),
description: Some("Encoding of content: 'utf-8' (default) or 'base64'".into()),
required: false, properties: None, items: None,
}),
]);
let schema = ToolSchema {
schema_type: "object".into(),
properties: Some(p),
required: Some(vec!["project_name".into(), "repo_name".into(), "content".into()]),
};
registry.register(
ToolDefinition::new("git_blob_create")
.description("Create a new git blob in the repository. Writes the raw content to the object database and returns the new blob OID. Supports both utf-8 text and base64-encoded binary content.")
.parameters(schema),
ToolHandler::new(|ctx, args| {
let gctx = GitToolCtx::new(ctx);
Box::pin(async move {
git_blob_create_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError)
})
}),
);
}

View File

@ -35,8 +35,10 @@ async fn git_branch_info_exec(ctx: GitToolCtx, args: serde_json::Value) -> Resul
let info = domain.branch_get(name).map_err(|e| e.to_string())?;
let ahead_behind = if let Some(ref upstream) = info.upstream {
let (ahead, behind) = domain.branch_ahead_behind(name, upstream).unwrap_or((0, 0));
Some(serde_json::json!({ "ahead": ahead, "behind": behind }))
match domain.branch_ahead_behind(name, upstream) {
Ok((ahead, behind)) => Some(serde_json::json!({ "ahead": ahead, "behind": behind })),
Err(e) => Some(serde_json::json!({ "error": e.to_string() })),
}
} else { None };
Ok(serde_json::json!({

View File

@ -47,6 +47,16 @@ async fn git_log_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde_
Ok(serde_json::to_value(result).map_err(|e| e.to_string())?)
}
/// Resolve a rev string to commit metadata. Tries full OID first (exactly 40 hex chars),
/// falls back to prefix lookup (branch, tag, short hash).
fn resolve_commit(domain: &git::GitDomain, rev: &str) -> Result<git::commit::types::CommitMeta, String> {
if rev.len() == 40 && rev.chars().all(|c| c.is_ascii_hexdigit()) {
domain.commit_get(&git::commit::types::CommitOid::new(rev)).map_err(|e| e.to_string())
} else {
domain.commit_get_prefix(rev).map_err(|e| e.to_string())
}
}
async fn git_show_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde_json::Value, String> {
let p: serde_json::Map<String, serde_json::Value> = serde_json::from_value(args).map_err(|e| e.to_string())?;
let project_name = p.get("project_name").and_then(|v| v.as_str()).ok_or("missing project_name")?;
@ -54,11 +64,7 @@ async fn git_show_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde
let rev = p.get("rev").and_then(|v| v.as_str()).ok_or("missing rev")?;
let domain = ctx.open_repo(project_name, repo_name).await?;
let meta = if rev.len() >= 40 {
domain.commit_get(&git::commit::types::CommitOid::new(rev)).map_err(|e| e.to_string())?
} else {
domain.commit_get_prefix(rev).map_err(|e| e.to_string())?
};
let meta = resolve_commit(&domain, rev).map_err(|e| e.to_string())?;
let refs = domain.commit_refs(&meta.oid).map_err(|e| e.to_string())?;
@ -128,11 +134,7 @@ async fn git_commit_info_exec(ctx: GitToolCtx, args: serde_json::Value) -> Resul
let rev = p.get("rev").and_then(|v| v.as_str()).ok_or("missing rev")?;
let domain = ctx.open_repo(project_name, repo_name).await?;
let meta = if rev.len() >= 40 {
domain.commit_get(&git::commit::types::CommitOid::new(rev)).map_err(|e| e.to_string())?
} else {
domain.commit_get_prefix(rev).map_err(|e| e.to_string())?
};
let meta = resolve_commit(&domain, rev).map_err(|e| e.to_string())?;
Ok(flatten_commit(&meta))
}
@ -195,9 +197,11 @@ async fn git_reflog_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<ser
let result: Vec<_> = entries.iter()
.take(limit)
.map(|e| {
let ts = e.time_secs;
// Convert to UTC by subtracting the timezone offset, consistent
// with all other timestamp conversions in this module.
let ts = e.time_secs - (e.offset_minutes as i64 * 60);
let time_str = chrono::Utc.timestamp_opt(ts, 0).single()
.map(|dt| dt.to_rfc3339()).unwrap_or_else(|| format!("{}", ts));
.map(|dt| dt.to_rfc3339()).unwrap_or_else(|| format!("{}", e.time_secs));
serde_json::json!({
"oid_new": e.oid_new.to_string(), "oid_old": e.oid_old.to_string(),
"committer_name": e.committer_name, "committer_email": e.committer_email,

View File

@ -17,7 +17,7 @@ async fn git_diff_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde
let domain = ctx.open_repo(project_name, repo_name).await?;
let resolve = |rev: &str| -> Result<git::commit::types::CommitOid, String> {
if rev.len() >= 40 {
if rev.len() == 40 && rev.chars().all(|c| c.is_ascii_hexdigit()) {
Ok(git::commit::types::CommitOid::new(rev))
} else {
domain.commit_get_prefix(rev).map_err(|e| e.to_string()).map(|m| m.oid)
@ -68,8 +68,14 @@ async fn git_diff_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde
}
};
use git::diff::types::DiffDeltaStatus;
let files: Vec<_> = result.deltas.iter().map(|d| {
serde_json::json!({ "path": d.new_file.path, "status": format!("{:?}", d.status), "is_binary": d.new_file.is_binary })
let (path, is_binary) = if d.status == DiffDeltaStatus::Deleted {
(d.old_file.path.clone(), d.old_file.is_binary)
} else {
(d.new_file.path.clone(), d.new_file.is_binary)
};
serde_json::json!({ "path": path, "status": format!("{:?}", d.status), "is_binary": is_binary })
}).collect();
Ok(serde_json::json!({
@ -87,22 +93,16 @@ async fn git_diff_stats_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result
let domain = ctx.open_repo(project_name, repo_name).await?;
let stats = if base.len() >= 40 && head.len() >= 40 {
domain.diff_stats(&git::commit::types::CommitOid::new(base), &git::commit::types::CommitOid::new(head))
.map_err(|e| e.to_string())?
} else {
let b = if base.len() >= 40 {
git::commit::types::CommitOid::new(base)
let resolve = |rev: &str| -> Result<git::commit::types::CommitOid, String> {
if rev.len() == 40 && rev.chars().all(|c| c.is_ascii_hexdigit()) {
Ok(git::commit::types::CommitOid::new(rev))
} else {
domain.commit_get_prefix(base).map_err(|e| e.to_string())?.oid
};
let h = if head.len() >= 40 {
git::commit::types::CommitOid::new(head)
} else {
domain.commit_get_prefix(head).map_err(|e| e.to_string())?.oid
};
domain.diff_stats(&b, &h).map_err(|e| e.to_string())?
domain.commit_get_prefix(rev).map_err(|e| e.to_string()).map(|m| m.oid)
}
};
let b = resolve(base).map_err(|e| e.to_string())?;
let h = resolve(head).map_err(|e| e.to_string())?;
let stats = domain.diff_stats(&b, &h).map_err(|e| e.to_string())?;
Ok(serde_json::json!({
"files_changed": stats.files_changed,
@ -121,11 +121,11 @@ async fn git_blame_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serd
let to_line = p.get("to_line").and_then(|v| v.as_u64().map(|n| n as u32));
let domain = ctx.open_repo(project_name, repo_name).await?;
let oid = if rev.len() >= 40 {
git::commit::types::CommitOid::new(&rev)
let oid = if rev.len() == 40 && rev.chars().all(|c| c.is_ascii_hexdigit()) {
Ok(git::commit::types::CommitOid::new(&rev))
} else {
domain.commit_get_prefix(&rev).map_err(|e| e.to_string())?.oid
};
domain.commit_get_prefix(&rev).map_err(|e| e.to_string()).map(|m| m.oid)
}?;
use git::blame::ops::BlameOptions;
let mut bopts = BlameOptions::new();

View File

@ -3,6 +3,7 @@
//! Each module defines async exec functions + a `register_git_tools()` call.
//! All tools take `project_name` + `repo_name` as required params.
pub mod blob;
pub mod branch;
pub mod commit;
pub mod ctx;
@ -16,6 +17,7 @@ pub fn register_all(registry: &mut agent::ToolRegistry) {
commit::register_git_tools(registry);
branch::register_git_tools(registry);
diff::register_git_tools(registry);
blob::register_git_tools(registry);
tree::register_git_tools(registry);
tag::register_git_tools(registry);
}

View File

@ -16,12 +16,19 @@ async fn git_tag_list_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<s
let result: Vec<_> = match pattern {
Some(ref pat) => {
let pat_lower = pat.to_lowercase();
let has_wildcard = pat.contains('*');
// Convert glob pattern (only * wildcards) to regex for proper matching.
// "*" matches any sequence of characters.
let regex_pat = pat_lower
.split('*')
.map(|s| regex::escape(s))
.collect::<Vec<_>>()
.join(".*");
let re = regex::Regex::new(&format!("^{}$", regex_pat))
.ok();
all_tags.iter()
.filter(|t| {
let n = t.name.to_lowercase();
if has_wildcard { n.contains(&pat_lower.replace('*', "")) }
else { n.contains(&pat_lower) }
re.as_ref().map(|r| r.is_match(&n)).unwrap_or(false)
})
.map(|t| tag_to_json(t))
.collect()

View File

@ -5,6 +5,16 @@ use agent::{ToolDefinition, ToolHandler, ToolParam, ToolRegistry, ToolSchema};
use base64::Engine;
use std::collections::HashMap;
/// Resolve a rev string to a commit OID. Tries full OID first (exactly 40 hex chars),
/// falls back to prefix lookup (branch, tag, short hash).
fn resolve_commit_oid(domain: &git::GitDomain, rev: &str) -> Result<git::commit::types::CommitOid, String> {
if rev.len() == 40 && rev.chars().all(|c| c.is_ascii_hexdigit()) {
Ok(git::commit::types::CommitOid::new(rev))
} else {
domain.commit_get_prefix(rev).map_err(|e| e.to_string()).map(|m| m.oid)
}
}
async fn git_file_content_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde_json::Value, String> {
let p: serde_json::Map<String, serde_json::Value> = serde_json::from_value(args).map_err(|e| e.to_string())?;
let project_name = p.get("project_name").and_then(|v| v.as_str()).ok_or("missing project_name")?;
@ -13,11 +23,7 @@ async fn git_file_content_exec(ctx: GitToolCtx, args: serde_json::Value) -> Resu
let rev = p.get("rev").and_then(|v| v.as_str()).map(|s| s.to_string()).unwrap_or_else(|| "HEAD".to_string());
let domain = ctx.open_repo(project_name, repo_name).await?;
let oid = if rev.len() >= 40 {
git::commit::types::CommitOid::new(&rev)
} else {
domain.commit_get_prefix(&rev).map_err(|e| e.to_string())?.oid
};
let oid = resolve_commit_oid(&domain, &rev)?;
let entry = domain.tree_entry_by_path_from_commit(&oid, path).map_err(|e| e.to_string())?;
let blob_info = domain.blob_get(&entry.oid).map_err(|e| e.to_string())?;
@ -46,11 +52,7 @@ async fn git_tree_ls_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<se
let rev = p.get("rev").and_then(|v| v.as_str()).map(|s| s.to_string()).unwrap_or_else(|| "HEAD".to_string());
let domain = ctx.open_repo(project_name, repo_name).await?;
let commit_oid = if rev.len() >= 40 {
git::commit::types::CommitOid::new(&rev)
} else {
domain.commit_get_prefix(&rev).map_err(|e| e.to_string())?.oid
};
let commit_oid = resolve_commit_oid(&domain, &rev).map_err(|e| e.to_string())?;
let entries = match dir_path {
Some(ref dp) => {
@ -102,11 +104,7 @@ async fn git_blob_get_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<s
let rev = p.get("rev").and_then(|v| v.as_str()).map(String::from).unwrap_or_else(|| "HEAD".to_string());
let domain = ctx.open_repo(project_name, repo_name).await?;
let oid = if rev.len() >= 40 {
git::commit::types::CommitOid::new(&rev)
} else {
domain.commit_get_prefix(&rev).map_err(|e| e.to_string())?.oid
};
let oid = resolve_commit_oid(&domain, &rev).map_err(|e| e.to_string())?;
let entry = domain.tree_entry_by_path_from_commit(&oid, path).map_err(|e| e.to_string())?;
let blob_info = domain.blob_get(&entry.oid).map_err(|e| e.to_string())?;

View File

@ -217,16 +217,24 @@ pub struct DiffFileOut {
impl DiffFileOut {
pub fn from_delta(delta: &DiffDelta) -> Self {
// For deleted files, use old_file.path; for all others, use new_file.path.
let path = match delta.status {
DiffDeltaStatus::Deleted => delta.old_file.path.clone(),
_ => delta.new_file.path.clone(),
// For deleted files, use old_file for all metadata; for all others, use new_file.
let (path, is_binary, size) = match delta.status {
DiffDeltaStatus::Deleted => (
delta.old_file.path.clone(),
delta.old_file.is_binary,
delta.old_file.size,
),
_ => (
delta.new_file.path.clone(),
delta.new_file.is_binary,
delta.new_file.size,
),
};
Self {
path,
status: format!("{:?}", delta.status),
is_binary: delta.new_file.is_binary,
size: delta.new_file.size,
is_binary,
size,
}
}
}

5
libs/fctool/src/lib.rs Normal file
View File

@ -0,0 +1,5 @@
//! AI agent function-call tools: git operations, file parsing/search, and project management.
pub mod git_tools;
pub mod file_tools;
pub mod project_tools;

View File

@ -23,6 +23,14 @@ pub async fn list_repos_exec(
let project_id = ctx.project_id();
let db = ctx.db();
// Resolve project name so the AI can use it for git_tools operations
let project = models::projects::project::Entity::find_by_id(project_id)
.one(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?
.ok_or_else(|| ToolError::ExecutionError("Project not found".into()))?;
let project_name = project.name.clone();
let repos = repo::Entity::find()
.filter(repo::Column::Project.eq(project_id))
.order_by_asc(repo::Column::RepoName)
@ -36,6 +44,7 @@ pub async fn list_repos_exec(
serde_json::json!({
"id": r.id.to_string(),
"name": r.repo_name,
"project_name": project_name,
"description": r.description,
"default_branch": r.default_branch,
"is_private": r.is_private,
@ -293,8 +302,9 @@ pub async fn create_commit_exec(
let repo_name = args
.get("repo_name")
.or_else(|| args.get("name"))
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::ExecutionError("repo_name is required".into()))?;
.ok_or_else(|| ToolError::ExecutionError("repo_name (or name) is required".into()))?;
let message = args
.get("message")
@ -308,10 +318,12 @@ pub async fn create_commit_exec(
.unwrap_or("main")
.to_string();
// Validate branch: no path traversal, no slashes
if branch.contains("..") || branch.contains('/') || branch.contains('\\') || branch.is_empty() {
// Validate branch: no path traversal, no backslashes, not empty, no lock files
if branch.contains("..") || branch.contains('\\') || branch.is_empty()
|| branch.ends_with(".lock") || branch.starts_with('-')
{
return Err(ToolError::ExecutionError(
"Invalid branch name: must not contain path separators or '..'".into(),
"Invalid branch name: must not contain '..' or backslashes, and must not be empty".into(),
));
}
@ -574,9 +586,14 @@ pub fn create_commit_tool_definition() -> ToolDefinition {
let mut p = HashMap::new();
p.insert("repo_name".into(), ToolParam {
name: "repo_name".into(), param_type: "string".into(),
description: Some("Repository name (required).".into()),
description: Some("Repository name. Can also use 'name' as alias. Required.".into()),
required: true, properties: None, items: None,
});
p.insert("name".into(), ToolParam {
name: "name".into(), param_type: "string".into(),
description: Some("Alias for repo_name. Use the same value as returned by project_list_repos.".into()),
required: false, properties: None, items: None,
});
p.insert("branch".into(), ToolParam {
name: "branch".into(), param_type: "string".into(),
description: Some("Branch to commit to. Defaults to 'main'. Optional.".into()),

View File

@ -266,7 +266,10 @@ impl GitDomain {
let oid = entry.id();
let obj = match self.repo().find_object(oid, None) {
Ok(o) => o,
Err(_) => continue,
Err(e) => {
tracing::warn!("archive_skip_missing_object oid={} path={} error={}", oid, full_path, e);
continue;
}
};
let mode = entry.filemode() as u32;
@ -381,7 +384,10 @@ impl GitDomain {
let oid = entry.id();
let obj = match self.repo().find_object(oid, None) {
Ok(o) => o,
Err(_) => continue,
Err(e) => {
tracing::warn!("archive_skip_missing_object oid={} path={} error={}", oid, full_path, e);
continue;
}
};
let mode = entry.filemode() as u32;
@ -408,7 +414,7 @@ impl GitDomain {
.set_path(&full_path)
.map_err(|e| GitError::Internal(e.to_string()))?;
header.set_size(content.len() as u64);
header.set_mode(mode & 0o755);
header.set_mode(mode & 0o777);
header.set_cksum();
builder
@ -457,7 +463,10 @@ impl GitDomain {
let oid = entry.id();
let obj = match self.repo().find_object(oid, None) {
Ok(o) => o,
Err(_) => continue,
Err(e) => {
tracing::warn!("archive_skip_missing_object oid={} path={} error={}", oid, full_path, e);
continue;
}
};
let mode = entry.filemode() as u32;
@ -480,7 +489,7 @@ impl GitDomain {
let content = blob.content();
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated)
.unix_permissions(mode & 0o755);
.unix_permissions(mode & 0o777);
zip.start_file(&full_path, options)
.map_err(|e| GitError::Internal(e.to_string()))?;
@ -511,14 +520,17 @@ impl GitDomain {
continue;
}
if opts.max_depth.map_or(false, |d| depth >= d) {
if opts.max_depth.map_or(false, |d| depth > d) {
continue;
}
let oid = entry.id();
let obj = match self.repo().find_object(oid, None) {
Ok(o) => o,
Err(_) => continue,
Err(e) => {
tracing::warn!("archive_list_skip_missing_object oid={} path={} error={}", oid, full_path, e);
continue;
}
};
let mode = entry.filemode() as u32;

View File

@ -63,6 +63,9 @@ impl BlameOptions {
}
impl GitDomain {
/// Blame a file. Note: git2's `blame_file` always operates on HEAD,
/// not an arbitrary commit. The `commit_oid` parameter is only
/// validated for existence; blame results reflect the current HEAD.
pub fn blame_file(
&self,
commit_oid: &CommitOid,
@ -238,8 +241,8 @@ impl GitDomain {
.blame_file(std::path::Path::new(path), Some(&mut blame_opts))
.map_err(|e| GitError::Internal(e.to_string()))?;
// Use get_line to find the hunk at the given line
let hunk_opt = blame.get_line(line_no);
// get_line expects 1-based line numbers; caller provides 0-based.
let hunk_opt = blame.get_line(line_no + 1);
match hunk_opt {
Some(hunk) => Ok(CommitBlameHunk {

View File

@ -58,11 +58,8 @@ impl GitDomain {
.repo()
.find_commit(oid.to_oid()?)
.map_err(|_e| GitError::ObjectNotFound(oid.to_string()))?;
let len = oid.0.len();
if len < 7 {
return Err(GitError::InvalidOid(oid.to_string()));
}
Ok(oid.0[..7].to_string())
let take = 7.min(oid.0.len());
Ok(oid.0[..take].to_string())
}
pub fn commit_author(&self, oid: &CommitOid) -> GitResult<CommitSignature> {

View File

@ -129,8 +129,8 @@ impl GitDomain {
}
pub fn rebase_abort(&self) -> GitResult<()> {
// git2 rebase sessions are not persistent across process exits.
// The caller resets HEAD to the original position.
Ok(())
self.repo()
.cleanup_state()
.map_err(|e| GitError::Internal(e.to_string()))
}
}

View File

@ -21,9 +21,17 @@ impl GitDomain {
pub fn config_get(&self, key: &str) -> GitResult<Option<String>> {
let cfg = self.config()?;
cfg.get_str(key)
.map(Some)
.map_err(|e| GitError::ConfigError(e.to_string()))
match cfg.get_str(key) {
Ok(v) => Ok(Some(v)),
Err(e) => {
// git2 returns an error for not-found keys.
if e.code() == git2::ErrorCode::NotFound {
Ok(None)
} else {
Err(GitError::ConfigError(e.to_string()))
}
}
}
}
pub fn config_set(&self, key: &str, value: &str) -> GitResult<()> {

View File

@ -281,7 +281,7 @@ impl DiffOptions {
pub fn to_git2(&self) -> git2::DiffOptions {
let mut opts = git2::DiffOptions::new();
if self.context_lines != 3 {
if self.context_lines > 0 {
opts.context_lines(self.context_lines);
}
for p in &self.pathspec {

View File

@ -16,9 +16,14 @@ const POOL_GET_TIMEOUT: Duration = Duration::from_secs(5);
impl RedisConsumer {
pub fn new(
pool: deadpool_redis::cluster::Pool,
prefix: String,
mut prefix: String,
block_timeout_secs: u64,
) -> Self {
// Redis Cluster requires hash tags ({...}) for multi-key commands
// like BLMOVE and Lua scripts to ensure keys hash to the same slot.
if !prefix.contains('{') {
prefix = format!("{{{}}}", prefix);
}
Self {
pool,
prefix,

View File

@ -20,6 +20,13 @@ pub fn is_valid_oid(oid: &str) -> bool {
oid.len() == 40 && oid.chars().all(|c| c.is_ascii_hexdigit())
}
/// Validate a Git LFS OID (base64-encoded SHA-256 hash, ~44 chars).
pub fn is_valid_lfs_oid(oid: &str) -> bool {
// base64 alphabet: A-Z, a-z, 0-9, +, /, = (padding)
(43..=44).contains(&oid.len())
&& oid.chars().all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '=')
}
pub struct GitHttpHandler {
storage_path: PathBuf,
repo: repo::Model,
@ -248,53 +255,59 @@ fn check_branch_protection(
let refs = parse_ref_updates(pre_pack)?;
for r#ref in &refs {
for protection in branch_protects {
if r#ref.name.starts_with(&protection.branch) {
// Check deletion (new_oid is all zeros / 40 zeros)
if r#ref.new_oid.as_deref() == Some("0000000000000000000000000000000000000000") {
if protection.forbid_deletion {
return Err(format!(
"Deletion of protected branch '{}' is forbidden",
r#ref.name
));
}
continue;
}
// Check tag push
if r#ref.name.starts_with("refs/tags/") {
if protection.forbid_tag_push {
return Err(format!(
"Tag push to protected branch '{}' is forbidden",
r#ref.name
));
}
continue;
}
// Check force push: old != new AND old is non-zero (non-fast-forward update)
if let (Some(old_oid), Some(new_oid)) =
(r#ref.old_oid.as_deref(), r#ref.new_oid.as_deref())
{
let is_new_branch = old_oid == "0000000000000000000000000000000000000000";
if !is_new_branch
&& old_oid != new_oid
&& r#ref.name.starts_with("refs/heads/")
&& protection.forbid_force_push
{
return Err(format!(
"Force push to protected branch '{}' is forbidden",
r#ref.name
));
}
}
// Check push
if protection.forbid_push {
// Match exactly or as directory prefix (e.g. "refs/heads/main"
// matches "refs/heads/main" and "refs/heads/main/*" but NOT
// "refs/heads/main-v2").
let matches = r#ref.name == protection.branch
|| r#ref.name.starts_with(&format!("{}/", protection.branch));
if !matches {
continue;
}
// Check deletion (new_oid is all zeros / 40 zeros)
if r#ref.new_oid.as_deref() == Some("0000000000000000000000000000000000000000") {
if protection.forbid_deletion {
return Err(format!(
"Push to protected branch '{}' is forbidden",
"Deletion of protected branch '{}' is forbidden",
r#ref.name
));
}
continue;
}
// Check tag push
if r#ref.name.starts_with("refs/tags/") {
if protection.forbid_tag_push {
return Err(format!(
"Tag push to protected branch '{}' is forbidden",
r#ref.name
));
}
continue;
}
// Check force push: old != new AND old is non-zero (non-fast-forward update)
if let (Some(old_oid), Some(new_oid)) =
(r#ref.old_oid.as_deref(), r#ref.new_oid.as_deref())
{
let is_new_branch = old_oid == "0000000000000000000000000000000000000000";
if !is_new_branch
&& old_oid != new_oid
&& r#ref.name.starts_with("refs/heads/")
&& protection.forbid_force_push
{
return Err(format!(
"Force push to protected branch '{}' is forbidden",
r#ref.name
));
}
}
// Check push
if protection.forbid_push {
return Err(format!(
"Push to protected branch '{}' is forbidden",
r#ref.name
));
}
}
}

View File

@ -1,5 +1,5 @@
use crate::error::GitError;
use crate::http::handler::is_valid_oid;
use crate::http::handler::is_valid_lfs_oid;
use actix_web::{HttpResponse, web};
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
@ -244,7 +244,7 @@ impl LfsHandler {
payload: web::Payload,
_auth_token: &str,
) -> Result<HttpResponse, GitError> {
if !is_valid_oid(oid) {
if !is_valid_lfs_oid(oid) {
return Err(GitError::InvalidOid(format!("Invalid OID format: {}", oid)));
}
@ -332,7 +332,7 @@ impl LfsHandler {
oid: &str,
_auth_token: &str,
) -> Result<HttpResponse, GitError> {
if !is_valid_oid(oid) {
if !is_valid_lfs_oid(oid) {
return Err(GitError::InvalidOid(format!("Invalid OID format: {}", oid)));
}
@ -382,7 +382,7 @@ impl LfsHandler {
) -> Result<LockResponse, GitError> {
use sea_orm::ActiveModelTrait;
if !is_valid_oid(oid) {
if !is_valid_lfs_oid(oid) {
return Err(GitError::InvalidOid(format!("Invalid OID format: {}", oid)));
}

View File

@ -1,6 +1,6 @@
use crate::error::GitError;
use crate::http::HttpAppState;
use crate::http::handler::is_valid_oid;
use crate::http::handler::is_valid_lfs_oid;
use crate::http::lfs::{BatchRequest, CreateLockRequest, LfsHandler};
use crate::http::utils::get_repo_model;
use actix_web::{Error, HttpRequest, HttpResponse, web};
@ -82,7 +82,7 @@ pub async fn lfs_upload(
) -> Result<HttpResponse, Error> {
let (namespace, repo_name, oid) = path.into_inner();
if !is_valid_oid(&oid) {
if !is_valid_lfs_oid(&oid) {
return Err(actix_web::error::ErrorBadRequest("Invalid OID format"));
}
@ -112,7 +112,7 @@ pub async fn lfs_download(
) -> Result<HttpResponse, Error> {
let (namespace, repo_name, oid) = path.into_inner();
if !is_valid_oid(&oid) {
if !is_valid_lfs_oid(&oid) {
return Err(actix_web::error::ErrorBadRequest("Invalid OID format"));
}

View File

@ -324,11 +324,12 @@ impl GitDomain {
msg.push_str(&format!("- {}", commit.summary().unwrap_or("(no message)")));
}
// Create the squash commit on top of base
// Create the squash commit on top of base.
// Do NOT update any ref — the caller decides how to use the returned OID.
let squash_oid = self
.repo()
.commit(
Some("HEAD"),
None,
&sig,
&sig,
&msg,

View File

@ -25,6 +25,7 @@ pub fn validate_ref_name(name: &str) -> Result<(), GitError> {
|| name.contains('*')
|| name.contains('[')
|| name.contains('\\')
|| name.contains('@')
{
return Err(GitError::InvalidRefName(format!(
"invalid ref name: {}",

View File

@ -19,6 +19,8 @@ use std::path::PathBuf;
use std::process::Stdio;
use std::str::FromStr;
use std::time::Duration;
const PRE_PACK_LIMIT: usize = 1_048_576;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt};
use tokio::process::ChildStdin;
use tokio::sync::mpsc::Sender;
@ -357,6 +359,23 @@ impl russh::server::Handler for SSHandle {
if matches!(self.service, Some(GitService::ReceivePack)) {
if !self.branch.contains_key(&channel) {
let bf = self.buffer.entry(channel).or_default();
// Reject oversized pre-PACK data to prevent memory exhaustion
if bf.len() + data.len() > PRE_PACK_LIMIT {
tracing::warn!("ssh_pre_pack_too_large channel={:?}", channel);
let msg = "remote: Ref negotiation exceeds size limit\r\n";
let _ = session.extended_data(
channel,
1,
CryptoVec::from_slice(msg.as_bytes()),
);
let _ = session.exit_status_request(channel, 1);
let _ = session.eof(channel);
let _ = session.close(channel);
self.cleanup_channel(channel);
return Ok(());
}
bf.extend_from_slice(data);
if !bf.windows(4).any(|w| w == b"0000") {
@ -377,16 +396,15 @@ impl russh::server::Handler for SSHandle {
})?;
for r#ref in &refs {
if branch_protect_roles
.iter()
.any(|x| r#ref.name.starts_with(&x.branch))
if let Some(msg) =
check_branch_protection(&branch_protect_roles, r#ref)
{
let msg =
format!("remote: Branch '{}' is protected\r\n", r#ref.name);
let full_msg =
format!("remote: {}\r\n", msg);
let _ = session.extended_data(
channel,
1,
CryptoVec::from_slice(msg.as_bytes()),
CryptoVec::from_slice(full_msg.as_bytes()),
);
let _ = session.exit_status_request(channel, 1);
let _ = session.eof(channel);
@ -779,6 +797,71 @@ impl FromStr for GitService {
}
}
/// Ref name matches a protection rule exactly, or as a directory prefix
/// (e.g. "refs/heads/main" matches "refs/heads/main" and "refs/heads/main/*"
/// but NOT "refs/heads/main-v2").
fn ref_matches_protection(ref_name: &str, protection_branch: &str) -> bool {
ref_name == protection_branch
|| ref_name.starts_with(&format!("{}/", protection_branch))
}
/// Granular branch protection check (same logic as HTTP handler).
/// Returns `Some(error_message)` if the push should be rejected.
fn check_branch_protection(
branch_protects: &[repo_branch_protect::Model],
r#ref: &RefUpdate,
) -> Option<String> {
for protection in branch_protects {
if !ref_matches_protection(&r#ref.name, &protection.branch) {
continue;
}
// Check deletion (new_oid is all zeros)
if r#ref.new_oid == "0000000000000000000000000000000000000000" {
if protection.forbid_deletion {
return Some(format!(
"Deletion of protected branch '{}' is forbidden",
r#ref.name
));
}
continue;
}
// Check tag push
if r#ref.name.starts_with("refs/tags/") {
if protection.forbid_tag_push {
return Some(format!(
"Tag push to protected branch '{}' is forbidden",
r#ref.name
));
}
continue;
}
// Check force push: old != new AND old is non-zero (non-fast-forward)
let is_new_branch = r#ref.old_oid == "0000000000000000000000000000000000000000";
if !is_new_branch
&& r#ref.old_oid != r#ref.new_oid
&& r#ref.name.starts_with("refs/heads/")
&& protection.forbid_force_push
{
return Some(format!(
"Force push to protected branch '{}' is forbidden",
r#ref.name
));
}
// Check push
if protection.forbid_push {
return Some(format!(
"Push to protected branch '{}' is forbidden",
r#ref.name
));
}
}
None
}
async fn forward<'a, R, Fut, Fwd>(
session_handle: &'a Handle,
chan_id: ChannelId,

View File

@ -28,9 +28,10 @@ impl RoomService {
.await
.ok()
.flatten()
.map(|m| m.name);
.map(|m| m.name)
.unwrap_or_else(|| format!("AI {}", model.model));
let mut resp = super::RoomAiResponse::from(model);
resp.model_name = model_name;
resp.model_name = Some(model_name);
responses.push(resp);
}
@ -113,9 +114,22 @@ impl RoomService {
.await
.ok()
.flatten()
.map(|m| m.name);
.map(|m| m.name)
.unwrap_or_else(|| format!("AI {}", saved.model));
let mut resp = super::RoomAiResponse::from(saved);
resp.model_name = model_name;
resp.model_name = Some(model_name);
if let Ok(room) = self.find_room_or_404(room_id).await {
self.publish_room_event(
room.project,
super::RoomEventType::RoomAiUpdated,
Some(room_id),
None,
None,
None,
)
.await;
}
Ok(resp)
}
@ -132,6 +146,19 @@ impl RoomService {
room_ai::Entity::delete_by_id((room_id, model_id))
.exec(&self.db)
.await?;
if let Ok(room) = self.find_room_or_404(room_id).await {
self.publish_room_event(
room.project,
super::RoomEventType::RoomAiUpdated,
Some(room_id),
None,
None,
None,
)
.await;
}
Ok(())
}
}

View File

@ -1,9 +1,11 @@
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 sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect};
use uuid::Uuid;
use super::patterns::mention_bracket_re;
use crate::error::RoomError;
pub async fn get_room_history(
@ -57,6 +59,34 @@ pub async fn get_room_ai_config(
Ok(ai_config)
}
pub async fn extract_mention_context(_content: &str) -> Vec<agent::chat::Mention> {
Vec::new()
pub async fn extract_mention_context(
db: &AppDatabase,
project_id: Uuid,
content: &str,
) -> Vec<agent::chat::Mention> {
let mut mentions: Vec<agent::chat::Mention> = Vec::new();
let mut seen_repos: 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());
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));
}
}
}
}
mentions
}

View File

@ -374,7 +374,7 @@ impl RoomService {
.collect();
let user_names = self.get_user_names(&user_ids).await;
let mentions = history::extract_mention_context(&content).await;
let mentions = history::extract_mention_context(&self.db, room.project, &content).await;
let request = AiRequest {
db: self.db.clone(),

View File

@ -35,6 +35,10 @@ pub enum RoomEventType {
ReactionRemoved,
TypingStart,
TypingStop,
RoomAiUpdated,
RepoCreated,
RepoUpdated,
RepoDeleted,
}
impl RoomEventType {
@ -59,6 +63,10 @@ impl RoomEventType {
RoomEventType::ReactionRemoved => "reaction_removed",
RoomEventType::TypingStart => "typing_start",
RoomEventType::TypingStop => "typing_stop",
RoomEventType::RoomAiUpdated => "room_ai_updated",
RoomEventType::RepoCreated => "repo_created",
RoomEventType::RepoUpdated => "repo_updated",
RoomEventType::RepoDeleted => "repo_deleted",
}
}
@ -83,6 +91,10 @@ impl RoomEventType {
"reaction_removed" => Some(RoomEventType::ReactionRemoved),
"typing_start" => Some(RoomEventType::TypingStart),
"typing_stop" => Some(RoomEventType::TypingStop),
"room_ai_updated" => Some(RoomEventType::RoomAiUpdated),
"repo_created" => Some(RoomEventType::RepoCreated),
"repo_updated" => Some(RoomEventType::RepoUpdated),
"repo_deleted" => Some(RoomEventType::RepoDeleted),
_ => None,
}
}

View File

@ -17,6 +17,7 @@ name = "service"
[dependencies]
config = { workspace = true }
agent = { workspace = true }
fctool = { workspace = true }
db = { workspace = true }
models = { workspace = true }
email = { workspace = true }

View File

@ -1,3 +1,4 @@
#![allow(dead_code)]
//! Synchronizes AI model metadata from the upstream AI endpoint
//! (`GET /v1/models`) into the local database.
//!
@ -495,10 +496,9 @@ async fn sync_models_from_upstream(
pricing_created += 1;
}
capabilities_created +=
upsert_capabilities(db, version_record.id, &model)
.await
.unwrap_or(0);
capabilities_created += upsert_capabilities(db, version_record.id, &model)
.await
.unwrap_or(0);
if upsert_parameter_profile(db, version_record.id, &model)
.await
@ -526,7 +526,12 @@ async fn list_upstream_models(
base_url: &str,
api_key: &str,
) -> Result<Vec<UpstreamModel>, AppError> {
let url = format!("{}/v1/models", base_url.trim_end_matches('/'));
let base = base_url.trim_end_matches('/');
let url = if base.ends_with("/v1") {
format!("{}/models", base)
} else {
format!("{}/v1/models", base)
};
let resp = client
.get(&url)
.header("Authorization", format!("Bearer {}", api_key))
@ -559,7 +564,9 @@ async fn list_upstream_models(
)))
}
fn build_ai_client(config: &config::AppConfig) -> Result<(reqwest::Client, String, String), AppError> {
fn build_ai_client(
config: &config::AppConfig,
) -> Result<(reqwest::Client, String, String), AppError> {
let api_key = config
.ai_api_key()
.map_err(|e| AppError::InternalServerError(format!("AI API key not configured: {}", e)))?;
@ -638,18 +645,15 @@ impl AppService {
/// Perform a single sync pass. Errors are logged and silently swallowed
/// so the periodic task never stops.
async fn sync_once(
db: &AppDatabase,
ai_api_key: Option<String>,
ai_base_url: Option<String>,
) {
let (http_client, base_url, api_key) = match build_ai_client_from_parts(ai_api_key, ai_base_url) {
Ok(c) => c,
Err(msg) => {
tracing::warn!(error = %msg, "Model sync: AI client config error");
return;
}
};
async fn sync_once(db: &AppDatabase, ai_api_key: Option<String>, ai_base_url: Option<String>) {
let (http_client, base_url, api_key) =
match build_ai_client_from_parts(ai_api_key, ai_base_url) {
Ok(c) => c,
Err(msg) => {
tracing::warn!(error = %msg, "Model sync: AI client config error");
return;
}
};
let upstream_models = match list_upstream_models(&http_client, &base_url, &api_key).await {
Ok(models) => models,

View File

@ -1,184 +0,0 @@
//! read_excel — parse and query Excel files (.xlsx, .xls).
use crate::file_tools::MAX_FILE_SIZE;
use crate::git_tools::ctx::GitToolCtx;
use agent::{ToolDefinition, ToolHandler, ToolParam, ToolRegistry, ToolSchema};
use calamine::{open_workbook, Reader, Xlsx};
use futures::FutureExt;
use std::collections::HashMap;
async fn read_excel_exec(
ctx: GitToolCtx,
args: serde_json::Value,
) -> Result<serde_json::Value, String> {
let p: serde_json::Map<String, serde_json::Value> =
serde_json::from_value(args).map_err(|e| e.to_string())?;
let project_name = p
.get("project_name")
.and_then(|v| v.as_str())
.ok_or("missing project_name")?;
let repo_name = p
.get("repo_name")
.and_then(|v| v.as_str())
.ok_or("missing repo_name")?;
let path = p.get("path").and_then(|v| v.as_str()).ok_or("missing path")?;
let rev = p
.get("rev")
.and_then(|v| v.as_str())
.map(String::from)
.unwrap_or_else(|| "HEAD".to_string());
let sheet_name = p.get("sheet_name").and_then(|v| v.as_str()).map(String::from);
let sheet_index = p.get("sheet_index").and_then(|v| v.as_u64()).map(|v| v as usize);
let offset = p.get("offset").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
let limit = p
.get("limit")
.and_then(|v| v.as_u64())
.unwrap_or(100) as usize;
let has_header = p
.get("has_header")
.and_then(|v| v.as_bool())
.unwrap_or(true);
let domain = ctx.open_repo(project_name, repo_name).await?;
let commit_oid = if rev.len() >= 40 {
git::commit::types::CommitOid::new(&rev)
} else {
domain
.commit_get_prefix(&rev)
.map_err(|e| e.to_string())?
.oid
};
let entry = domain
.tree_entry_by_path_from_commit(&commit_oid, path)
.map_err(|e| e.to_string())?;
let blob = domain.blob_get(&entry.oid).map_err(|e| e.to_string())?;
let content = domain.blob_content(&entry.oid).map_err(|e| e.to_string())?;
let data = &content.content;
if data.len() > MAX_FILE_SIZE {
return Err(format!(
"file too large ({} bytes), max {} bytes",
data.len(),
MAX_FILE_SIZE
));
}
// Use cursor-based reading to avoid tempfile
let cursor = std::io::Cursor::new(data.clone());
let mut workbook: Xlsx<std::io::Cursor<Vec<u8>>> =
open_workbook(cursor).map_err(|e| format!("failed to open Excel: {}", e))?;
let sheet_names = workbook.sheet_names().to_vec();
// Determine which sheet to read
let sheet_idx = match (sheet_name.clone(), sheet_index) {
(Some(name), _) => sheet_names
.iter()
.position(|n| n == &name)
.ok_or_else(|| format!("sheet '{}' not found. Available: {:?}", name, sheet_names))?,
(_, Some(idx)) => {
if idx >= sheet_names.len() {
return Err(format!(
"sheet index {} out of range (0..{})",
idx,
sheet_names.len()
));
}
idx
}
_ => 0,
};
let range = workbook
.worksheet_range_at(sheet_idx)
.map_err(|e| format!("failed to read sheet: {}", e))?;
let rows: Vec<Vec<serde_json::Value>> = range
.rows()
.skip(if has_header { offset + 1 } else { offset })
.take(limit)
.map(|row| {
row.iter()
.map(|cell| {
use calamine::Data;
match cell {
Data::Int(i) => serde_json::Value::Number((*i).into()),
Data::Float(f) => {
serde_json::json!(f)
}
Data::String(s) => serde_json::Value::String(s.clone()),
Data::Bool(b) => serde_json::Value::Bool(*b),
Data::DateTime(dt) => {
serde_json::Value::String(format!("{:?}", dt))
}
Data::DateTimeIso(s) => serde_json::Value::String(s.clone()),
Data::DurationIso(s) => serde_json::Value::String(s.clone()),
Data::Error(e) => serde_json::json!({ "error": format!("{:?}", e) }),
Data::Empty => serde_json::Value::Null,
}
})
.collect()
})
.collect();
let header_row: Vec<String> = if has_header {
range
.rows()
.next()
.map(|row| {
row.iter()
.map(|c| {
if let calamine::Data::String(s) = c {
s.clone()
} else {
String::new()
}
})
.collect()
})
.unwrap_or_default()
} else {
vec![]
};
Ok(serde_json::json!({
"path": path,
"rev": rev,
"sheets": sheet_names,
"active_sheet": sheet_names.get(sheet_idx).cloned(),
"sheet_index": sheet_idx,
"headers": header_row,
"rows": rows,
"row_count": rows.len(),
"total_rows": range.rows().count().saturating_sub(if has_header { 1 } else { 0 }),
}))
}
pub fn register_excel_tools(registry: &mut ToolRegistry) {
let p = HashMap::from([
("project_name".into(), ToolParam { name: "project_name".into(), param_type: "string".into(), description: Some("Project name (slug)".into()), required: true, properties: None, items: None }),
("repo_name".into(), ToolParam { name: "repo_name".into(), param_type: "string".into(), description: Some("Repository name".into()), required: true, properties: None, items: None }),
("path".into(), ToolParam { name: "path".into(), param_type: "string".into(), description: Some("File path within the repository (supports .xlsx, .xls)".into()), required: true, properties: None, items: None }),
("rev".into(), ToolParam { name: "rev".into(), param_type: "string".into(), description: Some("Git revision (default: HEAD)".into()), required: false, properties: None, items: None }),
("sheet_name".into(), ToolParam { name: "sheet_name".into(), param_type: "string".into(), description: Some("Sheet name to read. Defaults to first sheet.".into()), required: false, properties: None, items: None }),
("sheet_index".into(), ToolParam { name: "sheet_index".into(), param_type: "integer".into(), description: Some("Sheet index (0-based). Ignored if sheet_name is set.".into()), required: false, properties: None, items: None }),
("has_header".into(), ToolParam { name: "has_header".into(), param_type: "boolean".into(), description: Some("If true, first row is column headers (default: true)".into()), required: false, properties: None, items: None }),
("offset".into(), ToolParam { name: "offset".into(), param_type: "integer".into(), description: Some("Number of rows to skip (default: 0)".into()), required: false, properties: None, items: None }),
("limit".into(), ToolParam { name: "limit".into(), param_type: "integer".into(), description: Some("Maximum rows to return (default: 100)".into()), required: false, properties: None, items: None }),
]);
let schema = ToolSchema { schema_type: "object".into(), properties: Some(p), required: Some(vec!["project_name".into(), "repo_name".into(), "path".into()]) };
registry.register(
ToolDefinition::new("read_excel")
.description("Parse and query Excel spreadsheets (.xlsx, .xls). Returns sheet names, headers, and rows with support for sheet selection and pagination.")
.parameters(schema),
ToolHandler::new(|ctx, args| {
let gctx = GitToolCtx::new(ctx);
Box::pin(async move {
read_excel_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError)
})
}),
);
}

View File

@ -1,244 +0,0 @@
//! read_pdf — extract text from PDF files.
use crate::file_tools::MAX_FILE_SIZE;
use crate::git_tools::ctx::GitToolCtx;
use agent::{ToolDefinition, ToolHandler, ToolParam, ToolRegistry, ToolSchema};
use futures::FutureExt;
use lopdf::{Document, Object, ObjectId};
use std::collections::HashMap;
/// Extract text content from a PDF page's content stream.
fn extract_page_text(doc: &Document, page_id: ObjectId) -> String {
let mut text = String::new();
// Get page dictionary
let page_dict = match doc.get(page_id) {
Ok(dict) => dict,
Err(_) => return text,
};
// Get content streams (can be a single stream or array)
let content_streams = match page_dict.get(b"Contents") {
Ok(obj) => obj.clone(),
Err(_) => return text,
};
let stream_ids: Vec<ObjectId> = match &content_streams {
Object::Reference(id) => vec![*id],
Object::Array(arr) => arr
.iter()
.filter_map(|o| {
if let Object::Reference(id) = o {
Some(*id)
} else {
None
}
})
.collect(),
_ => return text,
};
for stream_id in stream_ids {
if let Ok((_, stream)) = doc.get_stream(stream_id) {
// Decode the stream
if let Ok(decompressed) = stream.decompressed_content() {
text.push_str(&extract_text_from_content(&decompress_pdf_stream(&decompressed)));
text.push('\n');
}
}
}
text
}
/// Very simple PDF content stream text extraction.
/// Handles Tj, TJ, Td, T*, ', " operators.
fn extract_text_from_content(content: &[u8]) -> String {
let data = String::from_utf8_lossy(content);
let mut result = String::new();
let mut in_parens = false;
let mut current_text = String::new();
let mut last_was_tj = false;
let mut chars = data.chars().peekable();
while let Some(c) = chars.next() {
match c {
'(' => {
in_parens = true;
current_text.clear();
}
')' if in_parens => {
in_parens = false;
if !current_text.is_empty() {
if last_was_tj {
// TJ operator: subtract current text width offset
}
result.push_str(&current_text);
result.push(' ');
last_was_tj = false;
}
}
c if in_parens => {
if c == '\\' {
if let Some(escaped) = chars.next() {
match escaped {
'n' => current_text.push('\n'),
'r' => current_text.push('\r'),
't' => current_text.push('\t'),
_ => current_text.push(escaped),
}
}
} else {
current_text.push(c);
}
}
'%' => {
// Comment, skip to end of line
while let Some(nc) = chars.next() {
if nc == '\n' || nc == '\r' {
break;
}
}
}
_ => {}
}
}
// Clean up excessive newlines
let lines: Vec<&str> = result.lines().map(|l| l.trim()).filter(|l| !l.is_empty()).collect();
lines.join("\n")
}
fn decompress_pdf_stream(data: &[u8]) -> Vec<u8> {
// Try to detect and decompress flate/zlib streams
if data.len() < 2 {
return data.to_vec();
}
// Simple zlib check: zlib-wrapped deflate starts with 0x78
if data.starts_with(&[0x78]) || data.starts_with(&[0x08, 0x1b]) {
if let Ok(decoded) = flate2::read::ZlibDecoder::new(data).bytes().collect::<Result<Vec<_>, _>>() {
return decoded;
}
}
// Try raw deflate
if let Ok(decoded) = flate2::read::DeflateDecoder::new(data).bytes().collect::<Result<Vec<_>, _>>() {
return decoded;
}
data.to_vec()
}
async fn read_pdf_exec(
ctx: GitToolCtx,
args: serde_json::Value,
) -> Result<serde_json::Value, String> {
let p: serde_json::Map<String, serde_json::Value> =
serde_json::from_value(args).map_err(|e| e.to_string())?;
let project_name = p
.get("project_name")
.and_then(|v| v.as_str())
.ok_or("missing project_name")?;
let repo_name = p
.get("repo_name")
.and_then(|v| v.as_str())
.ok_or("missing repo_name")?;
let path = p.get("path").and_then(|v| v.as_str()).ok_or("missing path")?;
let rev = p
.get("rev")
.and_then(|v| v.as_str())
.map(String::from)
.unwrap_or_else(|| "HEAD".to_string());
let page_start = p.get("page_start").and_then(|v| v.as_u64()).map(|v| v as usize);
let page_end = p.get("page_end").and_then(|v| v.as_u64()).map(|v| v as usize);
let max_pages = p
.get("max_pages")
.and_then(|v| v.as_u64())
.unwrap_or(20) as usize;
let domain = ctx.open_repo(project_name, repo_name).await?;
let commit_oid = if rev.len() >= 40 {
git::commit::types::CommitOid::new(&rev)
} else {
domain
.commit_get_prefix(&rev)
.map_err(|e| e.to_string())?
.oid
};
let entry = domain
.tree_entry_by_path_from_commit(&commit_oid, path)
.map_err(|e| e.to_string())?;
let content = domain.blob_content(&entry.oid).map_err(|e| e.to_string())?;
let data = &content.content;
if data.len() > MAX_FILE_SIZE {
return Err(format!(
"file too large ({} bytes), max {} bytes",
data.len(),
MAX_FILE_SIZE
));
}
let doc = Document::load_from_mem(data)
.map_err(|e| format!("failed to parse PDF: {}", e))?;
// Get all page references
let pages: Vec<ObjectId> = doc
.pages
.values()
.cloned()
.collect();
let total_pages = pages.len();
let start = page_start.unwrap_or(0).min(total_pages.saturating_sub(1));
let end = page_end.unwrap_or(start + max_pages).min(total_pages);
let mut page_texts: Vec<serde_json::Value> = Vec::new();
for (i, page_id) in pages.iter().enumerate().skip(start).take(end - start) {
let text = extract_page_text(&doc, *page_id);
page_texts.push(serde_json::json!({
"page": i + 1,
"text": text,
"char_count": text.chars().count(),
}));
}
Ok(serde_json::json!({
"path": path,
"rev": rev,
"total_pages": total_pages,
"extracted_pages": page_texts.len(),
"pages": page_texts,
}))
}
pub fn register_pdf_tools(registry: &mut ToolRegistry) {
let p = HashMap::from([
("project_name".into(), ToolParam { name: "project_name".into(), param_type: "string".into(), description: Some("Project name (slug)".into()), required: true, properties: None, items: None }),
("repo_name".into(), ToolParam { name: "repo_name".into(), param_type: "string".into(), description: Some("Repository name".into()), required: true, properties: None, items: None }),
("path".into(), ToolParam { name: "path".into(), param_type: "string".into(), description: Some("File path to the PDF document".into()), required: true, properties: None, items: None }),
("rev".into(), ToolParam { name: "rev".into(), param_type: "string".into(), description: Some("Git revision (default: HEAD)".into()), required: false, properties: None, items: None }),
("page_start".into(), ToolParam { name: "page_start".into(), param_type: "integer".into(), description: Some("1-based starting page number (default: 1)".into()), required: false, properties: None, items: None }),
("page_end".into(), ToolParam { name: "page_end".into(), param_type: "integer".into(), description: Some("1-based ending page number (default: page_start + 20)".into()), required: false, properties: None, items: None }),
("max_pages".into(), ToolParam { name: "max_pages".into(), param_type: "integer".into(), description: Some("Maximum number of pages to extract (default: 20)".into()), required: false, properties: None, items: None }),
]);
let schema = ToolSchema { schema_type: "object".into(), properties: Some(p), required: Some(vec!["project_name".into(), "repo_name".into(), "path".into()]) };
registry.register(
ToolDefinition::new("read_pdf")
.description("Extract text content from PDF files. Returns page-by-page text extraction with character counts. Supports page range selection.")
.parameters(schema),
ToolHandler::new(|ctx, args| {
let gctx = GitToolCtx::new(ctx);
Box::pin(async move {
read_pdf_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError)
})
}),
);
}

View File

@ -1,204 +0,0 @@
//! read_ppt — extract text from PowerPoint files (.pptx).
use crate::file_tools::MAX_FILE_SIZE;
use crate::git_tools::ctx::GitToolCtx;
use agent::{ToolDefinition, ToolHandler, ToolParam, ToolRegistry, ToolSchema};
use futures::FutureExt;
use std::collections::HashMap;
use zip::ZipArchive;
async fn read_ppt_exec(
ctx: GitToolCtx,
args: serde_json::Value,
) -> Result<serde_json::Value, String> {
let p: serde_json::Map<String, serde_json::Value> =
serde_json::from_value(args).map_err(|e| e.to_string())?;
let project_name = p
.get("project_name")
.and_then(|v| v.as_str())
.ok_or("missing project_name")?;
let repo_name = p
.get("repo_name")
.and_then(|v| v.as_str())
.ok_or("missing repo_name")?;
let path = p.get("path").and_then(|v| v.as_str()).ok_or("missing path")?;
let rev = p
.get("rev")
.and_then(|v| v.as_str())
.map(String::from)
.unwrap_or_else(|| "HEAD".to_string());
let slide_start = p.get("slide_start").and_then(|v| v.as_u64()).map(|v| v as usize);
let slide_end = p.get("slide_end").and_then(|v| v.as_u64()).map(|v| v as usize);
let include_notes = p
.get("include_notes")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let domain = ctx.open_repo(project_name, repo_name).await?;
let commit_oid = if rev.len() >= 40 {
git::commit::types::CommitOid::new(&rev)
} else {
domain
.commit_get_prefix(&rev)
.map_err(|e| e.to_string())?
.oid
};
let entry = domain
.tree_entry_by_path_from_commit(&commit_oid, path)
.map_err(|e| e.to_string())?;
let content = domain.blob_content(&entry.oid).map_err(|e| e.to_string())?;
let data = &content.content;
if data.len() > MAX_FILE_SIZE {
return Err(format!(
"file too large ({} bytes), max {} bytes",
data.len(),
MAX_FILE_SIZE
));
}
let cursor = std::io::Cursor::new(data.clone());
let mut archive =
ZipArchive::new(cursor).map_err(|e| format!("failed to read PPTX ZIP: {}", e))?;
let mut slides: Vec<serde_json::Value> = Vec::new();
// Collect all slide file names
let mut slide_files: Vec<String> = (1..=1000)
.filter_map(|i| {
let name = format!("ppt/slides/slide{}.xml", i);
if archive.by_name(&name).is_ok() {
Some(name)
} else {
None
}
})
.collect();
let total_slides = slide_files.len();
let start = slide_start.unwrap_or(0).min(total_slides.saturating_sub(1));
let end = slide_end.unwrap_or(start + 50).min(total_slides);
for slide_file in slide_files.iter().skip(start).take(end - start) {
let slide_idx = slides.len() + start + 1;
let mut file = archive
.by_name(slide_file)
.map_err(|e| format!("failed to read slide {}: {}", slide_file, e))?;
let mut xml_content = String::new();
use std::io::Read;
file.read_to_string(&mut xml_content)
.map_err(|e| e.to_string())?;
// Extract text from slide XML
let text = extract_text_from_pptx_xml(&xml_content);
// Optionally extract notes
let notes = if include_notes {
let notes_file = format!("ppt/notesSlides/notesSlide{}.xml", slide_idx);
if let Ok(mut notes_file) = archive.by_name(&notes_file) {
let mut notes_xml = String::new();
if notes_file.read_to_string(&mut notes_xml).is_ok() {
Some(extract_text_from_pptx_xml(&notes_xml))
} else {
None
}
} else {
None
}
} else {
None
};
slides.push(serde_json::json!({
"slide": slide_idx,
"text": text.clone(),
"char_count": text.chars().count(),
"notes": notes,
}));
}
Ok(serde_json::json!({
"path": path,
"rev": rev,
"total_slides": total_slides,
"extracted_slides": slides.len(),
"slides": slides,
}))
}
/// Extract text content from PPTX slide XML using simple tag extraction.
fn extract_text_from_pptx_xml(xml: &str) -> String {
// PPTX uses <a:t> tags for text content
let mut results: Vec<&str> = Vec::new();
let mut last_end = 0;
while let Some(start) = xml[last_end..].find("<a:t") {
let abs_start = last_end + start;
if let Some(tag_end) = xml[abs_start..].find('>') {
let content_start = abs_start + tag_end + 1;
if let Some(end_tag) = xml[content_start..].find("</a:t>") {
let text = &xml[content_start..content_start + end_tag];
let trimmed = text.trim();
if !trimmed.is_empty() {
results.push(trimmed);
}
last_end = content_start + end_tag + 7; // len of </a:t>
} else {
break;
}
} else {
break;
}
}
// Also try <w:t> tags (notes slides use Word namespaces)
let mut last_end = 0;
while let Some(start) = xml[last_end..].find("<w:t") {
let abs_start = last_end + start;
if let Some(tag_end) = xml[abs_start..].find('>') {
let content_start = abs_start + tag_end + 1;
if let Some(end_tag) = xml[content_start..].find("</w:t>") {
let text = &xml[content_start..content_start + end_tag];
let trimmed = text.trim();
if !trimmed.is_empty() && !results.contains(&trimmed) {
results.push(trimmed);
}
last_end = content_start + end_tag + 6; // len of </w:t>
} else {
break;
}
} else {
break;
}
}
results.join(" ")
}
pub fn register_ppt_tools(registry: &mut ToolRegistry) {
let p = HashMap::from([
("project_name".into(), ToolParam { name: "project_name".into(), param_type: "string".into(), description: Some("Project name (slug)".into()), required: true, properties: None, items: None }),
("repo_name".into(), ToolParam { name: "repo_name".into(), param_type: "string".into(), description: Some("Repository name".into()), required: true, properties: None, items: None }),
("path".into(), ToolParam { name: "path".into(), param_type: "string".into(), description: Some("File path to the .pptx document".into()), required: true, properties: None, items: None }),
("rev".into(), ToolParam { name: "rev".into(), param_type: "string".into(), description: Some("Git revision (default: HEAD)".into()), required: false, properties: None, items: None }),
("slide_start".into(), ToolParam { name: "slide_start".into(), param_type: "integer".into(), description: Some("1-based starting slide number (default: 1)".into()), required: false, properties: None, items: None }),
("slide_end".into(), ToolParam { name: "slide_end".into(), param_type: "integer".into(), description: Some("1-based ending slide number".into()), required: false, properties: None, items: None }),
("include_notes".into(), ToolParam { name: "include_notes".into(), param_type: "boolean".into(), description: Some("Include speaker notes (default: false)".into()), required: false, properties: None, items: None }),
]);
let schema = ToolSchema { schema_type: "object".into(), properties: Some(p), required: Some(vec!["project_name".into(), "repo_name".into(), "path".into()]) };
registry.register(
ToolDefinition::new("read_ppt")
.description("Extract text content from PowerPoint presentations (.pptx). Returns slide-by-slide text with character counts. Supports slide range selection and speaker notes.")
.parameters(schema),
ToolHandler::new(|ctx, args| {
let gctx = GitToolCtx::new(ctx);
Box::pin(async move {
read_ppt_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError)
})
}),
);
}

View File

@ -1,184 +0,0 @@
//! read_word — parse and extract text from Word documents (.docx) via zip+xml.
use crate::file_tools::MAX_FILE_SIZE;
use crate::git_tools::ctx::GitToolCtx;
use agent::{ToolDefinition, ToolHandler, ToolParam, ToolRegistry, ToolSchema};
use futures::FutureExt;
use quick_xml::events::Event;
use quick_xml::Reader;
use std::collections::HashMap;
use zip::ZipArchive;
async fn read_word_exec(
ctx: GitToolCtx,
args: serde_json::Value,
) -> Result<serde_json::Value, String> {
let p: serde_json::Map<String, serde_json::Value> =
serde_json::from_value(args).map_err(|e| e.to_string())?;
let project_name = p
.get("project_name")
.and_then(|v| v.as_str())
.ok_or("missing project_name")?;
let repo_name = p
.get("repo_name")
.and_then(|v| v.as_str())
.ok_or("missing repo_name")?;
let path = p.get("path").and_then(|v| v.as_str()).ok_or("missing path")?;
let rev = p
.get("rev")
.and_then(|v| v.as_str())
.map(String::from)
.unwrap_or_else(|| "HEAD".to_string());
let offset = p.get("offset").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
let limit = p
.get("limit")
.and_then(|v| v.as_u64())
.unwrap_or(200) as usize;
let sections_only = p
.get("sections_only")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let domain = ctx.open_repo(project_name, repo_name).await?;
let commit_oid = if rev.len() >= 40 {
git::commit::types::CommitOid::new(&rev)
} else {
domain
.commit_get_prefix(&rev)
.map_err(|e| e.to_string())?
.oid
};
let entry = domain
.tree_entry_by_path_from_commit(&commit_oid, path)
.map_err(|e| e.to_string())?;
let content = domain.blob_content(&entry.oid).map_err(|e| e.to_string())?;
let data = &content.content;
if data.len() > MAX_FILE_SIZE {
return Err(format!(
"file too large ({} bytes), max {} bytes",
data.len(),
MAX_FILE_SIZE
));
}
// DOCX is a ZIP archive. Read word/document.xml from it.
let cursor = std::io::Cursor::new(data);
let mut archive = ZipArchive::new(cursor).map_err(|e| {
format!(
"failed to open docx as ZIP archive: {}. Make sure the file is a valid .docx document.",
e
)
})?;
let doc_xml = {
let file = if let Ok(f) = archive.by_name("word/document.xml") {
f
} else {
archive.by_name("document.xml")
.map_err(|_| "docx archive does not contain word/document.xml or document.xml")?
};
let mut s = String::new();
let mut reader = std::io::BufReader::new(file);
std::io::Read::read_to_string(&mut reader, &mut s)
.map_err(|e| format!("failed to read document.xml: {}", e))?;
s
};
// Parse paragraphs from <w:p> elements
let mut reader = Reader::from_str(&doc_xml);
reader.config_mut().trim_text(false);
let mut paragraphs: Vec<String> = Vec::new();
let mut buf = Vec::new();
let mut in_paragraph = false;
let mut current_text = String::new();
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(e)) => {
if e.name().as_ref() == b"w:p" {
in_paragraph = true;
current_text.clear();
}
}
Ok(Event::Text(e)) => {
if in_paragraph {
let txt = e.unescape().map(|s| s.into_owned()).unwrap_or_default();
current_text.push_str(&txt);
}
}
Ok(Event::End(e)) => {
if e.name().as_ref() == b"w:p" && in_paragraph {
in_paragraph = false;
let text = current_text.trim().to_string();
if !text.is_empty() {
paragraphs.push(text);
}
}
}
Ok(Event::Eof) => break,
_ => {}
}
buf.clear();
}
let total = paragraphs.len();
let body: Vec<serde_json::Value> = if sections_only {
paragraphs
.iter()
.enumerate()
.filter(|(_, text)| {
text.chars().next().map(|c| c.is_uppercase()).unwrap_or(false)
&& text.chars().filter(|&c| c == ' ').count() < text.len() / 2
&& text.len() < 200
})
.skip(offset)
.take(limit)
.map(|(i, t)| serde_json::json!({ "index": i, "text": t }))
.collect()
} else {
paragraphs
.iter()
.skip(offset)
.take(limit)
.enumerate()
.map(|(i, t)| serde_json::json!({ "index": offset + i, "text": t }))
.collect()
};
Ok(serde_json::json!({
"path": path,
"rev": rev,
"paragraph_count": total,
"paragraphs": body,
}))
}
pub fn register_word_tools(registry: &mut ToolRegistry) {
let p = HashMap::from([
("project_name".into(), ToolParam { name: "project_name".into(), param_type: "string".into(), description: Some("Project name (slug)".into()), required: true, properties: None, items: None }),
("repo_name".into(), ToolParam { name: "repo_name".into(), param_type: "string".into(), description: Some("Repository name".into()), required: true, properties: None, items: None }),
("path".into(), ToolParam { name: "path".into(), param_type: "string".into(), description: Some("File path to the .docx document".into()), required: true, properties: None, items: None }),
("rev".into(), ToolParam { name: "rev".into(), param_type: "string".into(), description: Some("Git revision (default: HEAD)".into()), required: false, properties: None, items: None }),
("sections_only".into(), ToolParam { name: "sections_only".into(), param_type: "boolean".into(), description: Some("If true, extract only section/heading-like paragraphs (short lines starting with uppercase)".into()), required: false, properties: None, items: None }),
("offset".into(), ToolParam { name: "offset".into(), param_type: "integer".into(), description: Some("Number of paragraphs to skip (default: 0)".into()), required: false, properties: None, items: None }),
("limit".into(), ToolParam { name: "limit".into(), param_type: "integer".into(), description: Some("Maximum paragraphs to return (default: 200)".into()), required: false, properties: None, items: None }),
]);
let schema = ToolSchema { schema_type: "object".into(), properties: Some(p), required: Some(vec!["project_name".into(), "repo_name".into(), "path".into()]) };
registry.register(
ToolDefinition::new("read_word")
.description("Parse and extract text from Word documents (.docx). Returns paragraphs with index and text content. Supports pagination.")
.parameters(schema),
ToolHandler::new(|ctx, args| {
let gctx = GitToolCtx::new(ctx);
Box::pin(async move {
read_word_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError)
})
}),
);
}

View File

@ -803,9 +803,20 @@ impl AppService {
commits.into_iter().map(CommitMetaResponse::from).collect();
// Get total count for pagination metadata.
// Must use the same cache key format as git_commit_count:
// git:commit:count:{namespace}:{repo_name}:{from:?}:{to:?}
// where from/to correspond to the rev spec passed to commit_log.
let (from, to) = match rev_for_count {
Some(rev) if rev.contains("..") => {
let parts: Vec<&str> = rev.splitn(2, "..").collect();
(Some(parts[0].to_string()), Some(parts[1].to_string()))
}
Some(rev) => (None, Some(rev)),
None => (None, None),
};
let total_cache_key = format!(
"git:commit:count:{}:{}:{:?}",
namespace, repo_name, rev_for_count,
"git:commit:count:{}:{}:{:?}:{:?}",
namespace, repo_name, from, to,
);
let total: usize = if let Ok(mut conn) = self.cache.conn().await {
if let Ok(cached) = conn.get::<_, String>(total_cache_key.clone()).await {
@ -1326,7 +1337,7 @@ impl AppService {
} else if reverse {
CommitSort(CommitSort::TIME.0 | CommitSort::REVERSE.0)
} else {
CommitSort(CommitSort::TOPOLOGICAL.0 | CommitSort::TIME.0)
CommitSort(CommitSort::TIME.0)
};
let commits = git_spawn!(repo, domain -> {

View File

@ -39,7 +39,7 @@ impl AppService {
let domain = git::GitDomain::open_workdir(&path).map_err(AppError::from)?;
Ok(GitInitResponse {
path: domain.repo().path().to_string_lossy().to_string(),
is_bare: true,
is_bare: false,
})
}

View File

@ -6,6 +6,45 @@ use redis::AsyncCommands;
use serde::{Deserialize, Serialize};
use session::Session;
/// Delete all cached ref list entries for a given namespace/repo.
/// Redis DEL does not support glob patterns, so we SCAN and delete each key.
async fn invalidate_ref_cache(
cache: &db::cache::AppCache,
namespace: &str,
repo_name: &str,
) {
let prefix = format!("git:ref:list:{}:{}:", namespace, repo_name);
if let Ok(mut conn) = cache.conn().await {
let pattern = format!("{}*", prefix);
let mut cursor: u64 = 0;
loop {
match redis::cmd("SCAN")
.arg(cursor)
.arg("MATCH")
.arg(&pattern)
.arg("COUNT")
.arg(100)
.query_async::<(u64, Vec<String>)>(&mut conn)
.await
{
Ok((new_cursor, keys)) => {
for key in &keys {
let _: () = conn.del(key).await.unwrap_or(());
}
if new_cursor == 0 {
break;
}
cursor = new_cursor;
}
Err(e) => {
tracing::debug!(error = ?e, "cache scan failed (non-fatal)");
break;
}
}
}
}
}
#[derive(Debug, Clone, Deserialize, utoipa::IntoParams)]
pub struct RefListQuery {
pub pattern: Option<String>,
@ -201,12 +240,7 @@ impl AppService {
.map_err(|e| AppError::InternalServerError(format!("Task join error: {}", e)))?
.map_err(AppError::from)?;
if let Ok(mut conn) = self.cache.conn().await {
let key = format!("git:ref:list:{}:{}:*", namespace, repo_name);
if let Err(e) = conn.del::<String, ()>(key).await {
tracing::debug!(error = ?e, "cache del failed (non-fatal)");
}
}
invalidate_ref_cache(&self.cache, &namespace, &repo_name).await;
Ok(RefUpdateResponse {
name: result.name,
@ -234,12 +268,7 @@ impl AppService {
.map_err(|e| AppError::InternalServerError(format!("Task join error: {}", e)))?
.map_err(AppError::from)?;
if let Ok(mut conn) = self.cache.conn().await {
let key = format!("git:ref:list:{}:{}:*", namespace, repo_name);
if let Err(e) = conn.del::<String, ()>(key).await {
tracing::debug!(error = ?e, "cache del failed (non-fatal)");
}
}
invalidate_ref_cache(&self.cache, &namespace, &repo_name).await;
Ok(RefDeleteResponse {
name,
@ -269,12 +298,7 @@ impl AppService {
.map_err(|e| AppError::InternalServerError(format!("Task join error: {}", e)))?
.map_err(AppError::from)?;
if let Ok(mut conn) = self.cache.conn().await {
let key = format!("git:ref:list:{}:{}:*", namespace, repo_name);
if let Err(e) = conn.del::<String, ()>(key).await {
tracing::debug!(error = ?e, "cache del failed (non-fatal)");
}
}
invalidate_ref_cache(&self.cache, &namespace, &repo_name).await;
Ok(RefInfoResponse::from(info))
}

View File

@ -465,6 +465,16 @@ impl AppService {
}
active.update(&txn).await?;
txn.commit().await?;
self.room.publish_room_event(
repo.project,
room::RoomEventType::RepoUpdated,
None,
None,
None,
None,
).await;
Ok(())
}
}

View File

@ -39,7 +39,7 @@ impl AppService {
.one(&self.db)
.await?;
if existing.is_some() {
return Err(AppError::InternalServerError("already starred".to_string()));
return Err(AppError::Conflict("already starred".to_string()));
}
RepoStar::insert(repo_star::ActiveModel {
id: Default::default(),
@ -97,7 +97,7 @@ impl AppService {
.exec(&self.db)
.await?;
if deleted.rows_affected == 0 {
return Err(AppError::InternalServerError("not starred".to_string()));
return Err(AppError::NotFound("not starred".to_string()));
}
let project_id = match repo_model::Entity::find_by_id(repo.id).one(&self.db).await {
Ok(Some(r)) => r.project,

View File

@ -51,9 +51,7 @@ impl AppService {
.one(&self.db)
.await?;
if existing.is_some() {
return Err(AppError::InternalServerError(
"already watching".to_string(),
));
return Err(AppError::Conflict("already watching".to_string()));
}
RepoWatch::insert(repo_watch::ActiveModel {
id: Default::default(),
@ -110,7 +108,7 @@ impl AppService {
.exec(&self.db)
.await?;
if deleted.rows_affected == 0 {
return Err(AppError::InternalServerError("not watching".to_string()));
return Err(AppError::NotFound("not watching".to_string()));
}
let project_id = match repo_model::Entity::find_by_id(repo.id).one(&self.db).await {
Ok(Some(r)) => r.project,

View File

@ -196,9 +196,9 @@ impl AppService {
tracing::info!(url = %base_url, "AI chat enabled");
let ai_client_config = AiClientConfig::new(api_key).with_base_url(&base_url);
let mut registry = ToolRegistry::new();
git_tools::register_all(&mut registry);
file_tools::register_all(&mut registry);
project_tools::register_all(&mut registry);
fctool::git_tools::register_all(&mut registry);
fctool::file_tools::register_all(&mut registry);
fctool::project_tools::register_all(&mut registry);
let mut chat_svc = ChatService::new()
.with_ai_client_config(ai_client_config)
.with_tool_registry(registry);
@ -324,12 +324,9 @@ impl AppService {
pub mod agent;
pub mod auth;
pub mod error;
pub mod file_tools;
pub mod git;
pub mod git_tools;
pub mod issue;
pub mod project;
pub mod project_tools;
pub mod pull_request;
pub mod search;
pub mod skill;

View File

@ -337,6 +337,15 @@ impl AppService {
)
.await;
self.room.publish_room_event(
project.id,
room::RoomEventType::RepoCreated,
None,
None,
None,
None,
).await;
Ok(ProjectRepoCreateResponse {
uid: repo.id,
repo_name: repo.repo_name,

View File

@ -171,7 +171,7 @@ export const DiscordMemberList = memo(function DiscordMemberList({
icon={<Bot className="h-3 w-3" />}
>
{aiConfigs.map((ai) => {
const label = ai.modelName ?? ai.model;
const label = ai.modelName || 'Unknown AI';
return (
<button
key={ai.model}

View File

@ -76,7 +76,7 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
{roomName, onSend, replyingTo, onCancelReply},
ref,
) {
const {members, activeRoomId, roomAiConfigs, wsClient} = useRoom();
const {members, activeRoomId, roomAiConfigs, projectRepos, wsClient} = useRoom();
// Ref passed to the inner IMEditor
const innerEditorRef = useRef<IMEditorHandle | null>(null);
@ -147,12 +147,18 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
channels: [] as { id: string; label: string; type: 'channel'; avatar?: string }[],
ai: roomAiConfigs.map((cfg) => ({
id: cfg.model,
label: cfg.modelName ?? cfg.model,
label: cfg.modelName || 'Unknown AI',
type: 'ai' as const,
})),
repos: projectRepos.map((r) => ({
id: r.repo_name,
label: r.repo_name,
type: 'repo' as const,
description: r.description ?? undefined,
})),
commands: SLASH_COMMANDS,
specialMentions: SPECIAL_MENTIONS,
}), [members, roomAiConfigs]);
}), [members, roomAiConfigs, projectRepos]);
// File upload handler — POST to /rooms/{room_id}/upload
const handleUploadFile = async (file: File): Promise<{ id: string; url: string }> => {

View File

@ -26,6 +26,7 @@ export interface IMEditorProps {
users: MentionItem[];
channels: MentionItem[];
ai: MentionItem[];
repos: MentionItem[];
commands: MentionItem[];
specialMentions?: MentionItem[];
};
@ -151,6 +152,7 @@ function getBadge(type: MentionType): { label: string; cls: string } | null {
if (type === 'ai') return {label: 'AI', cls: 'bg-blue-50 text-blue-600'};
if (type === 'channel') return {label: '#', cls: 'bg-gray-100 text-gray-500'};
if (type === 'command') return {label: 'cmd', cls: 'bg-amber-50 text-amber-600'};
if (type === 'repo') return {label: 'repo', cls: 'bg-green-50 text-green-600'};
return null;
}
@ -173,12 +175,13 @@ function serializeAstNode(node: EditorNode): string {
// ─── Mention Dropdown (sectioned by type) ────────────────────────────────────
const SECTION_ORDER = ['special_here', 'special_channel', 'ai', 'user', 'channel', 'command'] as const;
const SECTION_ORDER = ['special_here', 'special_channel', 'ai', 'user', 'repo', 'channel', 'command'] as const;
const SECTION_LABELS: Record<string, string> = {
special_here: 'Notify',
special_channel: 'Notify',
ai: 'AI',
user: 'Members',
repo: 'Repositories',
channel: 'Channels',
command: 'Commands',
};
@ -356,7 +359,8 @@ export const IMEditor = forwardRef<IMEditorHandle, IMEditorProps>(function IMEdi
...(mentionItems.specialMentions ?? []),
...mentionItems.ai,
...mentionItems.users,
], [mentionItems.specialMentions, mentionItems.ai, mentionItems.users]);
...mentionItems.repos,
], [mentionItems.specialMentions, mentionItems.ai, mentionItems.users, mentionItems.repos]);
const hashPool = useMemo(() => [...mentionItems.channels], [mentionItems.channels]);

View File

@ -2,7 +2,7 @@
* Core types for the IM editor (mentions, files, emojis).
*/
export type MentionType = 'user' | 'channel' | 'ai' | 'command' | 'special_here' | 'special_channel';
export type MentionType = 'user' | 'channel' | 'ai' | 'repo' | 'command' | 'special_here' | 'special_channel';
export interface MentionItem {
id: string;

View File

@ -42,6 +42,11 @@ const TYPE_STYLE: Record<MentionType, { light: string; dark?: string; prefix: st
dark: 'dark:bg-orange-900/30 dark:text-orange-300',
prefix: '@',
},
repo: {
light: 'bg-teal-50 text-teal-600',
dark: 'dark:bg-teal-900/30 dark:text-teal-300',
prefix: '@',
},
};
export function MentionBadge({ type, label, onClick, id, className }: MentionBadgeProps) {

View File

@ -195,6 +195,8 @@ export function RoomProvider({
const [wsClient, setWsClient] = useState<RoomWsClient | null>(null);
const wsClientRef = useRef<RoomWsClient | null>(null);
const activeRoomIdRef = useRef<string | null>(activeRoomId);
const fetchRoomAiConfigsRef = useRef<() => Promise<void>>(async () => {});
const fetchProjectReposRef = useRef<() => Promise<void>>(async () => {});
const [wsStatus, setWsStatus] = useState<RoomWsStatus>('idle');
const [wsError, setWsError] = useState<string | null>(null);
const [wsToken, setWsToken] = useState<string | null>(null);
@ -723,6 +725,14 @@ export function RoomProvider({
if (payload.room_id !== activeRoomIdRef.current) return;
setPins((prev) => prev.filter((p) => p.message !== payload.message_id));
},
onRoomAiUpdated: (payload) => {
if (payload.room_id && payload.room_id === activeRoomIdRef.current) {
fetchRoomAiConfigsRef.current();
}
},
onRepoChanged: () => {
fetchProjectReposRef.current();
},
onUserPresence: (payload) => {
if (payload.room_id !== activeRoomIdRef.current) return;
setPresence((prev) => ({ ...prev, [payload.user_id]: payload.status }));
@ -1303,6 +1313,9 @@ export function RoomProvider({
}
}, [activeRoomId]);
useEffect(() => { fetchRoomAiConfigsRef.current = fetchRoomAiConfigs; }, [fetchRoomAiConfigs]);
useEffect(() => { fetchProjectReposRef.current = fetchProjectRepos; }, [fetchProjectRepos]);
useEffect(() => {
fetchProjectRepos();
}, [fetchProjectRepos]);

View File

@ -7,6 +7,7 @@ export type MentionType =
| 'user'
| 'channel'
| 'ai'
| 'repo'
| 'command'
| 'special_here'
| 'special_channel';
@ -15,6 +16,7 @@ export const MENTION_TYPES: MentionType[] = [
'user',
'channel',
'ai',
'repo',
'command',
'special_here',
'special_channel',
@ -84,6 +86,11 @@ export function serializeAiMention(aiId: string, aiName: string): string {
return serializeMention('ai', aiId, aiName);
}
/** Build a mention token from a repo mention. */
export function serializeRepoMention(repoName: string, label?: string): string {
return serializeMention('repo', repoName, label ?? repoName);
}
/** Build a mention token from a command mention. */
export function serializeCommandMention(commandId: string, commandName: string): string {
return serializeMention('command', commandId, commandName);

View File

@ -81,6 +81,8 @@ export interface RoomWsCallbacks {
onUserPresence?: (payload: UserPresencePayload) => void;
onTypingStart?: (payload: import('./ws-protocol').TypingStartPayload) => void;
onTypingStop?: (payload: import('./ws-protocol').TypingStopPayload) => void;
onRoomAiUpdated?: (payload: ProjectEventPayload) => void;
onRepoChanged?: (payload: ProjectEventPayload) => void;
onStatusChange?: (status: RoomWsStatus) => void;
onError?: (error: Error) => void;
/** Called each time the client sends a heartbeat ping */
@ -212,7 +214,8 @@ export class RoomWsClient {
// Safety timeout: if not open within 10s, give up
const timeoutId = setTimeout(() => {
if (this.status === 'connecting') {
console.error(`[RoomWs] Connection timeout after 10s — closing`);
console.error(`[RoomWs] Connection timeout after 10s — clearing token and closing`);
this.wsToken = null;
this.ws?.close();
this.setStatus('error');
reject(new Error('Connection timeout'));
@ -246,6 +249,10 @@ export class RoomWsClient {
req.reject(new Error(`WebSocket closed: ${ev.reason || 'unknown'}`));
}
this.pendingRequests.clear();
// Auth-related close codes (3000-4999) — clear token so reconnect fetches a fresh one
if (ev.code >= 3000 && ev.code < 5000) {
this.wsToken = null;
}
if (this.shouldReconnect) {
this.scheduleReconnect();
}
@ -1130,6 +1137,14 @@ export class RoomWsClient {
room_id: event.room_id ?? '',
} as import('./ws-protocol').MessageUnpinnedPayload);
break;
case 'room_ai_updated':
this.callbacks.onRoomAiUpdated?.(event);
break;
case 'repo_created':
case 'repo_updated':
case 'repo_deleted':
this.callbacks.onRepoChanged?.(event);
break;
default:
// Other project events (member_joined, room_created, etc.)
this.callbacks.onProjectEvent?.(event);
@ -1178,8 +1193,15 @@ export class RoomWsClient {
const delay = Math.floor(jitter);
this.reconnectAttempt++;
// After 3 consecutive failures, clear token so next connect fetches a fresh one
const forceNew = this.reconnectAttempt >= 3;
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
if (forceNew) {
this.wsToken = null;
console.debug('[RoomWs] Clearing token after 3 reconnect failures, will fetch fresh');
}
this.connect().catch(() => {});
}, delay);
}