Compare commits
8 Commits
0e53f4a69f
...
3f1f0d5e23
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f1f0d5e23 | ||
|
|
64dc27161b | ||
|
|
a26551343c | ||
|
|
c8eba28e7a | ||
|
|
adbc0705db | ||
|
|
d72019e39f | ||
|
|
283835eb26 | ||
|
|
c7a8bc0458 |
26
Cargo.lock
generated
26
Cargo.lock
generated
@ -2757,6 +2757,31 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"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]]
|
[[package]]
|
||||||
name = "fdeflate"
|
name = "fdeflate"
|
||||||
version = "0.3.7"
|
version = "0.3.7"
|
||||||
@ -7705,6 +7730,7 @@ dependencies = [
|
|||||||
"db",
|
"db",
|
||||||
"deadpool-redis",
|
"deadpool-redis",
|
||||||
"email",
|
"email",
|
||||||
|
"fctool",
|
||||||
"flate2",
|
"flate2",
|
||||||
"futures",
|
"futures",
|
||||||
"git",
|
"git",
|
||||||
|
|||||||
@ -19,6 +19,7 @@ members = [
|
|||||||
"libs/agent",
|
"libs/agent",
|
||||||
"libs/migrate",
|
"libs/migrate",
|
||||||
"libs/agent-tool-derive",
|
"libs/agent-tool-derive",
|
||||||
|
"libs/fctool",
|
||||||
"apps/migrate",
|
"apps/migrate",
|
||||||
"apps/app",
|
"apps/app",
|
||||||
"apps/adminrpc",
|
"apps/adminrpc",
|
||||||
@ -50,6 +51,7 @@ observability = { path = "libs/observability" }
|
|||||||
avatar = { path = "libs/avatar" }
|
avatar = { path = "libs/avatar" }
|
||||||
migrate = { path = "libs/migrate" }
|
migrate = { path = "libs/migrate" }
|
||||||
session_manager = { path = "libs/session_manager" }
|
session_manager = { path = "libs/session_manager" }
|
||||||
|
fctool = { path = "libs/fctool" }
|
||||||
|
|
||||||
sea-query = "1.0.0-rc.31"
|
sea-query = "1.0.0-rc.31"
|
||||||
|
|
||||||
|
|||||||
@ -605,19 +605,37 @@ impl ChatService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(embed_service) = &self.embed_service {
|
for mention in &request.mention {
|
||||||
for mention in &request.mention {
|
match mention {
|
||||||
match mention {
|
Mention::Repo(repo) => {
|
||||||
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!(
|
let query = format!(
|
||||||
"{} {}",
|
"{} {}",
|
||||||
repo.repo_name,
|
repo.repo_name,
|
||||||
repo.description.as_deref().unwrap_or_default()
|
repo.description.as_deref().unwrap_or_default()
|
||||||
);
|
);
|
||||||
match embed_service.search_issues(&query, 5).await {
|
if let Ok(issues) = embed_service.search_issues(&query, 5).await {
|
||||||
Ok(issues) if !issues.is_empty() => {
|
if !issues.is_empty() {
|
||||||
let context = format!(
|
let context = format!(
|
||||||
"Related issues:\n{}",
|
"Related issues for repo {}:\n{}",
|
||||||
|
repo.repo_name,
|
||||||
issues
|
issues
|
||||||
.iter()
|
.iter()
|
||||||
.map(|i| format!("- {}", i.payload.text))
|
.map(|i| format!("- {}", i.payload.text))
|
||||||
@ -626,15 +644,11 @@ impl ChatService {
|
|||||||
);
|
);
|
||||||
messages.push(ChatRequestMessage::system(context));
|
messages.push(ChatRequestMessage::system(context));
|
||||||
}
|
}
|
||||||
Err(e) => {
|
|
||||||
let _ = e;
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
match embed_service.search_repos(&query, 3).await {
|
if let Ok(repos) = embed_service.search_repos(&query, 3).await {
|
||||||
Ok(repos) if !repos.is_empty() => {
|
if !repos.is_empty() {
|
||||||
let context = format!(
|
let context = format!(
|
||||||
"Related repositories:\n{}",
|
"Similar repositories:\n{}",
|
||||||
repos
|
repos
|
||||||
.iter()
|
.iter()
|
||||||
.map(|r| format!("- {}", r.payload.text))
|
.map(|r| format!("- {}", r.payload.text))
|
||||||
@ -643,28 +657,24 @@ impl ChatService {
|
|||||||
);
|
);
|
||||||
messages.push(ChatRequestMessage::system(context));
|
messages.push(ChatRequestMessage::system(context));
|
||||||
}
|
}
|
||||||
Err(e) => {
|
|
||||||
let _ = e;
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Mention::User(user) => {
|
}
|
||||||
let mut profile_parts = vec![format!("Username: {}", user.username)];
|
Mention::User(user) => {
|
||||||
if let Some(ref display_name) = user.display_name {
|
let mut profile_parts = vec![format!("Username: {}", user.username)];
|
||||||
profile_parts.push(format!("Display name: {}", display_name));
|
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")
|
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
|
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")
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,7 +19,12 @@ pub async fn list_accessible_models(
|
|||||||
base_url: &str,
|
base_url: &str,
|
||||||
api_key: &str,
|
api_key: &str,
|
||||||
) -> Result<std::collections::HashSet<String>, AgentError> {
|
) -> 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
|
let resp = client
|
||||||
.get(&url)
|
.get(&url)
|
||||||
.header("Authorization", format!("Bearer {}", api_key))
|
.header("Authorization", format!("Bearer {}", api_key))
|
||||||
|
|||||||
39
libs/fctool/Cargo.toml
Normal file
39
libs/fctool/Cargo.toml
Normal 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 }
|
||||||
@ -51,7 +51,7 @@ async fn read_csv_exec(
|
|||||||
|
|
||||||
let domain = ctx.open_repo(project_name, repo_name).await?;
|
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)
|
git::commit::types::CommitOid::new(&rev)
|
||||||
} else {
|
} else {
|
||||||
domain
|
domain
|
||||||
@ -67,7 +67,7 @@ async fn git_grep_exec(
|
|||||||
let domain = ctx.open_repo(project_name, repo_name).await?;
|
let domain = ctx.open_repo(project_name, repo_name).await?;
|
||||||
|
|
||||||
// Resolve revision to commit oid
|
// 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)
|
git::commit::types::CommitOid::new(&rev)
|
||||||
} else {
|
} else {
|
||||||
domain
|
domain
|
||||||
@ -130,7 +130,7 @@ async fn read_json_exec(
|
|||||||
|
|
||||||
let domain = ctx.open_repo(project_name, repo_name).await?;
|
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)
|
git::commit::types::CommitOid::new(&rev)
|
||||||
} else {
|
} else {
|
||||||
domain
|
domain
|
||||||
@ -41,7 +41,7 @@ async fn read_markdown_exec(
|
|||||||
|
|
||||||
let domain = ctx.open_repo(project_name, repo_name).await?;
|
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)
|
git::commit::types::CommitOid::new(&rev)
|
||||||
} else {
|
} else {
|
||||||
domain
|
domain
|
||||||
@ -33,7 +33,7 @@ async fn read_sql_exec(
|
|||||||
|
|
||||||
let domain = ctx.open_repo(project_name, repo_name).await?;
|
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)
|
git::commit::types::CommitOid::new(&rev)
|
||||||
} else {
|
} else {
|
||||||
domain
|
domain
|
||||||
273
libs/fctool/src/git_tools/blob.rs
Normal file
273
libs/fctool/src/git_tools/blob.rs
Normal 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)
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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 info = domain.branch_get(name).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
let ahead_behind = if let Some(ref upstream) = info.upstream {
|
let ahead_behind = if let Some(ref upstream) = info.upstream {
|
||||||
let (ahead, behind) = domain.branch_ahead_behind(name, upstream).unwrap_or((0, 0));
|
match domain.branch_ahead_behind(name, upstream) {
|
||||||
Some(serde_json::json!({ "ahead": ahead, "behind": behind }))
|
Ok((ahead, behind)) => Some(serde_json::json!({ "ahead": ahead, "behind": behind })),
|
||||||
|
Err(e) => Some(serde_json::json!({ "error": e.to_string() })),
|
||||||
|
}
|
||||||
} else { None };
|
} else { None };
|
||||||
|
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
@ -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())?)
|
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> {
|
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 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 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 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 domain = ctx.open_repo(project_name, repo_name).await?;
|
||||||
let meta = if rev.len() >= 40 {
|
let meta = resolve_commit(&domain, rev).map_err(|e| e.to_string())?;
|
||||||
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 refs = domain.commit_refs(&meta.oid).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 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 domain = ctx.open_repo(project_name, repo_name).await?;
|
||||||
let meta = if rev.len() >= 40 {
|
let meta = resolve_commit(&domain, rev).map_err(|e| e.to_string())?;
|
||||||
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())?
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(flatten_commit(&meta))
|
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()
|
let result: Vec<_> = entries.iter()
|
||||||
.take(limit)
|
.take(limit)
|
||||||
.map(|e| {
|
.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()
|
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!({
|
serde_json::json!({
|
||||||
"oid_new": e.oid_new.to_string(), "oid_old": e.oid_old.to_string(),
|
"oid_new": e.oid_new.to_string(), "oid_old": e.oid_old.to_string(),
|
||||||
"committer_name": e.committer_name, "committer_email": e.committer_email,
|
"committer_name": e.committer_name, "committer_email": e.committer_email,
|
||||||
@ -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 domain = ctx.open_repo(project_name, repo_name).await?;
|
||||||
|
|
||||||
let resolve = |rev: &str| -> Result<git::commit::types::CommitOid, String> {
|
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))
|
Ok(git::commit::types::CommitOid::new(rev))
|
||||||
} else {
|
} else {
|
||||||
domain.commit_get_prefix(rev).map_err(|e| e.to_string()).map(|m| m.oid)
|
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| {
|
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();
|
}).collect();
|
||||||
|
|
||||||
Ok(serde_json::json!({
|
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 domain = ctx.open_repo(project_name, repo_name).await?;
|
||||||
|
|
||||||
let stats = if base.len() >= 40 && head.len() >= 40 {
|
let resolve = |rev: &str| -> Result<git::commit::types::CommitOid, String> {
|
||||||
domain.diff_stats(&git::commit::types::CommitOid::new(base), &git::commit::types::CommitOid::new(head))
|
if rev.len() == 40 && rev.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||||
.map_err(|e| e.to_string())?
|
Ok(git::commit::types::CommitOid::new(rev))
|
||||||
} else {
|
|
||||||
let b = if base.len() >= 40 {
|
|
||||||
git::commit::types::CommitOid::new(base)
|
|
||||||
} else {
|
} else {
|
||||||
domain.commit_get_prefix(base).map_err(|e| e.to_string())?.oid
|
domain.commit_get_prefix(rev).map_err(|e| e.to_string()).map(|m| m.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())?
|
|
||||||
};
|
};
|
||||||
|
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!({
|
Ok(serde_json::json!({
|
||||||
"files_changed": stats.files_changed,
|
"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 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 domain = ctx.open_repo(project_name, repo_name).await?;
|
||||||
let oid = if rev.len() >= 40 {
|
let oid = if rev.len() == 40 && rev.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||||
git::commit::types::CommitOid::new(&rev)
|
Ok(git::commit::types::CommitOid::new(&rev))
|
||||||
} else {
|
} 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;
|
use git::blame::ops::BlameOptions;
|
||||||
let mut bopts = BlameOptions::new();
|
let mut bopts = BlameOptions::new();
|
||||||
@ -3,6 +3,7 @@
|
|||||||
//! Each module defines async exec functions + a `register_git_tools()` call.
|
//! Each module defines async exec functions + a `register_git_tools()` call.
|
||||||
//! All tools take `project_name` + `repo_name` as required params.
|
//! All tools take `project_name` + `repo_name` as required params.
|
||||||
|
|
||||||
|
pub mod blob;
|
||||||
pub mod branch;
|
pub mod branch;
|
||||||
pub mod commit;
|
pub mod commit;
|
||||||
pub mod ctx;
|
pub mod ctx;
|
||||||
@ -16,6 +17,7 @@ pub fn register_all(registry: &mut agent::ToolRegistry) {
|
|||||||
commit::register_git_tools(registry);
|
commit::register_git_tools(registry);
|
||||||
branch::register_git_tools(registry);
|
branch::register_git_tools(registry);
|
||||||
diff::register_git_tools(registry);
|
diff::register_git_tools(registry);
|
||||||
|
blob::register_git_tools(registry);
|
||||||
tree::register_git_tools(registry);
|
tree::register_git_tools(registry);
|
||||||
tag::register_git_tools(registry);
|
tag::register_git_tools(registry);
|
||||||
}
|
}
|
||||||
@ -16,12 +16,19 @@ async fn git_tag_list_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<s
|
|||||||
let result: Vec<_> = match pattern {
|
let result: Vec<_> = match pattern {
|
||||||
Some(ref pat) => {
|
Some(ref pat) => {
|
||||||
let pat_lower = pat.to_lowercase();
|
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()
|
all_tags.iter()
|
||||||
.filter(|t| {
|
.filter(|t| {
|
||||||
let n = t.name.to_lowercase();
|
let n = t.name.to_lowercase();
|
||||||
if has_wildcard { n.contains(&pat_lower.replace('*', "")) }
|
re.as_ref().map(|r| r.is_match(&n)).unwrap_or(false)
|
||||||
else { n.contains(&pat_lower) }
|
|
||||||
})
|
})
|
||||||
.map(|t| tag_to_json(t))
|
.map(|t| tag_to_json(t))
|
||||||
.collect()
|
.collect()
|
||||||
@ -5,6 +5,16 @@ use agent::{ToolDefinition, ToolHandler, ToolParam, ToolRegistry, ToolSchema};
|
|||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use std::collections::HashMap;
|
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> {
|
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 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 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 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 domain = ctx.open_repo(project_name, repo_name).await?;
|
||||||
let oid = if rev.len() >= 40 {
|
let oid = resolve_commit_oid(&domain, &rev)?;
|
||||||
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(&oid, path).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())?;
|
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 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 domain = ctx.open_repo(project_name, repo_name).await?;
|
||||||
let commit_oid = if rev.len() >= 40 {
|
let commit_oid = resolve_commit_oid(&domain, &rev).map_err(|e| e.to_string())?;
|
||||||
git::commit::types::CommitOid::new(&rev)
|
|
||||||
} else {
|
|
||||||
domain.commit_get_prefix(&rev).map_err(|e| e.to_string())?.oid
|
|
||||||
};
|
|
||||||
|
|
||||||
let entries = match dir_path {
|
let entries = match dir_path {
|
||||||
Some(ref dp) => {
|
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 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 domain = ctx.open_repo(project_name, repo_name).await?;
|
||||||
let oid = if rev.len() >= 40 {
|
let oid = resolve_commit_oid(&domain, &rev).map_err(|e| e.to_string())?;
|
||||||
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(&oid, path).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())?;
|
let blob_info = domain.blob_get(&entry.oid).map_err(|e| e.to_string())?;
|
||||||
@ -217,16 +217,24 @@ pub struct DiffFileOut {
|
|||||||
|
|
||||||
impl DiffFileOut {
|
impl DiffFileOut {
|
||||||
pub fn from_delta(delta: &DiffDelta) -> Self {
|
pub fn from_delta(delta: &DiffDelta) -> Self {
|
||||||
// For deleted files, use old_file.path; for all others, use new_file.path.
|
// For deleted files, use old_file for all metadata; for all others, use new_file.
|
||||||
let path = match delta.status {
|
let (path, is_binary, size) = match delta.status {
|
||||||
DiffDeltaStatus::Deleted => delta.old_file.path.clone(),
|
DiffDeltaStatus::Deleted => (
|
||||||
_ => delta.new_file.path.clone(),
|
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 {
|
Self {
|
||||||
path,
|
path,
|
||||||
status: format!("{:?}", delta.status),
|
status: format!("{:?}", delta.status),
|
||||||
is_binary: delta.new_file.is_binary,
|
is_binary,
|
||||||
size: delta.new_file.size,
|
size,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
5
libs/fctool/src/lib.rs
Normal file
5
libs/fctool/src/lib.rs
Normal 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;
|
||||||
@ -23,6 +23,14 @@ pub async fn list_repos_exec(
|
|||||||
let project_id = ctx.project_id();
|
let project_id = ctx.project_id();
|
||||||
let db = ctx.db();
|
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()
|
let repos = repo::Entity::find()
|
||||||
.filter(repo::Column::Project.eq(project_id))
|
.filter(repo::Column::Project.eq(project_id))
|
||||||
.order_by_asc(repo::Column::RepoName)
|
.order_by_asc(repo::Column::RepoName)
|
||||||
@ -36,6 +44,7 @@ pub async fn list_repos_exec(
|
|||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"id": r.id.to_string(),
|
"id": r.id.to_string(),
|
||||||
"name": r.repo_name,
|
"name": r.repo_name,
|
||||||
|
"project_name": project_name,
|
||||||
"description": r.description,
|
"description": r.description,
|
||||||
"default_branch": r.default_branch,
|
"default_branch": r.default_branch,
|
||||||
"is_private": r.is_private,
|
"is_private": r.is_private,
|
||||||
@ -293,8 +302,9 @@ pub async fn create_commit_exec(
|
|||||||
|
|
||||||
let repo_name = args
|
let repo_name = args
|
||||||
.get("repo_name")
|
.get("repo_name")
|
||||||
|
.or_else(|| args.get("name"))
|
||||||
.and_then(|v| v.as_str())
|
.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
|
let message = args
|
||||||
.get("message")
|
.get("message")
|
||||||
@ -308,10 +318,12 @@ pub async fn create_commit_exec(
|
|||||||
.unwrap_or("main")
|
.unwrap_or("main")
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
// Validate branch: no path traversal, no slashes
|
// Validate branch: no path traversal, no backslashes, not empty, no lock files
|
||||||
if branch.contains("..") || branch.contains('/') || branch.contains('\\') || branch.is_empty() {
|
if branch.contains("..") || branch.contains('\\') || branch.is_empty()
|
||||||
|
|| branch.ends_with(".lock") || branch.starts_with('-')
|
||||||
|
{
|
||||||
return Err(ToolError::ExecutionError(
|
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();
|
let mut p = HashMap::new();
|
||||||
p.insert("repo_name".into(), ToolParam {
|
p.insert("repo_name".into(), ToolParam {
|
||||||
name: "repo_name".into(), param_type: "string".into(),
|
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,
|
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 {
|
p.insert("branch".into(), ToolParam {
|
||||||
name: "branch".into(), param_type: "string".into(),
|
name: "branch".into(), param_type: "string".into(),
|
||||||
description: Some("Branch to commit to. Defaults to 'main'. Optional.".into()),
|
description: Some("Branch to commit to. Defaults to 'main'. Optional.".into()),
|
||||||
@ -266,7 +266,10 @@ impl GitDomain {
|
|||||||
let oid = entry.id();
|
let oid = entry.id();
|
||||||
let obj = match self.repo().find_object(oid, None) {
|
let obj = match self.repo().find_object(oid, None) {
|
||||||
Ok(o) => o,
|
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;
|
let mode = entry.filemode() as u32;
|
||||||
@ -381,7 +384,10 @@ impl GitDomain {
|
|||||||
let oid = entry.id();
|
let oid = entry.id();
|
||||||
let obj = match self.repo().find_object(oid, None) {
|
let obj = match self.repo().find_object(oid, None) {
|
||||||
Ok(o) => o,
|
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;
|
let mode = entry.filemode() as u32;
|
||||||
@ -408,7 +414,7 @@ impl GitDomain {
|
|||||||
.set_path(&full_path)
|
.set_path(&full_path)
|
||||||
.map_err(|e| GitError::Internal(e.to_string()))?;
|
.map_err(|e| GitError::Internal(e.to_string()))?;
|
||||||
header.set_size(content.len() as u64);
|
header.set_size(content.len() as u64);
|
||||||
header.set_mode(mode & 0o755);
|
header.set_mode(mode & 0o777);
|
||||||
header.set_cksum();
|
header.set_cksum();
|
||||||
|
|
||||||
builder
|
builder
|
||||||
@ -457,7 +463,10 @@ impl GitDomain {
|
|||||||
let oid = entry.id();
|
let oid = entry.id();
|
||||||
let obj = match self.repo().find_object(oid, None) {
|
let obj = match self.repo().find_object(oid, None) {
|
||||||
Ok(o) => o,
|
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;
|
let mode = entry.filemode() as u32;
|
||||||
@ -480,7 +489,7 @@ impl GitDomain {
|
|||||||
let content = blob.content();
|
let content = blob.content();
|
||||||
let options = zip::write::SimpleFileOptions::default()
|
let options = zip::write::SimpleFileOptions::default()
|
||||||
.compression_method(zip::CompressionMethod::Deflated)
|
.compression_method(zip::CompressionMethod::Deflated)
|
||||||
.unix_permissions(mode & 0o755);
|
.unix_permissions(mode & 0o777);
|
||||||
|
|
||||||
zip.start_file(&full_path, options)
|
zip.start_file(&full_path, options)
|
||||||
.map_err(|e| GitError::Internal(e.to_string()))?;
|
.map_err(|e| GitError::Internal(e.to_string()))?;
|
||||||
@ -511,14 +520,17 @@ impl GitDomain {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if opts.max_depth.map_or(false, |d| depth >= d) {
|
if opts.max_depth.map_or(false, |d| depth > d) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let oid = entry.id();
|
let oid = entry.id();
|
||||||
let obj = match self.repo().find_object(oid, None) {
|
let obj = match self.repo().find_object(oid, None) {
|
||||||
Ok(o) => o,
|
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;
|
let mode = entry.filemode() as u32;
|
||||||
|
|||||||
@ -63,6 +63,9 @@ impl BlameOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl GitDomain {
|
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(
|
pub fn blame_file(
|
||||||
&self,
|
&self,
|
||||||
commit_oid: &CommitOid,
|
commit_oid: &CommitOid,
|
||||||
@ -238,8 +241,8 @@ impl GitDomain {
|
|||||||
.blame_file(std::path::Path::new(path), Some(&mut blame_opts))
|
.blame_file(std::path::Path::new(path), Some(&mut blame_opts))
|
||||||
.map_err(|e| GitError::Internal(e.to_string()))?;
|
.map_err(|e| GitError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
// Use get_line to find the hunk at the given line
|
// get_line expects 1-based line numbers; caller provides 0-based.
|
||||||
let hunk_opt = blame.get_line(line_no);
|
let hunk_opt = blame.get_line(line_no + 1);
|
||||||
|
|
||||||
match hunk_opt {
|
match hunk_opt {
|
||||||
Some(hunk) => Ok(CommitBlameHunk {
|
Some(hunk) => Ok(CommitBlameHunk {
|
||||||
|
|||||||
@ -58,11 +58,8 @@ impl GitDomain {
|
|||||||
.repo()
|
.repo()
|
||||||
.find_commit(oid.to_oid()?)
|
.find_commit(oid.to_oid()?)
|
||||||
.map_err(|_e| GitError::ObjectNotFound(oid.to_string()))?;
|
.map_err(|_e| GitError::ObjectNotFound(oid.to_string()))?;
|
||||||
let len = oid.0.len();
|
let take = 7.min(oid.0.len());
|
||||||
if len < 7 {
|
Ok(oid.0[..take].to_string())
|
||||||
return Err(GitError::InvalidOid(oid.to_string()));
|
|
||||||
}
|
|
||||||
Ok(oid.0[..7].to_string())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn commit_author(&self, oid: &CommitOid) -> GitResult<CommitSignature> {
|
pub fn commit_author(&self, oid: &CommitOid) -> GitResult<CommitSignature> {
|
||||||
|
|||||||
@ -129,8 +129,8 @@ impl GitDomain {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn rebase_abort(&self) -> GitResult<()> {
|
pub fn rebase_abort(&self) -> GitResult<()> {
|
||||||
// git2 rebase sessions are not persistent across process exits.
|
self.repo()
|
||||||
// The caller resets HEAD to the original position.
|
.cleanup_state()
|
||||||
Ok(())
|
.map_err(|e| GitError::Internal(e.to_string()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,9 +21,17 @@ impl GitDomain {
|
|||||||
|
|
||||||
pub fn config_get(&self, key: &str) -> GitResult<Option<String>> {
|
pub fn config_get(&self, key: &str) -> GitResult<Option<String>> {
|
||||||
let cfg = self.config()?;
|
let cfg = self.config()?;
|
||||||
cfg.get_str(key)
|
match cfg.get_str(key) {
|
||||||
.map(Some)
|
Ok(v) => Ok(Some(v)),
|
||||||
.map_err(|e| GitError::ConfigError(e.to_string()))
|
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<()> {
|
pub fn config_set(&self, key: &str, value: &str) -> GitResult<()> {
|
||||||
|
|||||||
@ -281,7 +281,7 @@ impl DiffOptions {
|
|||||||
|
|
||||||
pub fn to_git2(&self) -> git2::DiffOptions {
|
pub fn to_git2(&self) -> git2::DiffOptions {
|
||||||
let mut opts = git2::DiffOptions::new();
|
let mut opts = git2::DiffOptions::new();
|
||||||
if self.context_lines != 3 {
|
if self.context_lines > 0 {
|
||||||
opts.context_lines(self.context_lines);
|
opts.context_lines(self.context_lines);
|
||||||
}
|
}
|
||||||
for p in &self.pathspec {
|
for p in &self.pathspec {
|
||||||
|
|||||||
@ -16,9 +16,14 @@ const POOL_GET_TIMEOUT: Duration = Duration::from_secs(5);
|
|||||||
impl RedisConsumer {
|
impl RedisConsumer {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
pool: deadpool_redis::cluster::Pool,
|
pool: deadpool_redis::cluster::Pool,
|
||||||
prefix: String,
|
mut prefix: String,
|
||||||
block_timeout_secs: u64,
|
block_timeout_secs: u64,
|
||||||
) -> Self {
|
) -> 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 {
|
Self {
|
||||||
pool,
|
pool,
|
||||||
prefix,
|
prefix,
|
||||||
|
|||||||
@ -20,6 +20,13 @@ pub fn is_valid_oid(oid: &str) -> bool {
|
|||||||
oid.len() == 40 && oid.chars().all(|c| c.is_ascii_hexdigit())
|
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 {
|
pub struct GitHttpHandler {
|
||||||
storage_path: PathBuf,
|
storage_path: PathBuf,
|
||||||
repo: repo::Model,
|
repo: repo::Model,
|
||||||
@ -248,53 +255,59 @@ fn check_branch_protection(
|
|||||||
let refs = parse_ref_updates(pre_pack)?;
|
let refs = parse_ref_updates(pre_pack)?;
|
||||||
for r#ref in &refs {
|
for r#ref in &refs {
|
||||||
for protection in branch_protects {
|
for protection in branch_protects {
|
||||||
if r#ref.name.starts_with(&protection.branch) {
|
// Match exactly or as directory prefix (e.g. "refs/heads/main"
|
||||||
// Check deletion (new_oid is all zeros / 40 zeros)
|
// matches "refs/heads/main" and "refs/heads/main/*" but NOT
|
||||||
if r#ref.new_oid.as_deref() == Some("0000000000000000000000000000000000000000") {
|
// "refs/heads/main-v2").
|
||||||
if protection.forbid_deletion {
|
let matches = r#ref.name == protection.branch
|
||||||
return Err(format!(
|
|| r#ref.name.starts_with(&format!("{}/", protection.branch));
|
||||||
"Deletion of protected branch '{}' is forbidden",
|
if !matches {
|
||||||
r#ref.name
|
continue;
|
||||||
));
|
}
|
||||||
}
|
// Check deletion (new_oid is all zeros / 40 zeros)
|
||||||
continue;
|
if r#ref.new_oid.as_deref() == Some("0000000000000000000000000000000000000000") {
|
||||||
}
|
if protection.forbid_deletion {
|
||||||
|
|
||||||
// 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!(
|
return Err(format!(
|
||||||
"Push to protected branch '{}' is forbidden",
|
"Deletion of protected branch '{}' is forbidden",
|
||||||
r#ref.name
|
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
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
use crate::error::GitError;
|
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 actix_web::{HttpResponse, web};
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use base64::engine::general_purpose::STANDARD;
|
use base64::engine::general_purpose::STANDARD;
|
||||||
@ -244,7 +244,7 @@ impl LfsHandler {
|
|||||||
payload: web::Payload,
|
payload: web::Payload,
|
||||||
_auth_token: &str,
|
_auth_token: &str,
|
||||||
) -> Result<HttpResponse, GitError> {
|
) -> Result<HttpResponse, GitError> {
|
||||||
if !is_valid_oid(oid) {
|
if !is_valid_lfs_oid(oid) {
|
||||||
return Err(GitError::InvalidOid(format!("Invalid OID format: {}", oid)));
|
return Err(GitError::InvalidOid(format!("Invalid OID format: {}", oid)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -332,7 +332,7 @@ impl LfsHandler {
|
|||||||
oid: &str,
|
oid: &str,
|
||||||
_auth_token: &str,
|
_auth_token: &str,
|
||||||
) -> Result<HttpResponse, GitError> {
|
) -> Result<HttpResponse, GitError> {
|
||||||
if !is_valid_oid(oid) {
|
if !is_valid_lfs_oid(oid) {
|
||||||
return Err(GitError::InvalidOid(format!("Invalid OID format: {}", oid)));
|
return Err(GitError::InvalidOid(format!("Invalid OID format: {}", oid)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -382,7 +382,7 @@ impl LfsHandler {
|
|||||||
) -> Result<LockResponse, GitError> {
|
) -> Result<LockResponse, GitError> {
|
||||||
use sea_orm::ActiveModelTrait;
|
use sea_orm::ActiveModelTrait;
|
||||||
|
|
||||||
if !is_valid_oid(oid) {
|
if !is_valid_lfs_oid(oid) {
|
||||||
return Err(GitError::InvalidOid(format!("Invalid OID format: {}", oid)));
|
return Err(GitError::InvalidOid(format!("Invalid OID format: {}", oid)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
use crate::error::GitError;
|
use crate::error::GitError;
|
||||||
use crate::http::HttpAppState;
|
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::lfs::{BatchRequest, CreateLockRequest, LfsHandler};
|
||||||
use crate::http::utils::get_repo_model;
|
use crate::http::utils::get_repo_model;
|
||||||
use actix_web::{Error, HttpRequest, HttpResponse, web};
|
use actix_web::{Error, HttpRequest, HttpResponse, web};
|
||||||
@ -82,7 +82,7 @@ pub async fn lfs_upload(
|
|||||||
) -> Result<HttpResponse, Error> {
|
) -> Result<HttpResponse, Error> {
|
||||||
let (namespace, repo_name, oid) = path.into_inner();
|
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"));
|
return Err(actix_web::error::ErrorBadRequest("Invalid OID format"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -112,7 +112,7 @@ pub async fn lfs_download(
|
|||||||
) -> Result<HttpResponse, Error> {
|
) -> Result<HttpResponse, Error> {
|
||||||
let (namespace, repo_name, oid) = path.into_inner();
|
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"));
|
return Err(actix_web::error::ErrorBadRequest("Invalid OID format"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -324,11 +324,12 @@ impl GitDomain {
|
|||||||
msg.push_str(&format!("- {}", commit.summary().unwrap_or("(no message)")));
|
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
|
let squash_oid = self
|
||||||
.repo()
|
.repo()
|
||||||
.commit(
|
.commit(
|
||||||
Some("HEAD"),
|
None,
|
||||||
&sig,
|
&sig,
|
||||||
&sig,
|
&sig,
|
||||||
&msg,
|
&msg,
|
||||||
|
|||||||
@ -25,6 +25,7 @@ pub fn validate_ref_name(name: &str) -> Result<(), GitError> {
|
|||||||
|| name.contains('*')
|
|| name.contains('*')
|
||||||
|| name.contains('[')
|
|| name.contains('[')
|
||||||
|| name.contains('\\')
|
|| name.contains('\\')
|
||||||
|
|| name.contains('@')
|
||||||
{
|
{
|
||||||
return Err(GitError::InvalidRefName(format!(
|
return Err(GitError::InvalidRefName(format!(
|
||||||
"invalid ref name: {}",
|
"invalid ref name: {}",
|
||||||
|
|||||||
@ -19,6 +19,8 @@ use std::path::PathBuf;
|
|||||||
use std::process::Stdio;
|
use std::process::Stdio;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
const PRE_PACK_LIMIT: usize = 1_048_576;
|
||||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt};
|
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt};
|
||||||
use tokio::process::ChildStdin;
|
use tokio::process::ChildStdin;
|
||||||
use tokio::sync::mpsc::Sender;
|
use tokio::sync::mpsc::Sender;
|
||||||
@ -357,6 +359,23 @@ impl russh::server::Handler for SSHandle {
|
|||||||
if matches!(self.service, Some(GitService::ReceivePack)) {
|
if matches!(self.service, Some(GitService::ReceivePack)) {
|
||||||
if !self.branch.contains_key(&channel) {
|
if !self.branch.contains_key(&channel) {
|
||||||
let bf = self.buffer.entry(channel).or_default();
|
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);
|
bf.extend_from_slice(data);
|
||||||
|
|
||||||
if !bf.windows(4).any(|w| w == b"0000") {
|
if !bf.windows(4).any(|w| w == b"0000") {
|
||||||
@ -377,16 +396,15 @@ impl russh::server::Handler for SSHandle {
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
for r#ref in &refs {
|
for r#ref in &refs {
|
||||||
if branch_protect_roles
|
if let Some(msg) =
|
||||||
.iter()
|
check_branch_protection(&branch_protect_roles, r#ref)
|
||||||
.any(|x| r#ref.name.starts_with(&x.branch))
|
|
||||||
{
|
{
|
||||||
let msg =
|
let full_msg =
|
||||||
format!("remote: Branch '{}' is protected\r\n", r#ref.name);
|
format!("remote: {}\r\n", msg);
|
||||||
let _ = session.extended_data(
|
let _ = session.extended_data(
|
||||||
channel,
|
channel,
|
||||||
1,
|
1,
|
||||||
CryptoVec::from_slice(msg.as_bytes()),
|
CryptoVec::from_slice(full_msg.as_bytes()),
|
||||||
);
|
);
|
||||||
let _ = session.exit_status_request(channel, 1);
|
let _ = session.exit_status_request(channel, 1);
|
||||||
let _ = session.eof(channel);
|
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>(
|
async fn forward<'a, R, Fut, Fwd>(
|
||||||
session_handle: &'a Handle,
|
session_handle: &'a Handle,
|
||||||
chan_id: ChannelId,
|
chan_id: ChannelId,
|
||||||
|
|||||||
@ -28,9 +28,10 @@ impl RoomService {
|
|||||||
.await
|
.await
|
||||||
.ok()
|
.ok()
|
||||||
.flatten()
|
.flatten()
|
||||||
.map(|m| m.name);
|
.map(|m| m.name)
|
||||||
|
.unwrap_or_else(|| format!("AI {}", model.model));
|
||||||
let mut resp = super::RoomAiResponse::from(model);
|
let mut resp = super::RoomAiResponse::from(model);
|
||||||
resp.model_name = model_name;
|
resp.model_name = Some(model_name);
|
||||||
responses.push(resp);
|
responses.push(resp);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -113,9 +114,22 @@ impl RoomService {
|
|||||||
.await
|
.await
|
||||||
.ok()
|
.ok()
|
||||||
.flatten()
|
.flatten()
|
||||||
.map(|m| m.name);
|
.map(|m| m.name)
|
||||||
|
.unwrap_or_else(|| format!("AI {}", saved.model));
|
||||||
let mut resp = super::RoomAiResponse::from(saved);
|
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)
|
Ok(resp)
|
||||||
}
|
}
|
||||||
@ -132,6 +146,19 @@ impl RoomService {
|
|||||||
room_ai::Entity::delete_by_id((room_id, model_id))
|
room_ai::Entity::delete_by_id((room_id, model_id))
|
||||||
.exec(&self.db)
|
.exec(&self.db)
|
||||||
.await?;
|
.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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
use db::database::AppDatabase;
|
use db::database::AppDatabase;
|
||||||
|
use models::repos::repo;
|
||||||
use models::rooms::room_ai;
|
use models::rooms::room_ai;
|
||||||
use models::rooms::room_message::{Column as RmCol, Entity as RoomMessage};
|
use models::rooms::room_message::{Column as RmCol, Entity as RoomMessage};
|
||||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect};
|
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use super::patterns::mention_bracket_re;
|
||||||
use crate::error::RoomError;
|
use crate::error::RoomError;
|
||||||
|
|
||||||
pub async fn get_room_history(
|
pub async fn get_room_history(
|
||||||
@ -57,6 +59,34 @@ pub async fn get_room_ai_config(
|
|||||||
Ok(ai_config)
|
Ok(ai_config)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn extract_mention_context(_content: &str) -> Vec<agent::chat::Mention> {
|
pub async fn extract_mention_context(
|
||||||
Vec::new()
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -374,7 +374,7 @@ impl RoomService {
|
|||||||
.collect();
|
.collect();
|
||||||
let user_names = self.get_user_names(&user_ids).await;
|
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 {
|
let request = AiRequest {
|
||||||
db: self.db.clone(),
|
db: self.db.clone(),
|
||||||
|
|||||||
@ -35,6 +35,10 @@ pub enum RoomEventType {
|
|||||||
ReactionRemoved,
|
ReactionRemoved,
|
||||||
TypingStart,
|
TypingStart,
|
||||||
TypingStop,
|
TypingStop,
|
||||||
|
RoomAiUpdated,
|
||||||
|
RepoCreated,
|
||||||
|
RepoUpdated,
|
||||||
|
RepoDeleted,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RoomEventType {
|
impl RoomEventType {
|
||||||
@ -59,6 +63,10 @@ impl RoomEventType {
|
|||||||
RoomEventType::ReactionRemoved => "reaction_removed",
|
RoomEventType::ReactionRemoved => "reaction_removed",
|
||||||
RoomEventType::TypingStart => "typing_start",
|
RoomEventType::TypingStart => "typing_start",
|
||||||
RoomEventType::TypingStop => "typing_stop",
|
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),
|
"reaction_removed" => Some(RoomEventType::ReactionRemoved),
|
||||||
"typing_start" => Some(RoomEventType::TypingStart),
|
"typing_start" => Some(RoomEventType::TypingStart),
|
||||||
"typing_stop" => Some(RoomEventType::TypingStop),
|
"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,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,6 +17,7 @@ name = "service"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
config = { workspace = true }
|
config = { workspace = true }
|
||||||
agent = { workspace = true }
|
agent = { workspace = true }
|
||||||
|
fctool = { workspace = true }
|
||||||
db = { workspace = true }
|
db = { workspace = true }
|
||||||
models = { workspace = true }
|
models = { workspace = true }
|
||||||
email = { workspace = true }
|
email = { workspace = true }
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
#![allow(dead_code)]
|
||||||
//! Synchronizes AI model metadata from the upstream AI endpoint
|
//! Synchronizes AI model metadata from the upstream AI endpoint
|
||||||
//! (`GET /v1/models`) into the local database.
|
//! (`GET /v1/models`) into the local database.
|
||||||
//!
|
//!
|
||||||
@ -495,10 +496,9 @@ async fn sync_models_from_upstream(
|
|||||||
pricing_created += 1;
|
pricing_created += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
capabilities_created +=
|
capabilities_created += upsert_capabilities(db, version_record.id, &model)
|
||||||
upsert_capabilities(db, version_record.id, &model)
|
.await
|
||||||
.await
|
.unwrap_or(0);
|
||||||
.unwrap_or(0);
|
|
||||||
|
|
||||||
if upsert_parameter_profile(db, version_record.id, &model)
|
if upsert_parameter_profile(db, version_record.id, &model)
|
||||||
.await
|
.await
|
||||||
@ -526,7 +526,12 @@ async fn list_upstream_models(
|
|||||||
base_url: &str,
|
base_url: &str,
|
||||||
api_key: &str,
|
api_key: &str,
|
||||||
) -> Result<Vec<UpstreamModel>, AppError> {
|
) -> 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
|
let resp = client
|
||||||
.get(&url)
|
.get(&url)
|
||||||
.header("Authorization", format!("Bearer {}", api_key))
|
.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
|
let api_key = config
|
||||||
.ai_api_key()
|
.ai_api_key()
|
||||||
.map_err(|e| AppError::InternalServerError(format!("AI API key not configured: {}", e)))?;
|
.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
|
/// Perform a single sync pass. Errors are logged and silently swallowed
|
||||||
/// so the periodic task never stops.
|
/// so the periodic task never stops.
|
||||||
async fn sync_once(
|
async fn sync_once(db: &AppDatabase, ai_api_key: Option<String>, ai_base_url: Option<String>) {
|
||||||
db: &AppDatabase,
|
let (http_client, base_url, api_key) =
|
||||||
ai_api_key: Option<String>,
|
match build_ai_client_from_parts(ai_api_key, ai_base_url) {
|
||||||
ai_base_url: Option<String>,
|
Ok(c) => c,
|
||||||
) {
|
Err(msg) => {
|
||||||
let (http_client, base_url, api_key) = match build_ai_client_from_parts(ai_api_key, ai_base_url) {
|
tracing::warn!(error = %msg, "Model sync: AI client config error");
|
||||||
Ok(c) => c,
|
return;
|
||||||
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 {
|
let upstream_models = match list_upstream_models(&http_client, &base_url, &api_key).await {
|
||||||
Ok(models) => models,
|
Ok(models) => models,
|
||||||
|
|||||||
@ -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)
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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(¤t_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)
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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(¬es_file) {
|
|
||||||
let mut notes_xml = String::new();
|
|
||||||
if notes_file.read_to_string(&mut notes_xml).is_ok() {
|
|
||||||
Some(extract_text_from_pptx_xml(¬es_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)
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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)
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -803,9 +803,20 @@ impl AppService {
|
|||||||
commits.into_iter().map(CommitMetaResponse::from).collect();
|
commits.into_iter().map(CommitMetaResponse::from).collect();
|
||||||
|
|
||||||
// Get total count for pagination metadata.
|
// 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!(
|
let total_cache_key = format!(
|
||||||
"git:commit:count:{}:{}:{:?}",
|
"git:commit:count:{}:{}:{:?}:{:?}",
|
||||||
namespace, repo_name, rev_for_count,
|
namespace, repo_name, from, to,
|
||||||
);
|
);
|
||||||
let total: usize = if let Ok(mut conn) = self.cache.conn().await {
|
let total: usize = if let Ok(mut conn) = self.cache.conn().await {
|
||||||
if let Ok(cached) = conn.get::<_, String>(total_cache_key.clone()).await {
|
if let Ok(cached) = conn.get::<_, String>(total_cache_key.clone()).await {
|
||||||
@ -1326,7 +1337,7 @@ impl AppService {
|
|||||||
} else if reverse {
|
} else if reverse {
|
||||||
CommitSort(CommitSort::TIME.0 | CommitSort::REVERSE.0)
|
CommitSort(CommitSort::TIME.0 | CommitSort::REVERSE.0)
|
||||||
} else {
|
} else {
|
||||||
CommitSort(CommitSort::TOPOLOGICAL.0 | CommitSort::TIME.0)
|
CommitSort(CommitSort::TIME.0)
|
||||||
};
|
};
|
||||||
|
|
||||||
let commits = git_spawn!(repo, domain -> {
|
let commits = git_spawn!(repo, domain -> {
|
||||||
|
|||||||
@ -39,7 +39,7 @@ impl AppService {
|
|||||||
let domain = git::GitDomain::open_workdir(&path).map_err(AppError::from)?;
|
let domain = git::GitDomain::open_workdir(&path).map_err(AppError::from)?;
|
||||||
Ok(GitInitResponse {
|
Ok(GitInitResponse {
|
||||||
path: domain.repo().path().to_string_lossy().to_string(),
|
path: domain.repo().path().to_string_lossy().to_string(),
|
||||||
is_bare: true,
|
is_bare: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,45 @@ use redis::AsyncCommands;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use session::Session;
|
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)]
|
#[derive(Debug, Clone, Deserialize, utoipa::IntoParams)]
|
||||||
pub struct RefListQuery {
|
pub struct RefListQuery {
|
||||||
pub pattern: Option<String>,
|
pub pattern: Option<String>,
|
||||||
@ -201,12 +240,7 @@ impl AppService {
|
|||||||
.map_err(|e| AppError::InternalServerError(format!("Task join error: {}", e)))?
|
.map_err(|e| AppError::InternalServerError(format!("Task join error: {}", e)))?
|
||||||
.map_err(AppError::from)?;
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
if let Ok(mut conn) = self.cache.conn().await {
|
invalidate_ref_cache(&self.cache, &namespace, &repo_name).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)");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(RefUpdateResponse {
|
Ok(RefUpdateResponse {
|
||||||
name: result.name,
|
name: result.name,
|
||||||
@ -234,12 +268,7 @@ impl AppService {
|
|||||||
.map_err(|e| AppError::InternalServerError(format!("Task join error: {}", e)))?
|
.map_err(|e| AppError::InternalServerError(format!("Task join error: {}", e)))?
|
||||||
.map_err(AppError::from)?;
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
if let Ok(mut conn) = self.cache.conn().await {
|
invalidate_ref_cache(&self.cache, &namespace, &repo_name).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)");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(RefDeleteResponse {
|
Ok(RefDeleteResponse {
|
||||||
name,
|
name,
|
||||||
@ -269,12 +298,7 @@ impl AppService {
|
|||||||
.map_err(|e| AppError::InternalServerError(format!("Task join error: {}", e)))?
|
.map_err(|e| AppError::InternalServerError(format!("Task join error: {}", e)))?
|
||||||
.map_err(AppError::from)?;
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
if let Ok(mut conn) = self.cache.conn().await {
|
invalidate_ref_cache(&self.cache, &namespace, &repo_name).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)");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(RefInfoResponse::from(info))
|
Ok(RefInfoResponse::from(info))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -465,6 +465,16 @@ impl AppService {
|
|||||||
}
|
}
|
||||||
active.update(&txn).await?;
|
active.update(&txn).await?;
|
||||||
txn.commit().await?;
|
txn.commit().await?;
|
||||||
|
|
||||||
|
self.room.publish_room_event(
|
||||||
|
repo.project,
|
||||||
|
room::RoomEventType::RepoUpdated,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
).await;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -39,7 +39,7 @@ impl AppService {
|
|||||||
.one(&self.db)
|
.one(&self.db)
|
||||||
.await?;
|
.await?;
|
||||||
if existing.is_some() {
|
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 {
|
RepoStar::insert(repo_star::ActiveModel {
|
||||||
id: Default::default(),
|
id: Default::default(),
|
||||||
@ -97,7 +97,7 @@ impl AppService {
|
|||||||
.exec(&self.db)
|
.exec(&self.db)
|
||||||
.await?;
|
.await?;
|
||||||
if deleted.rows_affected == 0 {
|
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 {
|
let project_id = match repo_model::Entity::find_by_id(repo.id).one(&self.db).await {
|
||||||
Ok(Some(r)) => r.project,
|
Ok(Some(r)) => r.project,
|
||||||
|
|||||||
@ -51,9 +51,7 @@ impl AppService {
|
|||||||
.one(&self.db)
|
.one(&self.db)
|
||||||
.await?;
|
.await?;
|
||||||
if existing.is_some() {
|
if existing.is_some() {
|
||||||
return Err(AppError::InternalServerError(
|
return Err(AppError::Conflict("already watching".to_string()));
|
||||||
"already watching".to_string(),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
RepoWatch::insert(repo_watch::ActiveModel {
|
RepoWatch::insert(repo_watch::ActiveModel {
|
||||||
id: Default::default(),
|
id: Default::default(),
|
||||||
@ -110,7 +108,7 @@ impl AppService {
|
|||||||
.exec(&self.db)
|
.exec(&self.db)
|
||||||
.await?;
|
.await?;
|
||||||
if deleted.rows_affected == 0 {
|
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 {
|
let project_id = match repo_model::Entity::find_by_id(repo.id).one(&self.db).await {
|
||||||
Ok(Some(r)) => r.project,
|
Ok(Some(r)) => r.project,
|
||||||
|
|||||||
@ -196,9 +196,9 @@ impl AppService {
|
|||||||
tracing::info!(url = %base_url, "AI chat enabled");
|
tracing::info!(url = %base_url, "AI chat enabled");
|
||||||
let ai_client_config = AiClientConfig::new(api_key).with_base_url(&base_url);
|
let ai_client_config = AiClientConfig::new(api_key).with_base_url(&base_url);
|
||||||
let mut registry = ToolRegistry::new();
|
let mut registry = ToolRegistry::new();
|
||||||
git_tools::register_all(&mut registry);
|
fctool::git_tools::register_all(&mut registry);
|
||||||
file_tools::register_all(&mut registry);
|
fctool::file_tools::register_all(&mut registry);
|
||||||
project_tools::register_all(&mut registry);
|
fctool::project_tools::register_all(&mut registry);
|
||||||
let mut chat_svc = ChatService::new()
|
let mut chat_svc = ChatService::new()
|
||||||
.with_ai_client_config(ai_client_config)
|
.with_ai_client_config(ai_client_config)
|
||||||
.with_tool_registry(registry);
|
.with_tool_registry(registry);
|
||||||
@ -324,12 +324,9 @@ impl AppService {
|
|||||||
pub mod agent;
|
pub mod agent;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod file_tools;
|
|
||||||
pub mod git;
|
pub mod git;
|
||||||
pub mod git_tools;
|
|
||||||
pub mod issue;
|
pub mod issue;
|
||||||
pub mod project;
|
pub mod project;
|
||||||
pub mod project_tools;
|
|
||||||
pub mod pull_request;
|
pub mod pull_request;
|
||||||
pub mod search;
|
pub mod search;
|
||||||
pub mod skill;
|
pub mod skill;
|
||||||
|
|||||||
@ -337,6 +337,15 @@ impl AppService {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
self.room.publish_room_event(
|
||||||
|
project.id,
|
||||||
|
room::RoomEventType::RepoCreated,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
).await;
|
||||||
|
|
||||||
Ok(ProjectRepoCreateResponse {
|
Ok(ProjectRepoCreateResponse {
|
||||||
uid: repo.id,
|
uid: repo.id,
|
||||||
repo_name: repo.repo_name,
|
repo_name: repo.repo_name,
|
||||||
|
|||||||
@ -171,7 +171,7 @@ export const DiscordMemberList = memo(function DiscordMemberList({
|
|||||||
icon={<Bot className="h-3 w-3" />}
|
icon={<Bot className="h-3 w-3" />}
|
||||||
>
|
>
|
||||||
{aiConfigs.map((ai) => {
|
{aiConfigs.map((ai) => {
|
||||||
const label = ai.modelName ?? ai.model;
|
const label = ai.modelName || 'Unknown AI';
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={ai.model}
|
key={ai.model}
|
||||||
|
|||||||
@ -76,7 +76,7 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
|
|||||||
{roomName, onSend, replyingTo, onCancelReply},
|
{roomName, onSend, replyingTo, onCancelReply},
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const {members, activeRoomId, roomAiConfigs, wsClient} = useRoom();
|
const {members, activeRoomId, roomAiConfigs, projectRepos, wsClient} = useRoom();
|
||||||
|
|
||||||
// Ref passed to the inner IMEditor
|
// Ref passed to the inner IMEditor
|
||||||
const innerEditorRef = useRef<IMEditorHandle | null>(null);
|
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 }[],
|
channels: [] as { id: string; label: string; type: 'channel'; avatar?: string }[],
|
||||||
ai: roomAiConfigs.map((cfg) => ({
|
ai: roomAiConfigs.map((cfg) => ({
|
||||||
id: cfg.model,
|
id: cfg.model,
|
||||||
label: cfg.modelName ?? cfg.model,
|
label: cfg.modelName || 'Unknown AI',
|
||||||
type: 'ai' as const,
|
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,
|
commands: SLASH_COMMANDS,
|
||||||
specialMentions: SPECIAL_MENTIONS,
|
specialMentions: SPECIAL_MENTIONS,
|
||||||
}), [members, roomAiConfigs]);
|
}), [members, roomAiConfigs, projectRepos]);
|
||||||
|
|
||||||
// File upload handler — POST to /rooms/{room_id}/upload
|
// File upload handler — POST to /rooms/{room_id}/upload
|
||||||
const handleUploadFile = async (file: File): Promise<{ id: string; url: string }> => {
|
const handleUploadFile = async (file: File): Promise<{ id: string; url: string }> => {
|
||||||
|
|||||||
@ -26,6 +26,7 @@ export interface IMEditorProps {
|
|||||||
users: MentionItem[];
|
users: MentionItem[];
|
||||||
channels: MentionItem[];
|
channels: MentionItem[];
|
||||||
ai: MentionItem[];
|
ai: MentionItem[];
|
||||||
|
repos: MentionItem[];
|
||||||
commands: MentionItem[];
|
commands: MentionItem[];
|
||||||
specialMentions?: 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 === '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 === '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 === '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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -173,12 +175,13 @@ function serializeAstNode(node: EditorNode): string {
|
|||||||
|
|
||||||
// ─── Mention Dropdown (sectioned by type) ────────────────────────────────────
|
// ─── 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> = {
|
const SECTION_LABELS: Record<string, string> = {
|
||||||
special_here: 'Notify',
|
special_here: 'Notify',
|
||||||
special_channel: 'Notify',
|
special_channel: 'Notify',
|
||||||
ai: 'AI',
|
ai: 'AI',
|
||||||
user: 'Members',
|
user: 'Members',
|
||||||
|
repo: 'Repositories',
|
||||||
channel: 'Channels',
|
channel: 'Channels',
|
||||||
command: 'Commands',
|
command: 'Commands',
|
||||||
};
|
};
|
||||||
@ -356,7 +359,8 @@ export const IMEditor = forwardRef<IMEditorHandle, IMEditorProps>(function IMEdi
|
|||||||
...(mentionItems.specialMentions ?? []),
|
...(mentionItems.specialMentions ?? []),
|
||||||
...mentionItems.ai,
|
...mentionItems.ai,
|
||||||
...mentionItems.users,
|
...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]);
|
const hashPool = useMemo(() => [...mentionItems.channels], [mentionItems.channels]);
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
* Core types for the IM editor (mentions, files, emojis).
|
* 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 {
|
export interface MentionItem {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@ -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',
|
dark: 'dark:bg-orange-900/30 dark:text-orange-300',
|
||||||
prefix: '@',
|
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) {
|
export function MentionBadge({ type, label, onClick, id, className }: MentionBadgeProps) {
|
||||||
|
|||||||
@ -195,6 +195,8 @@ export function RoomProvider({
|
|||||||
const [wsClient, setWsClient] = useState<RoomWsClient | null>(null);
|
const [wsClient, setWsClient] = useState<RoomWsClient | null>(null);
|
||||||
const wsClientRef = useRef<RoomWsClient | null>(null);
|
const wsClientRef = useRef<RoomWsClient | null>(null);
|
||||||
const activeRoomIdRef = useRef<string | null>(activeRoomId);
|
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 [wsStatus, setWsStatus] = useState<RoomWsStatus>('idle');
|
||||||
const [wsError, setWsError] = useState<string | null>(null);
|
const [wsError, setWsError] = useState<string | null>(null);
|
||||||
const [wsToken, setWsToken] = 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;
|
if (payload.room_id !== activeRoomIdRef.current) return;
|
||||||
setPins((prev) => prev.filter((p) => p.message !== payload.message_id));
|
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) => {
|
onUserPresence: (payload) => {
|
||||||
if (payload.room_id !== activeRoomIdRef.current) return;
|
if (payload.room_id !== activeRoomIdRef.current) return;
|
||||||
setPresence((prev) => ({ ...prev, [payload.user_id]: payload.status }));
|
setPresence((prev) => ({ ...prev, [payload.user_id]: payload.status }));
|
||||||
@ -1303,6 +1313,9 @@ export function RoomProvider({
|
|||||||
}
|
}
|
||||||
}, [activeRoomId]);
|
}, [activeRoomId]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchRoomAiConfigsRef.current = fetchRoomAiConfigs; }, [fetchRoomAiConfigs]);
|
||||||
|
useEffect(() => { fetchProjectReposRef.current = fetchProjectRepos; }, [fetchProjectRepos]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchProjectRepos();
|
fetchProjectRepos();
|
||||||
}, [fetchProjectRepos]);
|
}, [fetchProjectRepos]);
|
||||||
|
|||||||
@ -7,6 +7,7 @@ export type MentionType =
|
|||||||
| 'user'
|
| 'user'
|
||||||
| 'channel'
|
| 'channel'
|
||||||
| 'ai'
|
| 'ai'
|
||||||
|
| 'repo'
|
||||||
| 'command'
|
| 'command'
|
||||||
| 'special_here'
|
| 'special_here'
|
||||||
| 'special_channel';
|
| 'special_channel';
|
||||||
@ -15,6 +16,7 @@ export const MENTION_TYPES: MentionType[] = [
|
|||||||
'user',
|
'user',
|
||||||
'channel',
|
'channel',
|
||||||
'ai',
|
'ai',
|
||||||
|
'repo',
|
||||||
'command',
|
'command',
|
||||||
'special_here',
|
'special_here',
|
||||||
'special_channel',
|
'special_channel',
|
||||||
@ -84,6 +86,11 @@ export function serializeAiMention(aiId: string, aiName: string): string {
|
|||||||
return serializeMention('ai', aiId, aiName);
|
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. */
|
/** Build a mention token from a command mention. */
|
||||||
export function serializeCommandMention(commandId: string, commandName: string): string {
|
export function serializeCommandMention(commandId: string, commandName: string): string {
|
||||||
return serializeMention('command', commandId, commandName);
|
return serializeMention('command', commandId, commandName);
|
||||||
|
|||||||
@ -81,6 +81,8 @@ export interface RoomWsCallbacks {
|
|||||||
onUserPresence?: (payload: UserPresencePayload) => void;
|
onUserPresence?: (payload: UserPresencePayload) => void;
|
||||||
onTypingStart?: (payload: import('./ws-protocol').TypingStartPayload) => void;
|
onTypingStart?: (payload: import('./ws-protocol').TypingStartPayload) => void;
|
||||||
onTypingStop?: (payload: import('./ws-protocol').TypingStopPayload) => void;
|
onTypingStop?: (payload: import('./ws-protocol').TypingStopPayload) => void;
|
||||||
|
onRoomAiUpdated?: (payload: ProjectEventPayload) => void;
|
||||||
|
onRepoChanged?: (payload: ProjectEventPayload) => void;
|
||||||
onStatusChange?: (status: RoomWsStatus) => void;
|
onStatusChange?: (status: RoomWsStatus) => void;
|
||||||
onError?: (error: Error) => void;
|
onError?: (error: Error) => void;
|
||||||
/** Called each time the client sends a heartbeat ping */
|
/** 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
|
// Safety timeout: if not open within 10s, give up
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
if (this.status === 'connecting') {
|
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.ws?.close();
|
||||||
this.setStatus('error');
|
this.setStatus('error');
|
||||||
reject(new Error('Connection timeout'));
|
reject(new Error('Connection timeout'));
|
||||||
@ -246,6 +249,10 @@ export class RoomWsClient {
|
|||||||
req.reject(new Error(`WebSocket closed: ${ev.reason || 'unknown'}`));
|
req.reject(new Error(`WebSocket closed: ${ev.reason || 'unknown'}`));
|
||||||
}
|
}
|
||||||
this.pendingRequests.clear();
|
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) {
|
if (this.shouldReconnect) {
|
||||||
this.scheduleReconnect();
|
this.scheduleReconnect();
|
||||||
}
|
}
|
||||||
@ -1130,6 +1137,14 @@ export class RoomWsClient {
|
|||||||
room_id: event.room_id ?? '',
|
room_id: event.room_id ?? '',
|
||||||
} as import('./ws-protocol').MessageUnpinnedPayload);
|
} as import('./ws-protocol').MessageUnpinnedPayload);
|
||||||
break;
|
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:
|
default:
|
||||||
// Other project events (member_joined, room_created, etc.)
|
// Other project events (member_joined, room_created, etc.)
|
||||||
this.callbacks.onProjectEvent?.(event);
|
this.callbacks.onProjectEvent?.(event);
|
||||||
@ -1178,8 +1193,15 @@ export class RoomWsClient {
|
|||||||
const delay = Math.floor(jitter);
|
const delay = Math.floor(jitter);
|
||||||
this.reconnectAttempt++;
|
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 = setTimeout(() => {
|
||||||
this.reconnectTimer = null;
|
this.reconnectTimer = null;
|
||||||
|
if (forceNew) {
|
||||||
|
this.wsToken = null;
|
||||||
|
console.debug('[RoomWs] Clearing token after 3 reconnect failures, will fetch fresh');
|
||||||
|
}
|
||||||
this.connect().catch(() => {});
|
this.connect().catch(() => {});
|
||||||
}, delay);
|
}, delay);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user