Compare commits
6 Commits
00a5369fe1
...
a09f66b779
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a09f66b779 | ||
|
|
c4fb943e07 | ||
|
|
5579e6c58e | ||
|
|
821b0e998d | ||
|
|
0cccec33b2 | ||
|
|
9b9c12ffc8 |
@ -98,11 +98,7 @@ impl ChatService {
|
||||
frequency_penalty: Some(frequency_penalty as f32),
|
||||
presence_penalty: Some(presence_penalty as f32),
|
||||
stream: Some(false),
|
||||
reasoning_effort: Some(if think {
|
||||
ReasoningEffort::High
|
||||
} else {
|
||||
ReasoningEffort::None
|
||||
}),
|
||||
reasoning_effort: if think { Some(ReasoningEffort::High) } else { None },
|
||||
tools: if tools_enabled {
|
||||
Some(
|
||||
tools
|
||||
@ -232,11 +228,7 @@ impl ChatService {
|
||||
frequency_penalty: Some(frequency_penalty as f32),
|
||||
presence_penalty: Some(presence_penalty as f32),
|
||||
stream: Some(true),
|
||||
reasoning_effort: Some(if think {
|
||||
ReasoningEffort::High
|
||||
} else {
|
||||
ReasoningEffort::None
|
||||
}),
|
||||
reasoning_effort: if think { Some(ReasoningEffort::High) } else { None },
|
||||
tools: if tools_enabled {
|
||||
Some(
|
||||
tools
|
||||
|
||||
@ -452,6 +452,8 @@ use utoipa::OpenApi;
|
||||
crate::workspace::members::workspace_pending_invitations,
|
||||
crate::workspace::members::workspace_cancel_invitation,
|
||||
crate::workspace::members::workspace_accept_invitation,
|
||||
crate::workspace::members::workspace_my_invitations,
|
||||
crate::workspace::members::workspace_accept_invitation_by_slug,
|
||||
crate::workspace::settings::workspace_update,
|
||||
crate::workspace::settings::workspace_delete,
|
||||
),
|
||||
@ -635,6 +637,8 @@ use utoipa::OpenApi;
|
||||
service::workspace::members::WorkspaceInviteParams,
|
||||
service::workspace::members::WorkspaceInviteAcceptParams,
|
||||
service::workspace::members::PendingInvitationInfo,
|
||||
service::workspace::members::MyWorkspaceInvitation,
|
||||
service::workspace::members::WorkspaceAcceptBySlugParams,
|
||||
service::workspace::settings::WorkspaceUpdateParams,
|
||||
// Room
|
||||
room::RoomResponse,
|
||||
|
||||
@ -289,6 +289,15 @@ pub async fn ws_universal(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle JSON-level ping (application heartbeat).
|
||||
// Client sends {"type":"ping"} and we reply with {"type":"pong"}.
|
||||
if text.trim() == r#"{"type":"ping"}"# {
|
||||
if session.text(r#"{"type":"pong"}"#).await.is_err() { break; }
|
||||
last_activity = Instant::now();
|
||||
last_heartbeat = Instant::now();
|
||||
continue;
|
||||
}
|
||||
|
||||
match serde_json::from_str::<WsRequest>(&text) {
|
||||
Ok(request) => {
|
||||
let action_str = request.action.to_string();
|
||||
@ -388,7 +397,7 @@ async fn poll_push_streams(
|
||||
if let Some(reactions) = event.reactions.clone() {
|
||||
return Some(WsPushEvent::ReactionUpdated {
|
||||
room_id: event.room_id,
|
||||
message_id: event.id,
|
||||
message_id: event.message_id.unwrap_or(event.id),
|
||||
reactions,
|
||||
});
|
||||
}
|
||||
|
||||
@ -2,8 +2,8 @@ use crate::{ApiResponse, error::ApiError};
|
||||
use actix_web::{HttpResponse, Result, web};
|
||||
use service::AppService;
|
||||
use service::workspace::members::{
|
||||
PendingInvitationInfo, WorkspaceInviteAcceptParams, WorkspaceInviteParams,
|
||||
WorkspaceMembersResponse,
|
||||
MyWorkspaceInvitation, PendingInvitationInfo, WorkspaceAcceptBySlugParams,
|
||||
WorkspaceInviteAcceptParams, WorkspaceInviteParams, WorkspaceMembersResponse,
|
||||
};
|
||||
use session::Session;
|
||||
use uuid::Uuid;
|
||||
@ -203,3 +203,47 @@ pub async fn workspace_accept_invitation(
|
||||
let resp = service.workspace_info(&session, ws.slug).await?;
|
||||
Ok(ApiResponse::ok(resp).to_response())
|
||||
}
|
||||
|
||||
/// List all pending workspace invitations for the current user.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/workspaces/me/invitations",
|
||||
responses(
|
||||
(status = 200, description = "List my workspace invitations", body = ApiResponse<Vec<MyWorkspaceInvitation>>),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
tag = "Workspace"
|
||||
)]
|
||||
pub async fn workspace_my_invitations(
|
||||
service: web::Data<AppService>,
|
||||
session: Session,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let resp = service.workspace_my_pending_invitations(&session).await?;
|
||||
Ok(ApiResponse::ok(resp).to_response())
|
||||
}
|
||||
|
||||
/// Accept a workspace invitation by slug.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/workspaces/invitations/accept-by-slug",
|
||||
request_body = WorkspaceAcceptBySlugParams,
|
||||
responses(
|
||||
(status = 200, description = "Accept invitation", body = ApiResponse<service::workspace::info::WorkspaceInfoResponse>),
|
||||
(status = 400, description = "Invalid or expired token"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Invitation not found"),
|
||||
(status = 409, description = "Already accepted"),
|
||||
),
|
||||
tag = "Workspace"
|
||||
)]
|
||||
pub async fn workspace_accept_invitation_by_slug(
|
||||
service: web::Data<AppService>,
|
||||
session: Session,
|
||||
body: web::Json<WorkspaceAcceptBySlugParams>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let ws = service
|
||||
.workspace_accept_invitation_by_slug(&session, body.into_inner())
|
||||
.await?;
|
||||
let resp = service.workspace_info(&session, ws.slug).await?;
|
||||
Ok(ApiResponse::ok(resp).to_response())
|
||||
}
|
||||
|
||||
@ -48,6 +48,18 @@ pub fn init_workspace_routes(cfg: &mut web::ServiceConfig) {
|
||||
web::patch().to(members::workspace_update_member_role),
|
||||
)
|
||||
// Invitations
|
||||
.route(
|
||||
"/me/invitations",
|
||||
web::get().to(members::workspace_my_invitations),
|
||||
)
|
||||
.route(
|
||||
"/invitations/accept",
|
||||
web::post().to(members::workspace_accept_invitation),
|
||||
)
|
||||
.route(
|
||||
"/invitations/accept-by-slug",
|
||||
web::post().to(members::workspace_accept_invitation_by_slug),
|
||||
)
|
||||
.route(
|
||||
"/{slug}/invitations",
|
||||
web::post().to(members::workspace_invite_member),
|
||||
@ -59,10 +71,6 @@ pub fn init_workspace_routes(cfg: &mut web::ServiceConfig) {
|
||||
.route(
|
||||
"/{slug}/invitations/{user_id}",
|
||||
web::delete().to(members::workspace_cancel_invitation),
|
||||
)
|
||||
.route(
|
||||
"/invitations/accept",
|
||||
web::post().to(members::workspace_accept_invitation),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -176,7 +176,7 @@ impl MessageProducer {
|
||||
pub async fn publish_reaction_event(
|
||||
&self,
|
||||
room_id: uuid::Uuid,
|
||||
_message_id: uuid::Uuid,
|
||||
message_id: uuid::Uuid,
|
||||
reactions: Vec<ReactionGroup>,
|
||||
) {
|
||||
let Some(pubsub) = &self.pubsub else {
|
||||
@ -196,6 +196,7 @@ impl MessageProducer {
|
||||
seq: 0,
|
||||
display_name: None,
|
||||
reactions: Some(reactions),
|
||||
message_id: Some(message_id),
|
||||
};
|
||||
pubsub.publish_room_message(room_id, &event).await;
|
||||
}
|
||||
|
||||
@ -35,6 +35,9 @@ pub struct RoomMessageEvent {
|
||||
/// Present when this event carries reaction updates for the message.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reactions: Option<Vec<ReactionGroup>>,
|
||||
/// Target message ID for reaction update events.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub message_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@ -61,6 +64,7 @@ impl From<RoomMessageEnvelope> for RoomMessageEvent {
|
||||
seq: e.seq,
|
||||
display_name: None,
|
||||
reactions: None,
|
||||
message_id: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1065,6 +1065,7 @@ impl RoomService {
|
||||
display_name: Some(ai_display_name.clone()),
|
||||
in_reply_to: None,
|
||||
reactions: None,
|
||||
message_id: None,
|
||||
};
|
||||
room_manager.broadcast(room_id_inner, msg_event).await;
|
||||
room_manager.metrics.messages_sent.increment(1);
|
||||
@ -1207,6 +1208,7 @@ impl RoomService {
|
||||
display_name: model_display_name,
|
||||
in_reply_to: None,
|
||||
reactions: None,
|
||||
message_id: None,
|
||||
};
|
||||
room_manager.broadcast(room_id, event).await;
|
||||
|
||||
|
||||
93
libs/service/git_tools/branch.rs
Normal file
93
libs/service/git_tools/branch.rs
Normal file
@ -0,0 +1,93 @@
|
||||
//! Git branch tools.
|
||||
|
||||
use super::ctx::GitToolCtx;
|
||||
use agent::ToolRegistry;
|
||||
|
||||
async fn git_branch_list_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 remote_only = p.get("remote_only").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
|
||||
let domain = ctx.open_repo(project_name, repo_name).await?;
|
||||
let branches = domain.branch_list(remote_only).map_err(|e| e.to_string())?;
|
||||
|
||||
let result: Vec<_> = branches.iter().map(|b| {
|
||||
let oid = b.oid.to_string();
|
||||
serde_json::json!({
|
||||
"name": b.name, "oid": oid.clone(), "short_oid": oid.get(..7).unwrap_or(&oid).to_string(),
|
||||
"is_head": b.is_head, "is_remote": b.is_remote, "is_current": b.is_current,
|
||||
"upstream": b.upstream.clone()
|
||||
})
|
||||
}).collect();
|
||||
|
||||
Ok(serde_json::to_value(result).map_err(|e| e.to_string())?)
|
||||
}
|
||||
|
||||
async fn git_branch_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 name = p.get("name").and_then(|v| v.as_str()).ok_or("missing name")?;
|
||||
|
||||
let domain = ctx.open_repo(project_name, repo_name).await?;
|
||||
let info = domain.branch_get(name).map_err(|e| e.to_string())?;
|
||||
|
||||
let ahead_behind = if let Some(ref upstream) = info.upstream {
|
||||
let (ahead, behind) = domain.branch_ahead_behind(name, upstream).unwrap_or((0, 0));
|
||||
Some(serde_json::json!({ "ahead": ahead, "behind": behind }))
|
||||
} else { None };
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"branch": { "name": info.name, "oid": info.oid.to_string(), "is_head": info.is_head,
|
||||
"is_remote": info.is_remote, "is_current": info.is_current, "upstream": info.upstream },
|
||||
"ahead_behind": ahead_behind
|
||||
}))
|
||||
}
|
||||
|
||||
async fn git_branches_merged_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 branch = p.get("branch").and_then(|v| v.as_str()).ok_or("missing branch")?;
|
||||
let into = p.get("into").and_then(|v| v.as_str()).map(|s| s.to_string()).unwrap_or_else(|| "main".to_string());
|
||||
|
||||
let domain = ctx.open_repo(project_name, repo_name).await?;
|
||||
let is_merged = domain.branch_is_merged(branch, &into).map_err(|e| e.to_string())?;
|
||||
let merge_base = domain.merge_base(&git::commit::types::CommitOid::new(branch), &git::commit::types::CommitOid::new(&into))
|
||||
.map(|oid| oid.to_string()).ok();
|
||||
|
||||
Ok(serde_json::json!({ "branch": branch, "into": into, "is_merged": is_merged, "merge_base": merge_base }))
|
||||
}
|
||||
|
||||
async fn git_branch_diff_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 local = p.get("local").and_then(|v| v.as_str()).ok_or("missing local")?;
|
||||
let remote = p.get("remote").and_then(|v| v.as_str()).unwrap_or(local).to_string();
|
||||
|
||||
let domain = ctx.open_repo(project_name, repo_name).await?;
|
||||
let diff = domain.branch_diff(local, &remote).map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(serde_json::json!({ "ahead": diff.ahead, "behind": diff.behind, "diverged": diff.diverged }))
|
||||
}
|
||||
|
||||
macro_rules! register_fn {
|
||||
($registry:expr, $name:expr, $exec:expr) => {
|
||||
let handler_fn = move |ctx: agent::ToolContext, args: serde_json::Value| async move {
|
||||
let gctx = super::ctx::GitToolCtx::new(ctx);
|
||||
$exec(gctx, args)
|
||||
.await
|
||||
.map_err(agent::ToolError::ExecutionError)
|
||||
};
|
||||
$registry.register_fn($name, handler_fn);
|
||||
};
|
||||
}
|
||||
|
||||
pub fn register_git_tools(registry: &mut ToolRegistry) {
|
||||
register_fn!(registry, "git_branch_list", git_branch_list_exec);
|
||||
register_fn!(registry, "git_branch_info", git_branch_info_exec);
|
||||
register_fn!(registry, "git_branches_merged", git_branches_merged_exec);
|
||||
register_fn!(registry, "git_branch_diff", git_branch_diff_exec);
|
||||
}
|
||||
230
libs/service/git_tools/commit.rs
Normal file
230
libs/service/git_tools/commit.rs
Normal file
@ -0,0 +1,230 @@
|
||||
//! Git commit-related tools.
|
||||
|
||||
use super::ctx::GitToolCtx;
|
||||
use agent::ToolRegistry;
|
||||
use chrono::TimeZone;
|
||||
|
||||
// --- Execution functions for each tool ---
|
||||
|
||||
async fn git_log_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 rev = p.get("rev").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
let limit = p.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as usize;
|
||||
let skip = p.get("skip").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
|
||||
|
||||
let domain = ctx.open_repo(project_name, repo_name).await?;
|
||||
let commits = domain.commit_log(rev.as_deref(), skip, limit)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Flatten to simple JSON
|
||||
let result: Vec<_> = commits.iter().map(|c| {
|
||||
use chrono::TimeZone;
|
||||
let ts = c.author.time_secs + (c.author.offset_minutes as i64 * 60);
|
||||
let time_str = chrono::Utc.timestamp_opt(ts, 0).single()
|
||||
.map(|dt| dt.to_rfc3339()).unwrap_or_else(|| format!("{}", c.author.time_secs));
|
||||
|
||||
let oid = c.oid.to_string();
|
||||
let short_oid = oid.get(..7).unwrap_or(&oid).to_string();
|
||||
|
||||
serde_json::json!({
|
||||
"oid": oid,
|
||||
"short_oid": short_oid,
|
||||
"message": c.message,
|
||||
"summary": c.summary,
|
||||
"author_name": c.author.name,
|
||||
"author_email": c.author.email,
|
||||
"author_time": time_str,
|
||||
"committer_name": c.committer.name,
|
||||
"committer_email": c.committer.email,
|
||||
"parent_oids": c.parent_ids.iter().map(|p| p.to_string()).collect::<Vec<_>>(),
|
||||
"tree_oid": c.tree_id.to_string()
|
||||
})
|
||||
}).collect();
|
||||
|
||||
Ok(serde_json::to_value(result).map_err(|e| e.to_string())?)
|
||||
}
|
||||
|
||||
async fn git_show_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde_json::Value, String> {
|
||||
let p: serde_json::Map<String, serde_json::Value> = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let project_name = p.get("project_name").and_then(|v| v.as_str()).ok_or("missing project_name")?;
|
||||
let repo_name = p.get("repo_name").and_then(|v| v.as_str()).ok_or("missing repo_name")?;
|
||||
let rev = p.get("rev").and_then(|v| v.as_str()).ok_or("missing rev")?;
|
||||
|
||||
let domain = ctx.open_repo(project_name, repo_name).await?;
|
||||
let meta = if rev.len() >= 40 {
|
||||
domain.commit_get(&git::commit::types::CommitOid::new(rev)).map_err(|e| e.to_string())?
|
||||
} else {
|
||||
domain.commit_get_prefix(rev).map_err(|e| e.to_string())?
|
||||
};
|
||||
|
||||
let refs = domain.commit_refs(&meta.oid).map_err(|e| e.to_string())?;
|
||||
|
||||
use chrono::TimeZone;
|
||||
let ts = meta.author.time_secs + (meta.author.offset_minutes as i64 * 60);
|
||||
let author_time = chrono::Utc.timestamp_opt(ts, 0).single()
|
||||
.map(|dt| dt.to_rfc3339()).unwrap_or_else(|| format!("{}", meta.author.time_secs));
|
||||
|
||||
let oid = meta.oid.to_string();
|
||||
let short_oid = oid.get(..7).unwrap_or(&oid).to_string();
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"commit": {
|
||||
"oid": oid, "short_oid": short_oid, "message": meta.message, "summary": meta.summary,
|
||||
"author_name": meta.author.name, "author_email": meta.author.email, "author_time": author_time,
|
||||
"committer_name": meta.committer.name, "committer_email": meta.committer.email,
|
||||
"parent_oids": meta.parent_ids.iter().map(|p| p.to_string()).collect::<Vec<_>>(),
|
||||
"tree_oid": meta.tree_id.to_string()
|
||||
},
|
||||
"refs": refs.into_iter().map(|r| serde_json::json!({ "name": r.name, "is_tag": r.is_tag })).collect::<Vec<_>>()
|
||||
}))
|
||||
}
|
||||
|
||||
async fn git_search_commits_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 query = p.get("query").and_then(|v| v.as_str()).ok_or("missing query")?;
|
||||
let limit = p.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as usize;
|
||||
|
||||
let domain = ctx.open_repo(project_name, repo_name).await?;
|
||||
let commits = domain.commit_log(Some("HEAD"), 0, 100).map_err(|e| e.to_string())?;
|
||||
let q = query.to_lowercase();
|
||||
|
||||
let result: Vec<_> = commits.iter()
|
||||
.filter(|c| c.message.to_lowercase().contains(&q))
|
||||
.take(limit)
|
||||
.map(|c| flatten_commit(c))
|
||||
.collect();
|
||||
|
||||
Ok(serde_json::to_value(result).map_err(|e| e.to_string())?)
|
||||
}
|
||||
|
||||
fn flatten_commit(c: &git::commit::types::CommitMeta) -> serde_json::Value {
|
||||
use chrono::TimeZone;
|
||||
let ts = c.author.time_secs + (c.author.offset_minutes as i64 * 60);
|
||||
let author_time = chrono::Utc.timestamp_opt(ts, 0).single()
|
||||
.map(|dt| dt.to_rfc3339()).unwrap_or_else(|| format!("{}", c.author.time_secs));
|
||||
let oid = c.oid.to_string();
|
||||
serde_json::json!({
|
||||
"oid": oid.clone(),
|
||||
"short_oid": oid.get(..7).unwrap_or(&oid).to_string(),
|
||||
"message": c.message, "summary": c.summary,
|
||||
"author_name": c.author.name, "author_email": c.author.email, "author_time": author_time,
|
||||
"committer_name": c.committer.name, "committer_email": c.committer.email,
|
||||
"parent_oids": c.parent_ids.iter().map(|p| p.to_string()).collect::<Vec<_>>(),
|
||||
"tree_oid": c.tree_id.to_string()
|
||||
})
|
||||
}
|
||||
|
||||
async fn git_commit_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 rev = p.get("rev").and_then(|v| v.as_str()).ok_or("missing rev")?;
|
||||
|
||||
let domain = ctx.open_repo(project_name, repo_name).await?;
|
||||
let meta = if rev.len() >= 40 {
|
||||
domain.commit_get(&git::commit::types::CommitOid::new(rev)).map_err(|e| e.to_string())?
|
||||
} else {
|
||||
domain.commit_get_prefix(rev).map_err(|e| e.to_string())?
|
||||
};
|
||||
|
||||
Ok(flatten_commit(&meta))
|
||||
}
|
||||
|
||||
async fn git_graph_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 rev = p.get("rev").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
let limit = p.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as usize;
|
||||
|
||||
let domain = ctx.open_repo(project_name, repo_name).await?;
|
||||
let commits = domain.commit_log(rev.as_deref(), 0, limit).map_err(|e| e.to_string())?;
|
||||
|
||||
let mut col_map: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
|
||||
let lines: Vec<_> = commits.iter().map(|m| {
|
||||
let lane_index = *col_map.get(m.oid.as_str()).unwrap_or(&0);
|
||||
let oid = m.oid.to_string();
|
||||
let refs = match domain.commit_refs(&m.oid) {
|
||||
Ok(refs) => refs.iter().map(|r| {
|
||||
if r.is_tag { format!("tag: {}", r.name.trim_start_matches("refs/tags/")) }
|
||||
else if r.is_remote { r.name.trim_start_matches("refs/remotes/").to_string() }
|
||||
else { r.name.trim_start_matches("refs/heads/").to_string() }
|
||||
}).collect::<Vec<_>>().join(", "),
|
||||
Err(_) => String::new(),
|
||||
};
|
||||
for (i, p) in m.parent_ids.iter().enumerate() {
|
||||
if i == 0 { col_map.insert(p.to_string(), lane_index); } else { col_map.remove(p.as_str()); }
|
||||
}
|
||||
let ts = m.author.time_secs + (m.author.offset_minutes as i64 * 60);
|
||||
let author_time = chrono::Utc.timestamp_opt(ts, 0).single()
|
||||
.map(|dt| dt.to_rfc3339()).unwrap_or_else(|| format!("{}", m.author.time_secs));
|
||||
|
||||
serde_json::json!({
|
||||
"oid": oid.clone(),
|
||||
"short_oid": oid.get(..7).unwrap_or(&oid).to_string(),
|
||||
"refs": refs,
|
||||
"short_message": m.summary,
|
||||
"lane_index": lane_index,
|
||||
"author_name": m.author.name,
|
||||
"author_email": m.author.email,
|
||||
"author_time": author_time,
|
||||
"parent_oids": m.parent_ids.iter().map(|p| p.to_string()).collect::<Vec<_>>()
|
||||
})
|
||||
}).collect();
|
||||
|
||||
Ok(serde_json::to_value(lines).map_err(|e| e.to_string())?)
|
||||
}
|
||||
|
||||
async fn git_reflog_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 ref_name = p.get("ref_name").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
let limit = p.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
|
||||
|
||||
let domain = ctx.open_repo(project_name, repo_name).await?;
|
||||
let entries = domain.reflog_entries(ref_name.as_deref()).map_err(|e| e.to_string())?;
|
||||
|
||||
let result: Vec<_> = entries.iter()
|
||||
.take(limit)
|
||||
.map(|e| {
|
||||
let ts = e.time_secs;
|
||||
let time_str = chrono::Utc.timestamp_opt(ts, 0).single()
|
||||
.map(|dt| dt.to_rfc3339()).unwrap_or_else(|| format!("{}", ts));
|
||||
serde_json::json!({
|
||||
"oid_new": e.oid_new.to_string(), "oid_old": e.oid_old.to_string(),
|
||||
"committer_name": e.committer_name, "committer_email": e.committer_email,
|
||||
"time": time_str, "message": e.message, "ref_name": e.ref_name
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(serde_json::to_value(result).map_err(|e| e.to_string())?)
|
||||
}
|
||||
|
||||
// --- Registration macro ---
|
||||
|
||||
macro_rules! register_fn {
|
||||
($registry:expr, $name:expr, $exec:expr) => {
|
||||
let handler_fn = move |ctx: agent::ToolContext, args: serde_json::Value| async move {
|
||||
let gctx = super::ctx::GitToolCtx::new(ctx);
|
||||
$exec(gctx, args)
|
||||
.await
|
||||
.map_err(agent::ToolError::ExecutionError)
|
||||
};
|
||||
$registry.register_fn($name, handler_fn);
|
||||
};
|
||||
}
|
||||
|
||||
pub fn register_git_tools(registry: &mut ToolRegistry) {
|
||||
register_fn!(registry, "git_log", git_log_exec);
|
||||
register_fn!(registry, "git_show", git_show_exec);
|
||||
register_fn!(registry, "git_search_commits", git_search_commits_exec);
|
||||
register_fn!(registry, "git_commit_info", git_commit_info_exec);
|
||||
register_fn!(registry, "git_graph", git_graph_exec);
|
||||
register_fn!(registry, "git_reflog", git_reflog_exec);
|
||||
}
|
||||
53
libs/service/git_tools/ctx.rs
Normal file
53
libs/service/git_tools/ctx.rs
Normal file
@ -0,0 +1,53 @@
|
||||
//! Context wrapper for git tool handlers.
|
||||
//!
|
||||
//! Provides `GitToolCtx` which wraps `ToolContext` and adds git-domain operations.
|
||||
|
||||
use agent::ToolContext;
|
||||
use git::GitDomain;
|
||||
use models::projects::project;
|
||||
use models::repos::repo;
|
||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
|
||||
|
||||
/// Wrapper around `ToolContext` providing git-domain operations for tool handlers.
|
||||
#[derive(Clone)]
|
||||
pub struct GitToolCtx {
|
||||
pub ctx: ToolContext,
|
||||
}
|
||||
|
||||
impl GitToolCtx {
|
||||
pub fn new(ctx: ToolContext) -> Self {
|
||||
Self { ctx }
|
||||
}
|
||||
|
||||
/// Opens a git repository by project name and repo name.
|
||||
pub async fn open_repo(&self, project_name: &str, repo_name: &str) -> Result<GitDomain, String> {
|
||||
let db = self.ctx.db();
|
||||
resolve_project_and_repo(db, project_name, repo_name)
|
||||
.await
|
||||
.and_then(|(_, path)| GitDomain::open(&path).map_err(|e| e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Free helper to resolve project_id + storage_path from names. Used by registry.
|
||||
async fn resolve_project_and_repo(
|
||||
db: &db::database::AppDatabase,
|
||||
project_name: &str,
|
||||
repo_name: &str,
|
||||
) -> Result<(uuid::Uuid, String), String> {
|
||||
let project = project::Entity::find()
|
||||
.filter(project::Column::Name.eq(project_name))
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| format!("DB error looking up project '{}': {}", project_name, e))?
|
||||
.ok_or_else(|| format!("project '{}' not found", project_name))?;
|
||||
|
||||
let repo_model = repo::Entity::find()
|
||||
.filter(repo::Column::Project.eq(project.id))
|
||||
.filter(repo::Column::RepoName.eq(repo_name))
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| format!("DB error looking up repo '{}/{}': {}", project_name, repo_name, e))?
|
||||
.ok_or_else(|| format!("repo '{}/{}' not found", project_name, repo_name))?;
|
||||
|
||||
Ok((project.id, repo_model.storage_path))
|
||||
}
|
||||
147
libs/service/git_tools/diff.rs
Normal file
147
libs/service/git_tools/diff.rs
Normal file
@ -0,0 +1,147 @@
|
||||
//! Git diff and blame tools.
|
||||
|
||||
use super::ctx::GitToolCtx;
|
||||
use agent::ToolRegistry;
|
||||
|
||||
async fn git_diff_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 base = p.get("base").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
let head = p.get("head").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
let paths = p.get("paths").and_then(|v| v.as_array()).map(|a| {
|
||||
a.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect::<Vec<_>>()
|
||||
});
|
||||
|
||||
let domain = ctx.open_repo(project_name, repo_name).await?;
|
||||
|
||||
let resolve = |rev: &str| -> Result<git::commit::types::CommitOid, String> {
|
||||
if rev.len() >= 40 {
|
||||
Ok(git::commit::types::CommitOid::new(rev))
|
||||
} else {
|
||||
domain.commit_get_prefix(rev).map_err(|e| e.to_string()).map(|m| m.oid)
|
||||
}
|
||||
};
|
||||
|
||||
let base_oid = match &base {
|
||||
Some(b) => Some(resolve(b)?),
|
||||
None => None,
|
||||
};
|
||||
let head_oid = match &head {
|
||||
Some(h) => Some(resolve(h)?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let opts = paths.map(|ps| {
|
||||
let mut o = git::diff::types::DiffOptions::new();
|
||||
for p in ps { o = o.pathspec(&p); }
|
||||
Some(o)
|
||||
}).flatten();
|
||||
|
||||
let result = match (&base_oid, &head_oid) {
|
||||
(None, None) => {
|
||||
let head_meta = domain.commit_get_prefix("HEAD").map_err(|e| e.to_string())?;
|
||||
domain.diff_commit_to_workdir(&head_meta.oid, opts).map_err(|e| e.to_string())?
|
||||
}
|
||||
(Some(base), None) => {
|
||||
domain.diff_commit_to_workdir(base, opts).map_err(|e| e.to_string())?
|
||||
}
|
||||
(Some(base), Some(head_oid_val)) => {
|
||||
domain.diff_tree_to_tree(Some(base), Some(head_oid_val), opts).map_err(|e| e.to_string())?
|
||||
}
|
||||
(None, Some(_)) => {
|
||||
return Err("base revision required when head is specified".into());
|
||||
}
|
||||
};
|
||||
|
||||
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 })
|
||||
}).collect();
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"stats": { "files_changed": result.stats.files_changed, "insertions": result.stats.insertions, "deletions": result.stats.deletions },
|
||||
"files": files
|
||||
}))
|
||||
}
|
||||
|
||||
async fn git_diff_stats_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 base = p.get("base").and_then(|v| v.as_str()).ok_or("missing base")?;
|
||||
let head = p.get("head").and_then(|v| v.as_str()).ok_or("missing head")?;
|
||||
|
||||
let domain = ctx.open_repo(project_name, repo_name).await?;
|
||||
|
||||
let stats = if base.len() >= 40 || head.len() >= 40 {
|
||||
domain.diff_stats(&git::commit::types::CommitOid::new(base), &git::commit::types::CommitOid::new(head))
|
||||
.map_err(|e| e.to_string())?
|
||||
} else {
|
||||
let b = domain.commit_get_prefix(base).map_err(|e| e.to_string())?.oid;
|
||||
let h = domain.commit_get_prefix(head).map_err(|e| e.to_string())?.oid;
|
||||
domain.diff_stats(&b, &h).map_err(|e| e.to_string())?
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"files_changed": stats.files_changed,
|
||||
"insertions": stats.insertions,
|
||||
"deletions": stats.deletions
|
||||
}))
|
||||
}
|
||||
|
||||
async fn git_blame_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(|s| s.to_string()).unwrap_or_else(|| "HEAD".to_string());
|
||||
let from_line = p.get("from_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 oid = if rev.len() >= 40 {
|
||||
git::commit::types::CommitOid::new(&rev)
|
||||
} else {
|
||||
domain.commit_get_prefix(&rev).map_err(|e| e.to_string())?.oid
|
||||
};
|
||||
|
||||
use git::blame::ops::BlameOptions;
|
||||
let mut bopts = BlameOptions::new();
|
||||
if let Some(fl) = from_line { bopts = bopts.min_line(fl as usize); }
|
||||
if let Some(tl) = to_line { bopts = bopts.max_line(tl as usize); }
|
||||
|
||||
let hunks = domain.blame_file(&oid, path, Some(bopts)).map_err(|e| e.to_string())?;
|
||||
|
||||
let result: Vec<_> = hunks.iter().map(|h| {
|
||||
let oid = h.commit_oid.to_string();
|
||||
serde_json::json!({
|
||||
"commit_oid": oid.clone(),
|
||||
"short_oid": oid.get(..7).unwrap_or(&oid).to_string(),
|
||||
"final_start_line": h.final_start_line,
|
||||
"final_lines": h.final_lines,
|
||||
"orig_start_line": h.orig_start_line,
|
||||
"orig_path": h.orig_path,
|
||||
"boundary": h.boundary
|
||||
})
|
||||
}).collect();
|
||||
|
||||
Ok(serde_json::to_value(result).map_err(|e| e.to_string())?)
|
||||
}
|
||||
|
||||
macro_rules! register_fn {
|
||||
($registry:expr, $name:expr, $exec:expr) => {
|
||||
let handler_fn = move |ctx: agent::ToolContext, args: serde_json::Value| async move {
|
||||
let gctx = super::ctx::GitToolCtx::new(ctx);
|
||||
$exec(gctx, args)
|
||||
.await
|
||||
.map_err(agent::ToolError::ExecutionError)
|
||||
};
|
||||
$registry.register_fn($name, handler_fn);
|
||||
};
|
||||
}
|
||||
|
||||
pub fn register_git_tools(registry: &mut ToolRegistry) {
|
||||
register_fn!(registry, "git_diff", git_diff_exec);
|
||||
register_fn!(registry, "git_diff_stats", git_diff_stats_exec);
|
||||
register_fn!(registry, "git_blame", git_blame_exec);
|
||||
}
|
||||
21
libs/service/git_tools/mod.rs
Normal file
21
libs/service/git_tools/mod.rs
Normal file
@ -0,0 +1,21 @@
|
||||
//! Git tools for AI agent function calling.
|
||||
//!
|
||||
//! Each module defines async exec functions + a `register_git_tools()` call.
|
||||
//! All tools take `project_name` + `repo_name` as required params.
|
||||
|
||||
pub mod branch;
|
||||
pub mod commit;
|
||||
pub mod ctx;
|
||||
pub mod diff;
|
||||
pub mod tag;
|
||||
pub mod tree;
|
||||
pub mod types;
|
||||
|
||||
/// Batch-register all git tools into a ToolRegistry.
|
||||
pub fn register_all(registry: &mut agent::ToolRegistry) {
|
||||
commit::register_git_tools(registry);
|
||||
branch::register_git_tools(registry);
|
||||
diff::register_git_tools(registry);
|
||||
tree::register_git_tools(registry);
|
||||
tag::register_git_tools(registry);
|
||||
}
|
||||
73
libs/service/git_tools/tag.rs
Normal file
73
libs/service/git_tools/tag.rs
Normal file
@ -0,0 +1,73 @@
|
||||
//! Git tag tools.
|
||||
|
||||
use super::ctx::GitToolCtx;
|
||||
use agent::ToolRegistry;
|
||||
|
||||
async fn git_tag_list_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 pattern = p.get("pattern").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
|
||||
let domain = ctx.open_repo(project_name, repo_name).await?;
|
||||
let all_tags = domain.tag_list().map_err(|e| e.to_string())?;
|
||||
|
||||
let result: Vec<_> = match pattern {
|
||||
Some(ref pat) => {
|
||||
let pat_lower = pat.to_lowercase();
|
||||
let has_wildcard = pat.contains('*');
|
||||
all_tags.iter()
|
||||
.filter(|t| {
|
||||
let n = t.name.to_lowercase();
|
||||
if has_wildcard { n.contains(&pat_lower.replace('*', "")) }
|
||||
else { n.contains(&pat_lower) }
|
||||
})
|
||||
.map(|t| tag_to_json(t))
|
||||
.collect()
|
||||
}
|
||||
None => all_tags.into_iter().map(|t| tag_to_json(&t)).collect(),
|
||||
};
|
||||
|
||||
Ok(serde_json::to_value(result).map_err(|e| e.to_string())?)
|
||||
}
|
||||
|
||||
fn tag_to_json(t: &git::tags::types::TagInfo) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"name": t.name,
|
||||
"oid": t.oid.to_string(),
|
||||
"target": t.target.to_string(),
|
||||
"is_annotated": t.is_annotated,
|
||||
"message": t.message.clone(),
|
||||
"tagger_name": t.tagger.clone(),
|
||||
"tagger_email": t.tagger_email.clone()
|
||||
})
|
||||
}
|
||||
|
||||
async fn git_tag_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 name = p.get("name").and_then(|v| v.as_str()).ok_or("missing name")?;
|
||||
|
||||
let domain = ctx.open_repo(project_name, repo_name).await?;
|
||||
let info = domain.tag_get(name).map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(tag_to_json(&info))
|
||||
}
|
||||
|
||||
macro_rules! register_fn {
|
||||
($registry:expr, $name:expr, $exec:expr) => {
|
||||
let handler_fn = move |ctx: agent::ToolContext, args: serde_json::Value| async move {
|
||||
let gctx = super::ctx::GitToolCtx::new(ctx);
|
||||
$exec(gctx, args)
|
||||
.await
|
||||
.map_err(agent::ToolError::ExecutionError)
|
||||
};
|
||||
$registry.register_fn($name, handler_fn);
|
||||
};
|
||||
}
|
||||
|
||||
pub fn register_git_tools(registry: &mut ToolRegistry) {
|
||||
register_fn!(registry, "git_tag_list", git_tag_list_exec);
|
||||
register_fn!(registry, "git_tag_info", git_tag_info_exec);
|
||||
}
|
||||
126
libs/service/git_tools/tree.rs
Normal file
126
libs/service/git_tools/tree.rs
Normal file
@ -0,0 +1,126 @@
|
||||
//! Git tree and file tools.
|
||||
|
||||
use super::ctx::GitToolCtx;
|
||||
use agent::ToolRegistry;
|
||||
use base64::Engine;
|
||||
|
||||
async fn git_file_content_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde_json::Value, String> {
|
||||
let p: serde_json::Map<String, serde_json::Value> = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||
let project_name = p.get("project_name").and_then(|v| v.as_str()).ok_or("missing project_name")?;
|
||||
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(|s| s.to_string()).unwrap_or_else(|| "HEAD".to_string());
|
||||
|
||||
let domain = ctx.open_repo(project_name, repo_name).await?;
|
||||
let oid = if rev.len() >= 40 {
|
||||
git::commit::types::CommitOid::new(&rev)
|
||||
} else {
|
||||
domain.commit_get_prefix(&rev).map_err(|e| e.to_string())?.oid
|
||||
};
|
||||
|
||||
let 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 content = domain.blob_content(&entry.oid).map_err(|e| e.to_string())?;
|
||||
let (display_content, is_binary) = if blob_info.is_binary {
|
||||
(base64::engine::general_purpose::STANDARD.encode(&content.content), true)
|
||||
} else {
|
||||
(String::from_utf8_lossy(&content.content).to_string(), false)
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"path": path,
|
||||
"oid": entry.oid.to_string(),
|
||||
"size": blob_info.size,
|
||||
"content": display_content,
|
||||
"is_binary": is_binary
|
||||
}))
|
||||
}
|
||||
|
||||
async fn git_tree_ls_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 dir_path = p.get("path").and_then(|v| v.as_str()).map(|s| s.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 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 entries = match dir_path {
|
||||
Some(ref dp) => {
|
||||
let entry = domain.tree_entry_by_path(&commit_oid, dp).map_err(|e| e.to_string())?;
|
||||
domain.tree_list(&entry.oid).map_err(|e| e.to_string())?
|
||||
}
|
||||
None => domain.tree_list(&commit_oid).map_err(|e| e.to_string())?,
|
||||
};
|
||||
|
||||
let result: Vec<_> = entries.iter().map(|e| {
|
||||
serde_json::json!({
|
||||
"name": e.name,
|
||||
"oid": e.oid.to_string(),
|
||||
"kind": e.kind,
|
||||
"is_binary": e.is_binary
|
||||
})
|
||||
}).collect();
|
||||
|
||||
Ok(serde_json::to_value(result).map_err(|e| e.to_string())?)
|
||||
}
|
||||
|
||||
async fn git_file_history_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 limit = p.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as usize;
|
||||
|
||||
let domain = ctx.open_repo(project_name, repo_name).await?;
|
||||
let commits = domain.commit_log(Some("HEAD"), 0, 500).map_err(|e| e.to_string())?;
|
||||
|
||||
let result: Vec<_> = commits.iter()
|
||||
.filter(|c| domain.tree_entry_by_path(&c.tree_id, path).is_ok())
|
||||
.take(limit)
|
||||
.map(|c| flatten_commit(c))
|
||||
.collect();
|
||||
|
||||
Ok(serde_json::to_value(result).map_err(|e| e.to_string())?)
|
||||
}
|
||||
|
||||
fn flatten_commit(c: &git::commit::types::CommitMeta) -> serde_json::Value {
|
||||
use chrono::TimeZone;
|
||||
let ts = c.author.time_secs + (c.author.offset_minutes as i64 * 60);
|
||||
let author_time = chrono::Utc.timestamp_opt(ts, 0).single()
|
||||
.map(|dt| dt.to_rfc3339()).unwrap_or_else(|| format!("{}", c.author.time_secs));
|
||||
let oid = c.oid.to_string();
|
||||
serde_json::json!({
|
||||
"oid": oid.clone(),
|
||||
"short_oid": oid.get(..7).unwrap_or(&oid).to_string(),
|
||||
"message": c.message, "summary": c.summary,
|
||||
"author_name": c.author.name, "author_email": c.author.email, "author_time": author_time,
|
||||
"committer_name": c.committer.name, "committer_email": c.committer.email,
|
||||
"parent_oids": c.parent_ids.iter().map(|p| p.to_string()).collect::<Vec<_>>(),
|
||||
"tree_oid": c.tree_id.to_string()
|
||||
})
|
||||
}
|
||||
|
||||
macro_rules! register_fn {
|
||||
($registry:expr, $name:expr, $exec:expr) => {
|
||||
let handler_fn = move |ctx: agent::ToolContext, args: serde_json::Value| async move {
|
||||
let gctx = super::ctx::GitToolCtx::new(ctx);
|
||||
$exec(gctx, args)
|
||||
.await
|
||||
.map_err(agent::ToolError::ExecutionError)
|
||||
};
|
||||
$registry.register_fn($name, handler_fn);
|
||||
};
|
||||
}
|
||||
|
||||
pub fn register_git_tools(registry: &mut ToolRegistry) {
|
||||
register_fn!(registry, "git_file_content", git_file_content_exec);
|
||||
register_fn!(registry, "git_tree_ls", git_tree_ls_exec);
|
||||
register_fn!(registry, "git_file_history", git_file_history_exec);
|
||||
}
|
||||
411
libs/service/git_tools/types.rs
Normal file
411
libs/service/git_tools/types.rs
Normal file
@ -0,0 +1,411 @@
|
||||
//! Flat, JSON-friendly output types for git tools.
|
||||
//!
|
||||
//! These types convert from internal `git` crate types to clean, flat structures
|
||||
//! suitable for JSON serialization (function call responses).
|
||||
|
||||
use base64::Engine;
|
||||
use chrono::TimeZone;
|
||||
use git::commit::types::{CommitMeta, CommitReflogEntry};
|
||||
use git::diff::types::{DiffDelta, DiffStats};
|
||||
use git::tree::types::TreeEntry;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parameter structs used by all tool modules
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct RevQuery { pub rev: String }
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct SearchCommits { pub query: String, #[serde(default = "dl")] pub limit: u32 }
|
||||
fn dl() -> u32 { 20 }
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct GraphParams { #[serde(default)] pub rev: Option<String>, #[serde(default = "dl")] pub limit: u32 }
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct ReflogParams { #[serde(default)] pub ref_name: Option<String>, #[serde(default = "reflog_def")] pub limit: u32 }
|
||||
fn reflog_def() -> u32 { 50 }
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct SingleBranch { pub name: String }
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct BranchesMerged { pub branch: String, #[serde(default)] pub into: Option<String> }
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct BranchDiffP { pub local: String, #[serde(default)] pub remote: Option<String> }
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct DiffP { #[serde(default)] pub base: Option<String>, #[serde(default)] pub head: Option<String>, #[serde(default)] pub paths: Option<Vec<String>> }
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct DiffStatsP { pub base: String, pub head: String }
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct BlameP { pub path: String, #[serde(default)] pub rev: Option<String>, #[serde(default)] pub from_line: Option<u32>, #[serde(default)] pub to_line: Option<u32> }
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct FileContentP { pub path: String, #[serde(default)] pub rev: Option<String> }
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct TreeLsP { #[serde(default)] pub path: Option<String>, #[serde(default)] pub rev: Option<String> }
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct FileHistoryP { pub path: String, #[serde(default = "dl")] pub limit: u32 }
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct TagListP { #[serde(default)] pub pattern: Option<String> }
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct SingleTagP { pub name: String }
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct GitLogP { #[serde(default)] pub rev: Option<String>, #[serde(default = "dl")] pub limit: u32, #[serde(default)] pub skip: u32 }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Commit types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Flat commit information for tool responses.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CommitInfo {
|
||||
pub oid: String,
|
||||
pub short_oid: String,
|
||||
pub message: String,
|
||||
pub summary: String,
|
||||
pub author_name: String,
|
||||
pub author_email: String,
|
||||
pub author_time: String,
|
||||
pub committer_name: String,
|
||||
pub committer_email: String,
|
||||
pub parent_oids: Vec<String>,
|
||||
pub tree_oid: String,
|
||||
}
|
||||
|
||||
impl CommitInfo {
|
||||
pub fn from_meta(meta: &CommitMeta) -> Self {
|
||||
let ts = meta.author.time_secs;
|
||||
let offset = meta.author.offset_minutes;
|
||||
let author_time = format_rfc3339(ts, offset);
|
||||
|
||||
Self {
|
||||
oid: meta.oid.to_string(),
|
||||
short_oid: meta.oid.to_string().get(..7).unwrap_or(&meta.oid.to_string()).to_string(),
|
||||
message: meta.message.clone(),
|
||||
summary: meta.summary.clone(),
|
||||
author_name: meta.author.name.clone(),
|
||||
author_email: meta.author.email.clone(),
|
||||
author_time,
|
||||
committer_name: meta.committer.name.clone(),
|
||||
committer_email: meta.committer.email.clone(),
|
||||
parent_oids: meta.parent_ids.iter().map(|p| p.to_string()).collect(),
|
||||
tree_oid: meta.tree_id.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Commit reflog entry for tool responses.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ReflogEntryInfo {
|
||||
pub oid_new: String,
|
||||
pub oid_old: String,
|
||||
pub committer_name: String,
|
||||
pub committer_email: String,
|
||||
pub time: String,
|
||||
pub message: Option<String>,
|
||||
pub ref_name: String,
|
||||
}
|
||||
|
||||
impl ReflogEntryInfo {
|
||||
pub fn from_entry(entry: &CommitReflogEntry) -> Self {
|
||||
let ts = entry.time_secs;
|
||||
let time = format_rfc3339(ts, 0);
|
||||
Self {
|
||||
oid_new: entry.oid_new.to_string(),
|
||||
oid_old: entry.oid_old.to_string(),
|
||||
committer_name: entry.committer_name.clone(),
|
||||
committer_email: entry.committer_email.clone(),
|
||||
time,
|
||||
message: entry.message.clone(),
|
||||
ref_name: entry.ref_name.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Branch types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Flat branch info for tool responses.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BranchInfoOut {
|
||||
pub name: String,
|
||||
pub oid: String,
|
||||
pub short_oid: String,
|
||||
pub is_head: bool,
|
||||
pub is_remote: bool,
|
||||
pub is_current: bool,
|
||||
pub upstream: Option<String>,
|
||||
}
|
||||
|
||||
impl From<&git::branch::types::BranchInfo> for BranchInfoOut {
|
||||
fn from(b: &git::branch::types::BranchInfo) -> Self {
|
||||
let oid = b.oid.to_string();
|
||||
Self {
|
||||
name: b.name.clone(),
|
||||
oid: oid.clone(),
|
||||
short_oid: oid.get(..7).unwrap_or(&oid).to_string(),
|
||||
is_head: b.is_head,
|
||||
is_remote: b.is_remote,
|
||||
is_current: b.is_current,
|
||||
upstream: b.upstream.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Branch diff (ahead/behind) for tool responses.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BranchDiffOut {
|
||||
pub ahead: usize,
|
||||
pub behind: usize,
|
||||
pub diverged: bool,
|
||||
}
|
||||
|
||||
impl From<&git::branch::types::BranchDiff> for BranchDiffOut {
|
||||
fn from(d: &git::branch::types::BranchDiff) -> Self {
|
||||
Self {
|
||||
ahead: d.ahead,
|
||||
behind: d.behind,
|
||||
diverged: d.diverged,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Diff types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Diff statistics for tool responses.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DiffStatsOut {
|
||||
pub files_changed: u32,
|
||||
pub insertions: u32,
|
||||
pub deletions: u32,
|
||||
}
|
||||
|
||||
impl From<&DiffStats> for DiffStatsOut {
|
||||
fn from(s: &DiffStats) -> Self {
|
||||
Self {
|
||||
files_changed: s.files_changed as u32,
|
||||
insertions: s.insertions as u32,
|
||||
deletions: s.deletions as u32,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A single diff file change for tool responses.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DiffFileOut {
|
||||
pub path: Option<String>,
|
||||
pub status: String,
|
||||
pub is_binary: bool,
|
||||
pub size: u64,
|
||||
}
|
||||
|
||||
impl DiffFileOut {
|
||||
pub fn from_delta(delta: &DiffDelta) -> Self {
|
||||
Self {
|
||||
path: delta.new_file.path.clone(),
|
||||
status: format!("{:?}", delta.status),
|
||||
is_binary: delta.new_file.is_binary,
|
||||
size: delta.new_file.size,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Diff summary (files + stats) for tool responses.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DiffOut {
|
||||
pub stats: DiffStatsOut,
|
||||
pub files: Vec<DiffFileOut>,
|
||||
}
|
||||
|
||||
impl DiffOut {
|
||||
pub fn from_result(result: &git::diff::types::DiffResult) -> Self {
|
||||
let stats = DiffStatsOut::from(&result.stats);
|
||||
let files = result.deltas.iter().map(DiffFileOut::from_delta).collect();
|
||||
Self { stats, files }
|
||||
}
|
||||
}
|
||||
|
||||
/// Blame hunk for tool responses.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BlameHunkOut {
|
||||
pub commit_oid: String,
|
||||
pub short_oid: String,
|
||||
pub final_start_line: u32,
|
||||
pub final_lines: u32,
|
||||
pub orig_start_line: u32,
|
||||
pub orig_path: Option<String>,
|
||||
pub boundary: bool,
|
||||
}
|
||||
|
||||
impl From<&git::commit::types::CommitBlameHunk> for BlameHunkOut {
|
||||
fn from(h: &git::commit::types::CommitBlameHunk) -> Self {
|
||||
let oid = h.commit_oid.to_string();
|
||||
Self {
|
||||
commit_oid: oid.clone(),
|
||||
short_oid: oid.get(..7).unwrap_or(&oid).to_string(),
|
||||
final_start_line: h.final_start_line,
|
||||
final_lines: h.final_lines,
|
||||
orig_start_line: h.orig_start_line,
|
||||
orig_path: h.orig_path.clone(),
|
||||
boundary: h.boundary,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tree types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Directory entry for tool responses.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TreeLsEntry {
|
||||
pub name: String,
|
||||
pub oid: String,
|
||||
pub kind: String,
|
||||
pub is_binary: bool,
|
||||
}
|
||||
|
||||
impl From<&TreeEntry> for TreeLsEntry {
|
||||
fn from(entry: &TreeEntry) -> Self {
|
||||
Self {
|
||||
name: entry.name.clone(),
|
||||
oid: entry.oid.to_string(),
|
||||
kind: entry.kind.clone(),
|
||||
is_binary: entry.is_binary,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// File content for tool responses.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FileContentOut {
|
||||
pub path: String,
|
||||
pub oid: String,
|
||||
pub size: u64,
|
||||
pub content: String,
|
||||
pub is_binary: bool,
|
||||
}
|
||||
|
||||
impl FileContentOut {
|
||||
pub fn from_blob(
|
||||
path: String,
|
||||
oid: &git::commit::types::CommitOid,
|
||||
content: &[u8],
|
||||
is_binary: bool,
|
||||
) -> Self {
|
||||
let (display_content, size) = if is_binary {
|
||||
(
|
||||
base64::engine::general_purpose::STANDARD.encode(content),
|
||||
content.len() as u64,
|
||||
)
|
||||
} else {
|
||||
(
|
||||
String::from_utf8_lossy(content).to_string(),
|
||||
content.len() as u64,
|
||||
)
|
||||
};
|
||||
|
||||
Self {
|
||||
path,
|
||||
oid: oid.to_string(),
|
||||
size,
|
||||
content: display_content,
|
||||
is_binary,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tag types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Tag info for tool responses.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TagInfoOut {
|
||||
pub name: String,
|
||||
pub oid: String,
|
||||
pub target: String,
|
||||
pub is_annotated: bool,
|
||||
pub message: Option<String>,
|
||||
pub tagger_name: Option<String>,
|
||||
pub tagger_email: Option<String>,
|
||||
}
|
||||
|
||||
impl From<&git::tags::types::TagInfo> for TagInfoOut {
|
||||
fn from(t: &git::tags::types::TagInfo) -> Self {
|
||||
Self {
|
||||
name: t.name.clone(),
|
||||
oid: t.oid.to_string(),
|
||||
target: t.target.to_string(),
|
||||
is_annotated: t.is_annotated,
|
||||
message: t.message.clone(),
|
||||
tagger_name: t.tagger.clone(),
|
||||
tagger_email: t.tagger_email.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Graph types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Commit graph line for tool responses.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GraphLineOut {
|
||||
pub oid: String,
|
||||
pub short_oid: String,
|
||||
pub refs: String,
|
||||
pub short_message: String,
|
||||
pub lane_index: usize,
|
||||
pub author_name: String,
|
||||
pub author_email: String,
|
||||
pub author_time: String,
|
||||
pub parent_oids: Vec<String>,
|
||||
}
|
||||
|
||||
impl From<&git::commit::graph::CommitGraphLine> for GraphLineOut {
|
||||
fn from(line: &git::commit::graph::CommitGraphLine) -> Self {
|
||||
let oid = line.oid.to_string();
|
||||
let ts = line.meta.author.time_secs;
|
||||
let offset = line.meta.author.offset_minutes;
|
||||
Self {
|
||||
oid: oid.clone(),
|
||||
short_oid: oid.get(..7).unwrap_or(&oid).to_string(),
|
||||
refs: line.refs.clone(),
|
||||
short_message: line.short_message.clone(),
|
||||
lane_index: line.lane_index,
|
||||
author_name: line.meta.author.name.clone(),
|
||||
author_email: line.meta.author.email.clone(),
|
||||
author_time: format_rfc3339(ts, offset),
|
||||
parent_oids: line.meta.parent_ids.iter().map(|p| p.to_string()).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn format_rfc3339(time_secs: i64, offset_minutes: i32) -> String {
|
||||
let secs = time_secs + (offset_minutes as i64 * 60);
|
||||
chrono::Utc
|
||||
.timestamp_opt(secs, 0)
|
||||
.single()
|
||||
.map(|dt| dt.to_rfc3339())
|
||||
.unwrap_or_else(|| format!("{}", time_secs))
|
||||
}
|
||||
@ -1,6 +1,8 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use ::agent::chat::ChatService;
|
||||
use ::agent::task::service::TaskService;
|
||||
use async_openai::config::OpenAIConfig;
|
||||
use avatar::AppAvatar;
|
||||
use config::AppConfig;
|
||||
use db::cache::AppCache;
|
||||
@ -132,13 +134,36 @@ impl AppService {
|
||||
.and_then(|urls| urls.first().cloned())
|
||||
.unwrap_or_else(|| "redis://127.0.0.1:6379".to_string());
|
||||
|
||||
// Build ChatService if AI is configured; otherwise AI chat is disabled (graceful degradation)
|
||||
let chat_service: Option<Arc<ChatService>> = match (
|
||||
config.ai_api_key(),
|
||||
config.ai_basic_url(),
|
||||
) {
|
||||
(Ok(api_key), Ok(base_url)) => {
|
||||
slog::info!(logs, "AI chat enabled — connecting to {}", base_url);
|
||||
let cfg = OpenAIConfig::new()
|
||||
.with_api_key(&api_key)
|
||||
.with_api_base(&base_url);
|
||||
let client = async_openai::Client::with_config(cfg);
|
||||
Some(Arc::new(ChatService::new(client)))
|
||||
}
|
||||
(Err(e), _) => {
|
||||
slog::warn!(logs, "AI chat disabled — {}", e);
|
||||
None
|
||||
}
|
||||
(_, Err(e)) => {
|
||||
slog::warn!(logs, "AI chat disabled — {}", e);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let room = RoomService::new(
|
||||
db.clone(),
|
||||
cache.clone(),
|
||||
message_producer.clone(),
|
||||
room_manager,
|
||||
redis_url,
|
||||
None,
|
||||
chat_service,
|
||||
Some(task_service.clone()),
|
||||
logs.clone(),
|
||||
None,
|
||||
@ -200,6 +225,7 @@ pub mod agent;
|
||||
pub mod auth;
|
||||
pub mod error;
|
||||
pub mod git;
|
||||
pub mod git_tools;
|
||||
pub mod issue;
|
||||
pub mod project;
|
||||
pub mod pull_request;
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
use crate::AppService;
|
||||
use crate::error::AppError;
|
||||
use chrono::{DateTime, Utc};
|
||||
use futures::future::join_all;
|
||||
use models::projects::{
|
||||
MemberRole, project_audit_log, project_member_invitations, project_members,
|
||||
project, MemberRole, project_audit_log, project_member_invitations, project_members,
|
||||
};
|
||||
use models::users::{user, user_email};
|
||||
use sea_orm::*;
|
||||
@ -13,8 +14,10 @@ use uuid::Uuid;
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct InvitationResponse {
|
||||
pub project_uid: Uuid,
|
||||
pub project_name: String,
|
||||
pub user_uid: Uuid,
|
||||
pub invited_by: Uuid,
|
||||
pub invited_by_username: Option<String>,
|
||||
pub scope: String,
|
||||
pub accepted: bool,
|
||||
pub accepted_at: Option<DateTime<Utc>>,
|
||||
@ -31,18 +34,36 @@ pub struct InvitationListResponse {
|
||||
pub per_page: u64,
|
||||
}
|
||||
|
||||
impl From<project_member_invitations::Model> for InvitationResponse {
|
||||
fn from(invitation: project_member_invitations::Model) -> Self {
|
||||
impl InvitationResponse {
|
||||
pub async fn from_model(
|
||||
inv: project_member_invitations::Model,
|
||||
db: &DatabaseConnection,
|
||||
) -> Self {
|
||||
let project_name = project::Entity::find_by_id(inv.project)
|
||||
.one(db)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|p| p.name)
|
||||
.unwrap_or_default();
|
||||
let invited_by_username = user::Entity::find_by_id(inv.invited_by)
|
||||
.one(db)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|u| u.username);
|
||||
InvitationResponse {
|
||||
project_uid: invitation.project,
|
||||
user_uid: invitation.user,
|
||||
invited_by: invitation.invited_by,
|
||||
scope: invitation.scope,
|
||||
accepted: invitation.accepted,
|
||||
accepted_at: invitation.accepted_at,
|
||||
rejected: invitation.rejected,
|
||||
rejected_at: invitation.rejected_at,
|
||||
created_at: invitation.created_at,
|
||||
project_uid: inv.project,
|
||||
project_name,
|
||||
user_uid: inv.user,
|
||||
invited_by: inv.invited_by,
|
||||
invited_by_username,
|
||||
scope: inv.scope,
|
||||
accepted: inv.accepted,
|
||||
accepted_at: inv.accepted_at,
|
||||
rejected: inv.rejected,
|
||||
rejected_at: inv.rejected_at,
|
||||
created_at: inv.created_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -82,9 +103,31 @@ impl AppService {
|
||||
.count(&self.db)
|
||||
.await?;
|
||||
|
||||
let project_name = project.name.clone();
|
||||
let inviter_ids: Vec<Uuid> = invitations.iter().map(|i| i.invited_by).collect();
|
||||
let inviters: std::collections::HashMap<Uuid, Option<String>> = user::Entity::find()
|
||||
.filter(user::Column::Uid.is_in(inviter_ids))
|
||||
.all(&self.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|u| (u.uid, Some(u.username)))
|
||||
.collect();
|
||||
|
||||
let invitations = invitations
|
||||
.into_iter()
|
||||
.map(InvitationResponse::from)
|
||||
.map(|inv| InvitationResponse {
|
||||
project_uid: inv.project,
|
||||
project_name: project_name.clone(),
|
||||
user_uid: inv.user,
|
||||
invited_by: inv.invited_by,
|
||||
invited_by_username: inviters.get(&inv.invited_by).cloned().flatten(),
|
||||
scope: inv.scope,
|
||||
accepted: inv.accepted,
|
||||
accepted_at: inv.accepted_at,
|
||||
rejected: inv.rejected,
|
||||
rejected_at: inv.rejected_at,
|
||||
created_at: inv.created_at,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(InvitationListResponse {
|
||||
@ -122,9 +165,12 @@ impl AppService {
|
||||
.count(&self.db)
|
||||
.await?;
|
||||
|
||||
let invitations = invitations
|
||||
.into_iter()
|
||||
.map(InvitationResponse::from)
|
||||
let invitations = join_all(
|
||||
invitations
|
||||
.into_iter()
|
||||
.map(|inv| InvitationResponse::from_model(inv, self.db.writer())),
|
||||
)
|
||||
.await;
|
||||
.collect();
|
||||
|
||||
Ok(InvitationListResponse {
|
||||
|
||||
@ -36,6 +36,24 @@ pub struct PendingInvitationInfo {
|
||||
pub expires_at: Option<chrono::DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// Invitation received by the current user (workspace invitation for self).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct MyWorkspaceInvitation {
|
||||
pub workspace_id: Uuid,
|
||||
pub workspace_slug: String,
|
||||
pub workspace_name: String,
|
||||
pub role: String,
|
||||
pub invited_by_username: Option<String>,
|
||||
pub invited_at: chrono::DateTime<Utc>,
|
||||
pub expires_at: Option<chrono::DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// Request body for accepting workspace invitation by slug.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct WorkspaceAcceptBySlugParams {
|
||||
pub slug: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct WorkspaceMembersResponse {
|
||||
pub members: Vec<WorkspaceMemberInfo>,
|
||||
@ -395,6 +413,105 @@ impl AppService {
|
||||
self.utils_find_workspace_by_id(ws_id).await
|
||||
}
|
||||
|
||||
/// List all pending workspace invitations for the current user (where user is invitee).
|
||||
pub async fn workspace_my_pending_invitations(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
) -> Result<Vec<MyWorkspaceInvitation>, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
|
||||
let pending = workspace_membership::Entity::find()
|
||||
.filter(workspace_membership::Column::UserId.eq(user_uid))
|
||||
.filter(workspace_membership::Column::Status.eq("pending"))
|
||||
.order_by_desc(workspace_membership::Column::JoinedAt)
|
||||
.all(&self.db)
|
||||
.await?;
|
||||
|
||||
if pending.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
// Fetch workspace info
|
||||
let ws_ids: Vec<Uuid> = pending.iter().map(|m| m.workspace_id).collect();
|
||||
let workspaces: std::collections::HashMap<Uuid, workspace::Model> = workspace::Entity::find()
|
||||
.filter(workspace::Column::Id.is_in(ws_ids.clone()))
|
||||
.all(&self.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|w| (w.id, w))
|
||||
.collect();
|
||||
|
||||
// Fetch inviter usernames
|
||||
let inviter_ids: Vec<Uuid> = pending.iter().filter_map(|m| m.invited_by).collect();
|
||||
let inviters: std::collections::HashMap<Uuid, String> = if !inviter_ids.is_empty() {
|
||||
user::Entity::find()
|
||||
.filter(user::Column::Uid.is_in(inviter_ids))
|
||||
.all(&self.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|u| (u.uid, u.username))
|
||||
.collect()
|
||||
} else {
|
||||
std::collections::HashMap::new()
|
||||
};
|
||||
|
||||
let invitations: Vec<MyWorkspaceInvitation> = pending
|
||||
.into_iter()
|
||||
.filter_map(|m| {
|
||||
let ws = workspaces.get(&m.workspace_id)?;
|
||||
let invited_by_username = m.invited_by.and_then(|uid| inviters.get(&uid).cloned());
|
||||
Some(MyWorkspaceInvitation {
|
||||
workspace_id: m.workspace_id,
|
||||
workspace_slug: ws.slug.clone(),
|
||||
workspace_name: ws.name.clone(),
|
||||
role: m.role,
|
||||
invited_by_username,
|
||||
invited_at: m.joined_at,
|
||||
expires_at: m.invite_expires_at,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(invitations)
|
||||
}
|
||||
|
||||
/// Accept a workspace invitation by slug (for the current user).
|
||||
pub async fn workspace_accept_invitation_by_slug(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
params: WorkspaceAcceptBySlugParams,
|
||||
) -> Result<workspace::Model, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let ws = self.utils_find_workspace_by_slug(params.slug.clone()).await?;
|
||||
|
||||
let membership = workspace_membership::Entity::find()
|
||||
.filter(workspace_membership::Column::WorkspaceId.eq(ws.id))
|
||||
.filter(workspace_membership::Column::UserId.eq(user_uid))
|
||||
.one(&self.db)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound(
|
||||
"No pending invitation found for this workspace".to_string(),
|
||||
))?;
|
||||
|
||||
if membership.status == "active" {
|
||||
return Err(AppError::WorkspaceInviteAlreadyAccepted);
|
||||
}
|
||||
|
||||
if let Some(expires_at) = membership.invite_expires_at {
|
||||
if Utc::now() > expires_at {
|
||||
return Err(AppError::WorkspaceInviteExpired);
|
||||
}
|
||||
}
|
||||
|
||||
let mut m: workspace_membership::ActiveModel = membership.into();
|
||||
m.status = Set("active".to_string());
|
||||
m.invite_token = Set(None);
|
||||
m.invite_expires_at = Set(None);
|
||||
m.update(&self.db).await?;
|
||||
|
||||
Ok(ws)
|
||||
}
|
||||
|
||||
pub async fn workspace_remove_member(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
|
||||
@ -57,6 +57,8 @@ import {SettingsPreferences} from '@/app/settings/preferences';
|
||||
import {SettingsActivity} from '@/app/settings/activity';
|
||||
import NotifyLayout from '@/app/notify/layout';
|
||||
import NotifyPage from '@/app/notify/page';
|
||||
import InvitationsLayout from '@/app/invitations/layout';
|
||||
import InvitationsPage from '@/app/invitations/page';
|
||||
import LandingPage from '@/app/page';
|
||||
import SearchPage from '@/app/search/page';
|
||||
import PricingPage from '@/app/pricing/page';
|
||||
@ -143,6 +145,10 @@ function App() {
|
||||
<Route index element={<NotifyPage/>}/>
|
||||
</Route>
|
||||
|
||||
<Route path="/invitations" element={<InvitationsLayout/>}>
|
||||
<Route index element={<InvitationsPage/>}/>
|
||||
</Route>
|
||||
|
||||
<Route path="/repository/:namespace/:repoName" element={<RepoLayout/>}>
|
||||
<Route index element={<RepoOverview/>}/>
|
||||
<Route path="branches" element={<RepoBranches/>}/>
|
||||
|
||||
94
src/app/invitations/layout.tsx
Normal file
94
src/app/invitations/layout.tsx
Normal file
@ -0,0 +1,94 @@
|
||||
import { Bell, ChevronLeft, UserPlus } from "lucide-react";
|
||||
import { Outlet, useNavigate } from "react-router-dom";
|
||||
import { SidebarSystem } from "@/components/layout/sidebar-system";
|
||||
import { SidebarUser } from "@/components/layout/sidebar-user";
|
||||
import { useSidebarCollapse } from "@/hooks/use-sidebar-collapse";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
||||
function InvitationsSidebar() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { collapsed, setCollapsed } = useSidebarCollapse();
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
"hidden md:flex h-full flex-col border-r bg-background transition-all duration-200",
|
||||
collapsed ? "w-16" : "w-64",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn("border-b h-12 flex items-center", collapsed ? "justify-center" : "justify-between")}
|
||||
>
|
||||
{!collapsed && (
|
||||
<div className="flex min-w-0 items-center gap-3 overflow-hidden px-3">
|
||||
<div className="bg-muted rounded-full w-8 h-8 flex items-center justify-center">
|
||||
<UserPlus className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 truncate text-sm font-semibold">
|
||||
My Invitations
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-md hover:bg-muted cursor-pointer bg-transparent border-0"
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 p-2">
|
||||
<div className="space-y-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/invitations")}
|
||||
className={cn(
|
||||
"flex w-full h-9 justify-start items-center rounded-md font-medium hover:bg-muted cursor-pointer bg-transparent border-0 text-left text-sm",
|
||||
location.pathname === "/invitations" && "bg-primary/10 text-primary",
|
||||
collapsed ? "justify-center px-0" : "justify-start px-2",
|
||||
)}
|
||||
>
|
||||
<span className={cn("flex h-6 items-center shrink-0", collapsed ? "w-6 justify-center" : "w-6")}>
|
||||
<UserPlus className="h-4 w-4" />
|
||||
</span>
|
||||
{!collapsed && "Invitations"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/notify")}
|
||||
className={cn(
|
||||
"flex w-full h-9 justify-start items-center rounded-md font-medium hover:bg-muted cursor-pointer bg-transparent border-0 text-left text-sm",
|
||||
location.pathname === "/notify" && "bg-primary/10 text-primary",
|
||||
collapsed ? "justify-center px-0" : "justify-start px-2",
|
||||
)}
|
||||
>
|
||||
<span className={cn("flex h-6 items-center shrink-0", collapsed ? "w-6 justify-center" : "w-6")}>
|
||||
<Bell className="h-4 w-4" />
|
||||
</span>
|
||||
{!collapsed && "Notifications"}
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="border-t flex flex-col justify-end">
|
||||
<SidebarSystem collapsed={collapsed} />
|
||||
<SidebarUser collapsed={collapsed} />
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export default function InvitationsLayout() {
|
||||
return (
|
||||
<div className="flex h-screen w-full bg-background">
|
||||
<InvitationsSidebar />
|
||||
<main className="flex-1 h-screen overflow-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
316
src/app/invitations/page.tsx
Normal file
316
src/app/invitations/page.tsx
Normal file
@ -0,0 +1,316 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Building2,
|
||||
Check,
|
||||
FolderKanban,
|
||||
Loader2,
|
||||
UserPlus,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
projectMyInvitations,
|
||||
projectAcceptInvitation,
|
||||
projectRejectInvitation,
|
||||
workspaceMyInvitations,
|
||||
workspaceAcceptInvitationBySlug,
|
||||
} from "@/client";
|
||||
import type { InvitationResponse, MyWorkspaceInvitation } from "@/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { getApiErrorMessage } from "@/lib/api-error";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
function formatTime(dateStr: string): string {
|
||||
const d = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - d.getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
if (minutes < 1) return "just now";
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 7) return `${days}d ago`;
|
||||
return d.toLocaleDateString();
|
||||
}
|
||||
|
||||
type UnifiedInvitation =
|
||||
| ({ type: "project" } & InvitationResponse)
|
||||
| ({ type: "workspace" } & MyWorkspaceInvitation);
|
||||
|
||||
function ProjectInvitationItem({
|
||||
inv,
|
||||
onAccept,
|
||||
onReject,
|
||||
isAccepting,
|
||||
isRejecting,
|
||||
}: {
|
||||
inv: InvitationResponse;
|
||||
onAccept: () => void;
|
||||
onReject: () => void;
|
||||
isAccepting: boolean;
|
||||
isRejecting: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start gap-3 px-4 py-4 border-b last:border-b-0 hover:bg-muted/50 transition-colors">
|
||||
<div className="flex-shrink-0 mt-0.5 h-8 w-8 rounded-full border bg-purple-500/10 text-purple-600 border-purple-500/20 flex items-center justify-center">
|
||||
<FolderKanban className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-semibold truncate">Project Invitation</p>
|
||||
<Badge variant="outline" className="text-xs bg-purple-500/10 text-purple-600 border-purple-500/20">
|
||||
{inv.scope}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
You've been invited to join{" "}
|
||||
<span className="font-medium text-foreground">{inv.project_name}</span>
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-1.5">
|
||||
{inv.invited_by_username && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
by {inv.invited_by_username}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatTime(inv.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0 flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 text-xs gap-1 text-muted-foreground hover:text-foreground"
|
||||
onClick={onReject}
|
||||
disabled={isAccepting || isRejecting}
|
||||
>
|
||||
{isRejecting ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<X className="h-3 w-3" />
|
||||
)}
|
||||
Decline
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="h-8 text-xs gap-1"
|
||||
onClick={onAccept}
|
||||
disabled={isAccepting || isRejecting}
|
||||
>
|
||||
{isAccepting ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Check className="h-3 w-3" />
|
||||
)}
|
||||
Accept
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceInvitationItem({
|
||||
inv,
|
||||
onAccept,
|
||||
isAccepting,
|
||||
}: {
|
||||
inv: MyWorkspaceInvitation;
|
||||
onAccept: () => void;
|
||||
isAccepting: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start gap-3 px-4 py-4 border-b last:border-b-0 hover:bg-muted/50 transition-colors">
|
||||
<div className="flex-shrink-0 mt-0.5 h-8 w-8 rounded-full border bg-blue-500/10 text-blue-600 border-blue-500/20 flex items-center justify-center">
|
||||
<Building2 className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-semibold truncate">Workspace Invitation</p>
|
||||
<Badge variant="outline" className="text-xs bg-blue-500/10 text-blue-600 border-blue-500/20">
|
||||
{inv.role}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
You've been invited to join workspace{" "}
|
||||
<span className="font-medium text-foreground">{inv.workspace_name}</span>
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-1.5">
|
||||
{inv.invited_by_username && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
by {inv.invited_by_username}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatTime(inv.invited_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="h-8 text-xs gap-1"
|
||||
onClick={onAccept}
|
||||
disabled={isAccepting}
|
||||
>
|
||||
{isAccepting ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Check className="h-3 w-3" />
|
||||
)}
|
||||
Accept
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function InvitationsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data: projectData, isLoading: projectLoading } = useQuery({
|
||||
queryKey: ["projectInvitations"],
|
||||
queryFn: async () => {
|
||||
const resp = await projectMyInvitations();
|
||||
return resp.data?.data ?? null;
|
||||
},
|
||||
});
|
||||
|
||||
const { data: workspaceData, isLoading: workspaceLoading } = useQuery({
|
||||
queryKey: ["workspaceInvitations"],
|
||||
queryFn: async () => {
|
||||
const resp = await workspaceMyInvitations();
|
||||
return resp.data?.data ?? [];
|
||||
},
|
||||
});
|
||||
|
||||
const acceptProjectMutation = useMutation({
|
||||
mutationFn: async (projectName: string) => {
|
||||
await projectAcceptInvitation({ path: { project_name: projectName } });
|
||||
},
|
||||
onSuccess: (_data, projectName) => {
|
||||
toast.success(`You've joined project: ${projectName}`);
|
||||
queryClient.invalidateQueries({ queryKey: ["projectInvitations"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["me"] });
|
||||
navigate(`/project/${projectName}`);
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error(getApiErrorMessage(err, "Failed to accept invitation"));
|
||||
},
|
||||
});
|
||||
|
||||
const rejectProjectMutation = useMutation({
|
||||
mutationFn: async (projectName: string) => {
|
||||
await projectRejectInvitation({ path: { project_name: projectName } });
|
||||
},
|
||||
onSuccess: (_data, projectName) => {
|
||||
toast.success(`Invitation to ${projectName} declined`);
|
||||
queryClient.invalidateQueries({ queryKey: ["projectInvitations"] });
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error(getApiErrorMessage(err, "Failed to decline invitation"));
|
||||
},
|
||||
});
|
||||
|
||||
const acceptWorkspaceMutation = useMutation({
|
||||
mutationFn: async (slug: string) => {
|
||||
await workspaceAcceptInvitationBySlug({
|
||||
body: { slug },
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("You've joined the workspace");
|
||||
queryClient.invalidateQueries({ queryKey: ["workspaceInvitations"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["me"] });
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error(getApiErrorMessage(err, "Failed to accept invitation"));
|
||||
},
|
||||
});
|
||||
|
||||
const projectInvitations: InvitationResponse[] = projectData?.invitations ?? [];
|
||||
const workspaceInvitations: MyWorkspaceInvitation[] = workspaceData ?? [];
|
||||
const total = projectInvitations.length + workspaceInvitations.length;
|
||||
|
||||
const unified: UnifiedInvitation[] = [
|
||||
...projectInvitations.map((inv) => ({ type: "project" as const, ...inv })),
|
||||
...workspaceInvitations.map((inv) => ({ type: "workspace" as const, ...inv })),
|
||||
].sort((a, b) => {
|
||||
const aTime = a.type === "project" ? a.created_at : a.invited_at;
|
||||
const bTime = b.type === "project" ? b.created_at : b.invited_at;
|
||||
return new Date(bTime).getTime() - new Date(aTime).getTime();
|
||||
});
|
||||
|
||||
const isLoading = projectLoading || workspaceLoading;
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold flex items-center gap-2">
|
||||
<UserPlus className="h-5 w-5" />
|
||||
My Invitations
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
{total > 0
|
||||
? `${total} pending invitation${total !== 1 ? "s" : ""}`
|
||||
: "No pending invitations"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg bg-card overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-48">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : unified.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground">
|
||||
<UserPlus className="h-10 w-10 mb-3 opacity-40" />
|
||||
<p className="font-medium">No pending invitations</p>
|
||||
<p className="text-sm mt-1">
|
||||
Project and workspace invitations will appear here.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
unified.map((inv) => {
|
||||
if (inv.type === "project") {
|
||||
return (
|
||||
<ProjectInvitationItem
|
||||
key={`project-${inv.project_uid}`}
|
||||
inv={inv}
|
||||
onAccept={() => acceptProjectMutation.mutate(inv.project_name)}
|
||||
onReject={() => rejectProjectMutation.mutate(inv.project_name)}
|
||||
isAccepting={acceptProjectMutation.isPending}
|
||||
isRejecting={rejectProjectMutation.isPending}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<WorkspaceInvitationItem
|
||||
key={`workspace-${inv.workspace_id}`}
|
||||
inv={inv}
|
||||
onAccept={() => acceptWorkspaceMutation.mutate(inv.workspace_slug)}
|
||||
isAccepting={acceptWorkspaceMutation.isPending}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import { Bell, ChevronLeft } from "lucide-react";
|
||||
import { Bell, ChevronLeft, UserPlus } from "lucide-react";
|
||||
import { Outlet, useNavigate } from "react-router-dom";
|
||||
import { SidebarSystem } from "@/components/layout/sidebar-system";
|
||||
import { SidebarUser } from "@/components/layout/sidebar-user";
|
||||
@ -43,6 +43,20 @@ function NotifySidebar() {
|
||||
|
||||
<nav className="flex-1 p-2">
|
||||
<div className="space-y-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/invitations")}
|
||||
className={cn(
|
||||
"flex w-full h-9 justify-start items-center rounded-md font-medium hover:bg-muted cursor-pointer bg-transparent border-0 text-left text-sm",
|
||||
location.pathname === "/invitations" && "bg-primary/10 text-primary",
|
||||
collapsed ? "justify-center px-0" : "justify-start px-2",
|
||||
)}
|
||||
>
|
||||
<span className={cn("flex h-6 items-center shrink-0", collapsed ? "w-6 justify-center" : "w-6")}>
|
||||
<UserPlus className="h-4 w-4" />
|
||||
</span>
|
||||
{!collapsed && "Invitations"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/notify")}
|
||||
@ -55,7 +69,7 @@ function NotifySidebar() {
|
||||
<span className={cn("flex h-6 items-center shrink-0", collapsed ? "w-6 justify-center" : "w-6")}>
|
||||
<Bell className="h-4 w-4" />
|
||||
</span>
|
||||
{!collapsed && "All Notifications"}
|
||||
{!collapsed && "Notifications"}
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -3310,8 +3310,10 @@ export type InvitationListResponse = {
|
||||
|
||||
export type InvitationResponse = {
|
||||
project_uid: string;
|
||||
project_name: string;
|
||||
user_uid: string;
|
||||
invited_by: string;
|
||||
invited_by_username?: string | null;
|
||||
scope: string;
|
||||
accepted: boolean;
|
||||
accepted_at?: string | null;
|
||||
@ -3814,6 +3816,16 @@ export type PendingInvitationInfo = {
|
||||
expires_at?: string | null;
|
||||
};
|
||||
|
||||
export type MyWorkspaceInvitation = {
|
||||
workspace_id: string;
|
||||
workspace_slug: string;
|
||||
workspace_name: string;
|
||||
role: string;
|
||||
invited_by_username?: string | null;
|
||||
invited_at: string;
|
||||
expires_at?: string | null;
|
||||
};
|
||||
|
||||
export type PrCommitResponse = {
|
||||
oid: string;
|
||||
short_oid: string;
|
||||
@ -5127,6 +5139,10 @@ export type WorkspaceInviteParams = {
|
||||
role?: string | null;
|
||||
};
|
||||
|
||||
export type WorkspaceAcceptBySlugParams = {
|
||||
slug: string;
|
||||
};
|
||||
|
||||
export type WorkspaceListItem = {
|
||||
id: string;
|
||||
slug: string;
|
||||
@ -18237,6 +18253,66 @@ export type WorkspaceAcceptInvitationResponses = {
|
||||
|
||||
export type WorkspaceAcceptInvitationResponse = WorkspaceAcceptInvitationResponses[keyof WorkspaceAcceptInvitationResponses];
|
||||
|
||||
export type WorkspaceMyInvitationsData = {
|
||||
body?: never;
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/api/workspaces/me/invitations';
|
||||
};
|
||||
|
||||
export type WorkspaceMyInvitationsErrors = {
|
||||
/**
|
||||
* Unauthorized
|
||||
*/
|
||||
401: unknown;
|
||||
};
|
||||
|
||||
export type ApiResponseVecMyWorkspaceInvitation = {
|
||||
code: number;
|
||||
message: string;
|
||||
data?: Array<MyWorkspaceInvitation>;
|
||||
};
|
||||
|
||||
export type WorkspaceMyInvitationsResponses = {
|
||||
/**
|
||||
* List my workspace invitations
|
||||
*/
|
||||
200: ApiResponseVecMyWorkspaceInvitation;
|
||||
};
|
||||
|
||||
export type WorkspaceMyInvitationsResponse = WorkspaceMyInvitationsResponses[keyof WorkspaceMyInvitationsResponses];
|
||||
|
||||
export type WorkspaceAcceptInvitationBySlugData = {
|
||||
body: WorkspaceAcceptBySlugParams;
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/api/workspaces/invitations/accept-by-slug';
|
||||
};
|
||||
|
||||
export type WorkspaceAcceptInvitationBySlugErrors = {
|
||||
/**
|
||||
* Unauthorized
|
||||
*/
|
||||
401: unknown;
|
||||
/**
|
||||
* Invitation not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Already accepted
|
||||
*/
|
||||
409: unknown;
|
||||
};
|
||||
|
||||
export type WorkspaceAcceptInvitationBySlugResponses = {
|
||||
/**
|
||||
* Accept invitation
|
||||
*/
|
||||
200: ApiResponseWorkspaceInfoResponse;
|
||||
};
|
||||
|
||||
export type WorkspaceAcceptInvitationBySlugResponse = WorkspaceAcceptInvitationBySlugResponses[keyof WorkspaceAcceptInvitationBySlugResponses];
|
||||
|
||||
export type WorkspaceListData = {
|
||||
body?: never;
|
||||
path?: never;
|
||||
|
||||
@ -10,7 +10,7 @@ import {
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {useUser} from '@/contexts';
|
||||
import {cn} from '@/lib/utils';
|
||||
import {Mail} from 'lucide-react';
|
||||
import {Mail, UserPlus} from 'lucide-react';
|
||||
import {useNavigate} from 'react-router-dom';
|
||||
|
||||
const btnClass = 'flex w-full h-9 justify-start items-center rounded-md font-medium hover:bg-muted cursor-pointer bg-transparent border-0 text-left text-sm';
|
||||
@ -21,6 +21,14 @@ export function SidebarUser({collapsed}: { collapsed: boolean }) {
|
||||
|
||||
return (
|
||||
<div className="w-full mb-2">
|
||||
<button type="button" className={cn(btnClass, collapsed ? 'justify-center px-0' : 'px-2')}
|
||||
onClick={() => navigate('/invitations')}>
|
||||
<span className="flex h-6 items-center shrink-0 w-6">
|
||||
<UserPlus className="h-4 w-4"/>
|
||||
</span>
|
||||
{!collapsed && <span className="text-sm leading-none">Invitations</span>}
|
||||
</button>
|
||||
|
||||
<button type="button" className={cn(btnClass, collapsed ? 'justify-center px-0' : 'px-2')}
|
||||
onClick={() => navigate('/notify')}>
|
||||
<span className="relative flex h-6 items-center shrink-0 w-6">
|
||||
|
||||
@ -79,6 +79,7 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete }: DiscordCh
|
||||
(content: string) => {
|
||||
sendMessage(content, 'text', replyingTo?.id ?? undefined);
|
||||
setReplyingTo(null);
|
||||
messageInputRef.current?.clearContent();
|
||||
},
|
||||
[sendMessage, replyingTo],
|
||||
);
|
||||
@ -277,6 +278,10 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete }: DiscordCh
|
||||
onRevoke={handleRevoke}
|
||||
onReply={setReplyingTo}
|
||||
onMention={undefined}
|
||||
onOpenUserCard={({ userId, username }) => {
|
||||
messageInputRef.current?.insertMention('user', userId, username);
|
||||
messageInputRef.current?.focus();
|
||||
}}
|
||||
onOpenThread={handleOpenThread}
|
||||
onCreateThread={handleCreateThread}
|
||||
/>
|
||||
@ -294,7 +299,12 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete }: DiscordCh
|
||||
<DiscordMemberList
|
||||
members={members}
|
||||
membersLoading={membersLoading}
|
||||
onMemberClick={() => {}}
|
||||
onMemberClick={({ user, user_info, role }) => {
|
||||
const label = user_info?.username ?? user;
|
||||
const type = role === 'ai' ? 'ai' : 'user';
|
||||
messageInputRef.current?.insertMention(type, user, label);
|
||||
messageInputRef.current?.focus();
|
||||
}}
|
||||
aiConfigs={roomAiConfigs}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -11,7 +11,6 @@ import { Button } from '@/components/ui/button';
|
||||
import { parseFunctionCalls, type FunctionCall } from '@/lib/functionCallParser';
|
||||
import { formatMessageTime } from '../shared/formatters';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SmilePlus } from 'lucide-react';
|
||||
import { useUser, useRoom, useTheme } from '@/contexts';
|
||||
import { memo, useMemo, useState, useCallback, useRef } from 'react';
|
||||
import { ModelIcon } from '../icon-match';
|
||||
@ -20,6 +19,7 @@ import { MessageContent } from './MessageContent';
|
||||
import { ThreadIndicator } from '../RoomThreadPanel';
|
||||
import { getSenderDisplayName, getSenderModelId, getAvatarFromUiMessage, getSenderUserUid, isUserSender } from '../sender';
|
||||
import { MessageReactions } from './MessageReactions';
|
||||
import { ReactionPicker } from './ReactionPicker';
|
||||
|
||||
// Sender colors — AI Studio clean palette
|
||||
const SENDER_COLORS: Record<string, string> = {
|
||||
@ -375,14 +375,7 @@ export const MessageBubble = memo(function MessageBubble({
|
||||
className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity absolute -top-3 right-3"
|
||||
style={{ background: 'var(--card)', border: '1px solid var(--room-border)', borderRadius: 6 }}
|
||||
>
|
||||
<button
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md transition-colors"
|
||||
style={{ color: 'var(--room-text-muted)' }}
|
||||
onClick={() => handleReaction('👍')}
|
||||
title="Add reaction"
|
||||
>
|
||||
<SmilePlus className="size-3.5" />
|
||||
</button>
|
||||
<ReactionPicker onReact={handleReaction} />
|
||||
{onReply && (
|
||||
<button
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md transition-colors"
|
||||
|
||||
@ -21,6 +21,7 @@ export interface MessageInputHandle {
|
||||
focus: () => void;
|
||||
clearContent: () => void;
|
||||
getContent: () => string;
|
||||
insertMention: (type: string, id: string, label: string) => void;
|
||||
}
|
||||
|
||||
export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(function MessageInput(
|
||||
|
||||
@ -13,6 +13,7 @@ import { CustomEmojiNode } from './EmojiNode';
|
||||
import type { MentionItem, MessageAST, MentionType } from './types';
|
||||
import { Paperclip, Smile, Send, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { COMMON_EMOJIS } from '../../shared';
|
||||
import { useTheme } from '@/contexts';
|
||||
|
||||
export interface IMEditorProps {
|
||||
@ -33,6 +34,7 @@ export interface IMEditorHandle {
|
||||
focus: () => void;
|
||||
clearContent: () => void;
|
||||
getContent: () => string;
|
||||
insertMention: (type: string, id: string, label: string) => void;
|
||||
}
|
||||
|
||||
// ─── Color System (Google AI Studio / Linear palette, no Discord) ────────────
|
||||
@ -91,22 +93,7 @@ type Palette = typeof LIGHT;
|
||||
|
||||
// ─── Emoji Picker ─────────────────────────────────────────────────────────────
|
||||
|
||||
const EMOJIS = [
|
||||
{ name: 'thumbsup', url: 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f44d.png' },
|
||||
{ name: 'thumbsdown', url: 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f44e.png' },
|
||||
{ name: 'heart', url: 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/2764.png' },
|
||||
{ name: 'laugh', url: 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f602.png' },
|
||||
{ name: 'rocket', url: 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f680.png' },
|
||||
{ name: 'fire', url: 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f525.png' },
|
||||
{ name: 'eyes', url: 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f440.png' },
|
||||
{ name: 'check', url: 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/2705.png' },
|
||||
{ name: 'star', url: 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/2b50.png' },
|
||||
{ name: 'clap', url: 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f44f.png' },
|
||||
{ name: 'thinking', url: 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f914.png' },
|
||||
{ name: 'wave', url: 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f44b.png' },
|
||||
];
|
||||
|
||||
function EmojiPicker({ onClose, onSelect, p }: { onClose: () => void; onSelect: (n: string, u: string) => void; p: Palette }) {
|
||||
function EmojiPicker({ onClose, onSelect, p }: { onClose: () => void; onSelect: (emoji: string) => void; p: Palette }) {
|
||||
return (
|
||||
<div
|
||||
className="absolute bottom-full left-0 mb-2 z-50"
|
||||
@ -131,14 +118,14 @@ function EmojiPicker({ onClose, onSelect, p }: { onClose: () => void; onSelect:
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid p-2 gap-0.5" style={{ gridTemplateColumns: 'repeat(6, 1fr)' }}>
|
||||
{EMOJIS.map(e => (
|
||||
{COMMON_EMOJIS.map(emoji => (
|
||||
<button
|
||||
key={e.name}
|
||||
onClick={() => onSelect(e.name, e.url)}
|
||||
className="w-9 h-9 flex items-center justify-center rounded-lg transition-all duration-100 cursor-pointer hover:scale-110"
|
||||
key={emoji}
|
||||
onClick={() => onSelect(emoji)}
|
||||
className="w-9 h-9 flex items-center justify-center rounded-lg transition-all duration-100 cursor-pointer hover:scale-110 text-[18px]"
|
||||
style={{ background: 'transparent' }}
|
||||
>
|
||||
<img src={e.url} alt={e.name} className="w-5 h-5 pointer-events-none" />
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@ -368,6 +355,11 @@ export const IMEditor = forwardRef<IMEditorHandle, IMEditorProps>(function IMEdi
|
||||
focus: () => editor?.commands.focus(),
|
||||
clearContent: () => editor?.commands.clearContent(),
|
||||
getContent: () => editor?.getText() ?? '',
|
||||
insertMention: (type: string, id: string, label: string) => {
|
||||
if (!editor) return;
|
||||
const mentionStr = `@[${type}:${id}:${label}] `;
|
||||
editor.chain().focus().insertContent(mentionStr).run();
|
||||
},
|
||||
}));
|
||||
|
||||
const hasContent = !!editor && !editor.isEmpty;
|
||||
@ -442,7 +434,7 @@ export const IMEditor = forwardRef<IMEditorHandle, IMEditorProps>(function IMEdi
|
||||
>
|
||||
<Smile size={18} />
|
||||
</button>
|
||||
{showEmoji && <EmojiPicker onClose={() => setShowEmoji(false)} onSelect={(n, u) => { editor?.chain().focus().insertContent({ type: 'emoji', attrs: { name: n, url: u } }).insertContent(' ').run(); setShowEmoji(false); }} p={p} />}
|
||||
{showEmoji && <EmojiPicker onClose={() => setShowEmoji(false)} onSelect={(emoji) => { editor?.chain().focus().insertContent(emoji).insertContent(' ').run(); setShowEmoji(false); }} p={p} />}
|
||||
</div>
|
||||
|
||||
<label
|
||||
|
||||
@ -30,13 +30,6 @@ import {
|
||||
} from '@/lib/room-ws-client';
|
||||
import { requestWsToken } from '@/lib/ws-token';
|
||||
import { useUser } from '@/contexts';
|
||||
import {
|
||||
saveMessage,
|
||||
saveMessages,
|
||||
loadMessages as loadMessagesFromIdb,
|
||||
loadOlderMessagesFromIdb,
|
||||
deleteMessage as deleteMessageFromIdb,
|
||||
} from '@/lib/storage/indexed-db';
|
||||
|
||||
export type { RoomWsStatus, RoomWsClient } from '@/lib/room-ws-client';
|
||||
|
||||
@ -249,23 +242,17 @@ export function RoomProvider({
|
||||
}
|
||||
}, [activeRoomId]);
|
||||
|
||||
// ── Subscribe to room (WS must already be connected) ───────────────────────
|
||||
useEffect(() => {
|
||||
const client = wsClientRef.current;
|
||||
if (!activeRoomId || !client) return;
|
||||
|
||||
const setup = async () => {
|
||||
// IDB load does NOT need WS — show cached messages immediately.
|
||||
// loadMore checks IDB first, then falls back to API (WS-first + HTTP).
|
||||
loadMore(null);
|
||||
// Load messages via WS (with HTTP fallback)
|
||||
loadMore(null);
|
||||
|
||||
// Connect WS in parallel for real-time push + reactions batch-fetch.
|
||||
// connect() is idempotent — no-op if already connecting/open.
|
||||
// subscribeRoom uses WS-first request() with HTTP fallback.
|
||||
await client.connect();
|
||||
if (activeRoomIdRef.current !== activeRoomId) return;
|
||||
client.subscribeRoom(activeRoomId).catch(() => {});
|
||||
};
|
||||
setup().catch(() => {});
|
||||
// Subscribe to room events. connect() is already called at the provider
|
||||
// level — subscribe/unsubscribe only manage per-room event routing.
|
||||
client.subscribeRoom(activeRoomId).catch(() => {});
|
||||
|
||||
return () => {
|
||||
client.unsubscribeRoom(activeRoomId).catch(() => {});
|
||||
@ -284,26 +271,32 @@ export function RoomProvider({
|
||||
) => {
|
||||
const msgIds = msgs.map((m) => m.id);
|
||||
if (msgIds.length === 0) return;
|
||||
client
|
||||
.reactionListBatch(roomId, msgIds)
|
||||
.then((reactionResults: ReactionListData[]) => {
|
||||
const reactionMap = new Map<string, ReactionListData['reactions']>();
|
||||
for (const result of reactionResults) {
|
||||
if (result.reactions.length > 0) {
|
||||
reactionMap.set(result.message_id, result.reactions);
|
||||
}
|
||||
const doLoad = async () => {
|
||||
let reactionResults: ReactionListData[];
|
||||
if (client.getStatus() === 'open') {
|
||||
try {
|
||||
reactionResults = await client.reactionListBatchWs(roomId, msgIds);
|
||||
} catch {
|
||||
reactionResults = await client.reactionListBatch(roomId, msgIds);
|
||||
}
|
||||
if (reactionMap.size > 0) {
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
reactionMap.has(m.id) ? { ...m, reactions: reactionMap.get(m.id) } : m,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
reactionResults = await client.reactionListBatch(roomId, msgIds);
|
||||
}
|
||||
const reactionMap = new Map<string, ReactionListData['reactions']>();
|
||||
for (const result of reactionResults) {
|
||||
if (result.reactions.length > 0) {
|
||||
reactionMap.set(result.message_id, result.reactions);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Non-fatal: WS push will keep reactions up to date
|
||||
});
|
||||
}
|
||||
if (reactionMap.size > 0) {
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
reactionMap.has(m.id) ? { ...m, reactions: reactionMap.get(m.id) } : m,
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
doLoad().catch(() => {});
|
||||
};
|
||||
|
||||
const loadMore = useCallback(
|
||||
@ -321,56 +314,28 @@ export function RoomProvider({
|
||||
const isInitial = cursor === null || cursor === undefined;
|
||||
const limit = isInitial ? 200 : 50;
|
||||
|
||||
// --- Initial load: try IndexedDB first for instant render ---
|
||||
if (isInitial) {
|
||||
const cached = await loadMessagesFromIdb(activeRoomId);
|
||||
if (cached.length > 0) {
|
||||
setMessages(cached);
|
||||
setIsTransitioningRoom(false);
|
||||
const minSeq = cached[0].seq;
|
||||
setNextCursor(minSeq > 0 ? minSeq - 1 : null);
|
||||
setIsLoadingMore(false);
|
||||
// No API call needed — WS will push any new messages that arrived while away.
|
||||
// Fetch reactions via WS (with HTTP fallback) so reactions appear without extra latency.
|
||||
thisLoadReactions(activeRoomId, client, cached);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Load older history: try IDB first, then fall back to API ---
|
||||
if (!isInitial && cursor != null) {
|
||||
const idbMessages = await loadOlderMessagesFromIdb(activeRoomId, cursor, limit);
|
||||
if (idbMessages.length > 0) {
|
||||
setMessages((prev) => {
|
||||
if (abortController.signal.aborted) return prev;
|
||||
const existingIds = new Set(prev.map((m) => m.id));
|
||||
const filtered = idbMessages.filter((m) => !existingIds.has(m.id));
|
||||
let merged = [...filtered, ...prev];
|
||||
merged.sort((a, b) => a.seq - b.seq);
|
||||
if (merged.length > MAX_MESSAGES_IN_MEMORY) {
|
||||
merged = merged.slice(-MAX_MESSAGES_IN_MEMORY);
|
||||
}
|
||||
return merged;
|
||||
// Try WebSocket first; fall back to HTTP on failure
|
||||
let resp: import('@/lib/room-ws-client').RoomMessageListResponse;
|
||||
if (client.getStatus() === 'open') {
|
||||
try {
|
||||
resp = await client.messageListWs(activeRoomId, {
|
||||
beforeSeq: cursor ?? undefined,
|
||||
limit,
|
||||
});
|
||||
} catch {
|
||||
// WS failed — fall back to HTTP
|
||||
resp = await client.messageList(activeRoomId, {
|
||||
beforeSeq: cursor ?? undefined,
|
||||
limit,
|
||||
});
|
||||
const oldest = idbMessages[0];
|
||||
setNextCursor(oldest.seq > 0 ? oldest.seq - 1 : null);
|
||||
if (idbMessages.length < limit) {
|
||||
setIsHistoryLoaded(true);
|
||||
}
|
||||
setIsLoadingMore(false);
|
||||
// Also fetch reactions for the IDB-loaded history messages.
|
||||
thisLoadReactions(activeRoomId, client, idbMessages);
|
||||
return;
|
||||
}
|
||||
// IDB empty for this range — fall through to API
|
||||
} else {
|
||||
resp = await client.messageList(activeRoomId, {
|
||||
beforeSeq: cursor ?? undefined,
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
// --- API fetch ---
|
||||
const resp = await client.messageList(activeRoomId, {
|
||||
beforeSeq: cursor ?? undefined,
|
||||
limit,
|
||||
});
|
||||
|
||||
if (abortController.signal.aborted) return;
|
||||
|
||||
const newMessages = resp.messages.map((m) => ({
|
||||
@ -396,16 +361,12 @@ export function RoomProvider({
|
||||
return merged;
|
||||
});
|
||||
|
||||
if (newMessages.length > 0) {
|
||||
saveMessages(activeRoomId, newMessages).catch(() => {});
|
||||
}
|
||||
|
||||
if (resp.messages.length < limit) {
|
||||
setIsHistoryLoaded(true);
|
||||
}
|
||||
setNextCursor(resp.messages.length > 0 ? resp.messages[resp.messages.length - 1].seq : null);
|
||||
|
||||
// Fetch reactions for all loaded messages (WS-first with HTTP fallback)
|
||||
// Fetch reactions for all loaded messages
|
||||
thisLoadReactions(activeRoomId, client, newMessages);
|
||||
} catch (error) {
|
||||
if (abortController.signal.aborted) return;
|
||||
@ -434,8 +395,6 @@ export function RoomProvider({
|
||||
// Room AI configs for @ai: mention suggestions
|
||||
const [roomAiConfigs, setRoomAiConfigs] = useState<RoomAiConfig[]>([]);
|
||||
const [aiConfigsLoading, setAiConfigsLoading] = useState(false);
|
||||
// Available models (for looking up AI model names) — TODO: wire up once model sync API is available
|
||||
const [_availableModels, _setAvailableModels] = useState<{ id: string; name: string }[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL ?? window.location.origin;
|
||||
@ -446,37 +405,28 @@ export function RoomProvider({
|
||||
// Use ref to get current activeRoomId to avoid stale closure
|
||||
if (payload.room_id === activeRoomIdRef.current) {
|
||||
setMessages((prev) => {
|
||||
// Check if this is a reaction-update event (same ID, different reactions).
|
||||
// publish_reaction_event sends RoomMessageEvent with reactions field set.
|
||||
const existingIdx = prev.findIndex((m) => m.id === payload.id);
|
||||
if (existingIdx !== -1) {
|
||||
// Message already exists — update reactions if provided.
|
||||
// Reaction events have empty content/sender_type.
|
||||
// Message already exists — update reactions if provided
|
||||
if (payload.reactions !== undefined) {
|
||||
const updated = [...prev];
|
||||
updated[existingIdx] = { ...updated[existingIdx], reactions: payload.reactions };
|
||||
const msg = updated[existingIdx];
|
||||
saveMessage(msg).catch(() => {});
|
||||
return updated;
|
||||
}
|
||||
// Duplicate of a real message — ignore
|
||||
return prev;
|
||||
}
|
||||
// Also check if there's an optimistic message with the same seq that should be replaced
|
||||
// Replace optimistic message with server-confirmed one
|
||||
const optimisticIdx = prev.findIndex(
|
||||
(m) => m.isOptimistic && m.seq === payload.seq && m.seq !== 0,
|
||||
);
|
||||
if (optimisticIdx !== -1) {
|
||||
// Replace optimistic message with confirmed one
|
||||
const confirmed: MessageWithMeta = {
|
||||
...wsMessageToUiMessage(payload),
|
||||
reactions: prev[optimisticIdx].reactions,
|
||||
};
|
||||
const next = [...prev];
|
||||
next[optimisticIdx] = confirmed;
|
||||
// Remove optimistic from IDB, save confirmed
|
||||
deleteMessageFromIdb(prev[optimisticIdx].id).catch(() => {});
|
||||
saveMessage(confirmed).catch(() => {});
|
||||
return next;
|
||||
}
|
||||
const newMsg = wsMessageToUiMessage(payload);
|
||||
@ -487,39 +437,29 @@ export function RoomProvider({
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
// Persist to IndexedDB
|
||||
const msg = wsMessageToUiMessage(payload);
|
||||
saveMessage(msg).catch(() => {});
|
||||
}
|
||||
},
|
||||
onAiStreamChunk: (chunk) => {
|
||||
if (chunk.done) {
|
||||
// When streaming is done, update the message content and remove from streaming
|
||||
setStreamingContent((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(chunk.message_id);
|
||||
return next;
|
||||
});
|
||||
setMessages((prev) => {
|
||||
const updated = prev.map((m) =>
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === chunk.message_id
|
||||
? { ...m, content: chunk.content, is_streaming: false }
|
||||
: m,
|
||||
);
|
||||
// Persist final content to IndexedDB
|
||||
const msg = updated.find((m) => m.id === chunk.message_id);
|
||||
if (msg) saveMessage(msg).catch(() => {});
|
||||
return updated;
|
||||
});
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Accumulate streaming content
|
||||
setStreamingContent((prev) => {
|
||||
const next = new Map(prev);
|
||||
const existing = next.get(chunk.message_id) ?? '';
|
||||
next.set(chunk.message_id, existing + chunk.content);
|
||||
return next;
|
||||
});
|
||||
// Create streaming message placeholder if it doesn't exist
|
||||
setMessages((prev) => {
|
||||
if (prev.some((m) => m.id === chunk.message_id)) {
|
||||
return prev;
|
||||
@ -539,48 +479,34 @@ export function RoomProvider({
|
||||
}
|
||||
},
|
||||
onRoomReactionUpdated: (payload: RoomReactionUpdatedPayload) => {
|
||||
// Guard: ignore events for rooms that are no longer active.
|
||||
// Without this, a WS event arriving after room switch could update
|
||||
// the wrong room's message list (same message ID, different room).
|
||||
if (!activeRoomIdRef.current) return;
|
||||
|
||||
setMessages((prev) => {
|
||||
const existingIdx = prev.findIndex((m) => m.id === payload.message_id);
|
||||
if (existingIdx === -1) return prev;
|
||||
const updated = [...prev];
|
||||
updated[existingIdx] = { ...updated[existingIdx], reactions: payload.reactions };
|
||||
// Persist reaction update to IndexedDB
|
||||
saveMessage(updated[existingIdx]).catch(() => {});
|
||||
return updated;
|
||||
});
|
||||
},
|
||||
onMessageEdited: async (payload) => {
|
||||
// The event only contains message_id and edited_at.
|
||||
// Optimistically update edited_at, then fetch the full message from the API.
|
||||
if (payload.room_id !== activeRoomIdRef.current) return;
|
||||
|
||||
const client = wsClientRef.current;
|
||||
if (!client) return;
|
||||
|
||||
// Capture original edited_at for rollback if fetch fails
|
||||
let rollbackEditedAt: string | null = null;
|
||||
setMessages((prev) => {
|
||||
const msg = prev.find((m) => m.id === payload.message_id);
|
||||
rollbackEditedAt = msg?.edited_at ?? null;
|
||||
const updated = prev.map((m) =>
|
||||
return prev.map((m) =>
|
||||
m.id === payload.message_id ? { ...m, edited_at: payload.edited_at } : m,
|
||||
);
|
||||
const saved = updated.find((m) => m.id === payload.message_id);
|
||||
if (saved) saveMessage(saved).catch(() => {});
|
||||
return updated;
|
||||
});
|
||||
|
||||
// Fetch full updated message from API
|
||||
try {
|
||||
const updatedMsg = await client.messageGet(payload.message_id);
|
||||
if (!updatedMsg) return;
|
||||
setMessages((prev) => {
|
||||
const merged = prev.map((m) =>
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === payload.message_id
|
||||
? {
|
||||
...m,
|
||||
@ -589,14 +515,9 @@ export function RoomProvider({
|
||||
edited_at: payload.edited_at,
|
||||
}
|
||||
: m,
|
||||
);
|
||||
// Persist to IndexedDB
|
||||
const saved = merged.find((m) => m.id === payload.message_id);
|
||||
if (saved) saveMessage(saved).catch(() => {});
|
||||
return merged;
|
||||
});
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
// Revert edited_at if the fetch failed
|
||||
if (rollbackEditedAt !== null) {
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
@ -608,8 +529,8 @@ export function RoomProvider({
|
||||
},
|
||||
onMessageRevoked: async (payload) => {
|
||||
if (payload.room_id !== activeRoomIdRef.current) return;
|
||||
setMessages((prev) => {
|
||||
const updated = prev.map((m) =>
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === payload.message_id
|
||||
? {
|
||||
...m,
|
||||
@ -619,12 +540,8 @@ export function RoomProvider({
|
||||
display_content: '',
|
||||
}
|
||||
: m,
|
||||
);
|
||||
// Persist to IndexedDB
|
||||
const msg = updated.find((m) => m.id === payload.message_id);
|
||||
if (msg) saveMessage(msg).catch(() => {});
|
||||
return updated;
|
||||
});
|
||||
),
|
||||
);
|
||||
},
|
||||
onMessagePinned: async (payload) => {
|
||||
if (payload.room_id !== activeRoomIdRef.current) return;
|
||||
@ -670,18 +587,14 @@ export function RoomProvider({
|
||||
};
|
||||
}, [wsToken]);
|
||||
|
||||
// ── Connect WS whenever a new client is created ─────────────────────────────
|
||||
// Intentionally depends on wsClient (not wsClientRef) so a new client triggers connect().
|
||||
// connect() is idempotent — no-op if already connecting/open.
|
||||
useEffect(() => {
|
||||
// NOTE: intentionally omitted [wsClient] from deps.
|
||||
// In React StrictMode the component mounts twice — if wsClient were a dep,
|
||||
// the first mount's effect would connect client-1, then StrictMode cleanup
|
||||
// would disconnect it, then the second mount's effect would connect client-2,
|
||||
// then immediately the first mount's *second* cleanup would fire and
|
||||
// disconnect client-2 — leaving WS unconnected. Using a ref for the initial
|
||||
// connect avoids this. The client is always ready by the time this runs.
|
||||
wsClientRef.current?.connect().catch((e) => {
|
||||
console.error('[RoomContext] WS connect error:', e);
|
||||
});
|
||||
}, []);
|
||||
}, [wsClient]);
|
||||
|
||||
const connectWs = useCallback(async () => {
|
||||
const client = wsClientRef.current;
|
||||
@ -899,8 +812,6 @@ export function RoomProvider({
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, optimisticMsg]);
|
||||
// Persist optimistic message to IndexedDB so it's not lost on refresh
|
||||
saveMessage(optimisticMsg).catch(() => {});
|
||||
|
||||
try {
|
||||
const confirmedMsg = await client.messageCreate(activeRoomId, content, {
|
||||
@ -918,10 +829,6 @@ export function RoomProvider({
|
||||
isOptimistic: false,
|
||||
reactions: [],
|
||||
};
|
||||
// Remove optimistic from IDB
|
||||
deleteMessageFromIdb(optimisticId).catch(() => {});
|
||||
// Save confirmed to IDB
|
||||
saveMessage(confirmed).catch(() => {});
|
||||
return [...without, confirmed];
|
||||
});
|
||||
} catch (err) {
|
||||
@ -944,7 +851,6 @@ export function RoomProvider({
|
||||
const client = wsClientRef.current;
|
||||
if (!client) return;
|
||||
|
||||
// Capture original content for rollback on server rejection
|
||||
let rollbackContent: string | null = null;
|
||||
setMessages((prev) => {
|
||||
const msg = prev.find((m) => m.id === messageId);
|
||||
@ -955,15 +861,16 @@ export function RoomProvider({
|
||||
});
|
||||
|
||||
try {
|
||||
await client.messageUpdate(messageId, content);
|
||||
// Persist updated content to IndexedDB
|
||||
setMessages((prev) => {
|
||||
const msg = prev.find((m) => m.id === messageId);
|
||||
if (msg) saveMessage(msg).catch(() => {});
|
||||
return prev;
|
||||
});
|
||||
if (client.getStatus() === 'open') {
|
||||
try {
|
||||
await client.messageUpdateWs(messageId, content);
|
||||
} catch {
|
||||
await client.messageUpdate(messageId, content);
|
||||
}
|
||||
} else {
|
||||
await client.messageUpdate(messageId, content);
|
||||
}
|
||||
} catch (err) {
|
||||
// Rollback optimistic update on server rejection
|
||||
if (rollbackContent !== null) {
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
@ -982,7 +889,6 @@ export function RoomProvider({
|
||||
const client = wsClientRef.current;
|
||||
if (!client) return;
|
||||
|
||||
// Optimistic removal: hide message immediately
|
||||
let rollbackMsg: MessageWithMeta | null = null;
|
||||
setMessages((prev) => {
|
||||
rollbackMsg = prev.find((m) => m.id === messageId) ?? null;
|
||||
@ -990,13 +896,18 @@ export function RoomProvider({
|
||||
});
|
||||
|
||||
try {
|
||||
await client.messageRevoke(messageId);
|
||||
deleteMessageFromIdb(messageId).catch(() => {});
|
||||
if (client.getStatus() === 'open') {
|
||||
try {
|
||||
await client.messageRevokeWs(messageId);
|
||||
} catch {
|
||||
await client.messageRevoke(messageId);
|
||||
}
|
||||
} else {
|
||||
await client.messageRevoke(messageId);
|
||||
}
|
||||
} catch (err) {
|
||||
// Rollback: restore message on server rejection
|
||||
if (rollbackMsg) {
|
||||
setMessages((prev) => [...prev, rollbackMsg!]);
|
||||
saveMessage(rollbackMsg!).catch(() => {});
|
||||
}
|
||||
handleRoomError('Delete message', err);
|
||||
}
|
||||
@ -1160,25 +1071,10 @@ export function RoomProvider({
|
||||
}
|
||||
}, [activeRoomId]);
|
||||
|
||||
// Fetch available models (for AI model name lookup)
|
||||
const fetchAvailableModels = useCallback(async () => {
|
||||
try {
|
||||
const resp = await (await import('@/client')).modelList({});
|
||||
const inner = (resp.data as { data?: { data?: { id: string; name: string }[] } } | undefined);
|
||||
_setAvailableModels(inner?.data?.data ?? []);
|
||||
} catch {
|
||||
// Non-fatal
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProjectRepos();
|
||||
}, [fetchProjectRepos]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAvailableModels();
|
||||
}, [fetchAvailableModels]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRoomAiConfigs();
|
||||
}, [fetchRoomAiConfigs]);
|
||||
|
||||
@ -77,6 +77,8 @@ export interface RoomWsCallbacks {
|
||||
onMessageUnpinned?: (payload: import('./ws-protocol').MessageUnpinnedPayload) => void;
|
||||
onStatusChange?: (status: RoomWsStatus) => void;
|
||||
onError?: (error: Error) => void;
|
||||
/** Called each time the client sends a heartbeat ping */
|
||||
onHeartbeat?: () => void;
|
||||
}
|
||||
|
||||
export class RoomWsClient {
|
||||
@ -93,7 +95,11 @@ export class RoomWsClient {
|
||||
private readonly reconnectBaseDelay: number;
|
||||
private readonly reconnectMaxDelay: number;
|
||||
private readonly requestTimeout: number;
|
||||
private readonly heartbeatInterval: number;
|
||||
private readonly heartbeatTimeout: number;
|
||||
private wsToken: string | null = null;
|
||||
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private lastHeartbeat: number = 0;
|
||||
|
||||
constructor(
|
||||
baseUrl: string,
|
||||
@ -102,6 +108,8 @@ export class RoomWsClient {
|
||||
reconnectBaseDelay?: number;
|
||||
reconnectMaxDelay?: number;
|
||||
requestTimeout?: number;
|
||||
heartbeatInterval?: number;
|
||||
heartbeatTimeout?: number;
|
||||
wsToken?: string;
|
||||
} = {},
|
||||
) {
|
||||
@ -110,6 +118,9 @@ export class RoomWsClient {
|
||||
this.reconnectBaseDelay = options.reconnectBaseDelay ?? 1000;
|
||||
this.reconnectMaxDelay = options.reconnectMaxDelay ?? 15000;
|
||||
this.requestTimeout = options.requestTimeout ?? 30_000;
|
||||
// Heartbeat: send a ping every 25s, timeout after 55s of inactivity
|
||||
this.heartbeatInterval = options.heartbeatInterval ?? 25_000;
|
||||
this.heartbeatTimeout = options.heartbeatTimeout ?? 55_000;
|
||||
this.wsToken = options.wsToken ?? null;
|
||||
}
|
||||
|
||||
@ -200,22 +211,21 @@ export class RoomWsClient {
|
||||
console.debug('[RoomWs] Connected');
|
||||
this.reconnectAttempt = 0;
|
||||
this.setStatus('open');
|
||||
this.startHeartbeat();
|
||||
this.resubscribeAll().catch(() => {});
|
||||
resolve();
|
||||
};
|
||||
|
||||
this.ws!.onmessage = (ev: MessageEvent) => {
|
||||
try {
|
||||
const message: WsInMessage = JSON.parse(ev.data);
|
||||
this.handleMessage(message);
|
||||
} catch (e) {
|
||||
console.warn('[RoomWs] parse error:', e);
|
||||
}
|
||||
const text = ev.data;
|
||||
if (typeof text !== 'string') return;
|
||||
this.handleMessage(text);
|
||||
};
|
||||
|
||||
this.ws!.onclose = (ev: CloseEvent) => {
|
||||
clearTimeout(timeoutId);
|
||||
console.debug(`[RoomWs] onclose code=${ev.code} reason=${ev.reason || 'none'} wasClean=${ev.wasClean}`);
|
||||
this.stopHeartbeat();
|
||||
this.ws = null;
|
||||
this.setStatus('closed');
|
||||
for (const [, req] of this.pendingRequests) {
|
||||
@ -241,6 +251,7 @@ export class RoomWsClient {
|
||||
|
||||
disconnect(): void {
|
||||
this.shouldReconnect = false;
|
||||
this.stopHeartbeat();
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
@ -253,6 +264,39 @@ export class RoomWsClient {
|
||||
this.setStatus('closed');
|
||||
}
|
||||
|
||||
private startHeartbeat(): void {
|
||||
this.stopHeartbeat();
|
||||
this.lastHeartbeat = Date.now();
|
||||
this.heartbeatTimer = setInterval(() => {
|
||||
if (this.status !== 'open' || !this.ws) {
|
||||
this.stopHeartbeat();
|
||||
return;
|
||||
}
|
||||
// Detect heartbeat timeout (server died or network dropped)
|
||||
if (Date.now() - this.lastHeartbeat > this.heartbeatTimeout) {
|
||||
console.warn('[RoomWs] Heartbeat timeout — closing connection');
|
||||
this.callbacks.onError?.(new Error('Heartbeat timeout'));
|
||||
this.stopHeartbeat();
|
||||
this.ws.close();
|
||||
return;
|
||||
}
|
||||
// Send application-level ping
|
||||
try {
|
||||
this.ws.send(JSON.stringify({ type: 'ping' }));
|
||||
this.callbacks.onHeartbeat?.();
|
||||
} catch {
|
||||
this.stopHeartbeat();
|
||||
}
|
||||
}, this.heartbeatInterval);
|
||||
}
|
||||
|
||||
private stopHeartbeat(): void {
|
||||
if (this.heartbeatTimer !== null) {
|
||||
clearInterval(this.heartbeatTimer);
|
||||
this.heartbeatTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async request<T = WsResponseData>(action: WsAction, params?: WsRequestParams): Promise<T> {
|
||||
if (this.ws && this.status === 'open') {
|
||||
return await this.requestWs<T>(action, params);
|
||||
@ -486,6 +530,87 @@ export class RoomWsClient {
|
||||
return data || { messages: [], total: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Load messages via WebSocket only. Throws if WS is not connected.
|
||||
* Use this for WS-first loading; falls back to `messageList()` on failure.
|
||||
*/
|
||||
async messageListWs(
|
||||
roomId: string,
|
||||
options?: {
|
||||
beforeSeq?: number;
|
||||
afterSeq?: number;
|
||||
limit?: number;
|
||||
},
|
||||
): Promise<RoomMessageListResponse> {
|
||||
const data = await this.requestWs<RoomMessageListResponse>('message.list', {
|
||||
room_id: roomId,
|
||||
before_seq: options?.beforeSeq,
|
||||
after_seq: options?.afterSeq,
|
||||
limit: options?.limit,
|
||||
});
|
||||
return data || { messages: [], total: 0 };
|
||||
}
|
||||
|
||||
async messageGetWs(messageId: string): Promise<RoomMessageResponse | null> {
|
||||
const data = await this.requestWs<RoomMessageResponse>('message.get', {
|
||||
message_id: messageId,
|
||||
});
|
||||
return data || null;
|
||||
}
|
||||
|
||||
async messageUpdateWs(messageId: string, content: string): Promise<RoomMessageResponse> {
|
||||
return this.requestWs<RoomMessageResponse>('message.update', {
|
||||
message_id: messageId,
|
||||
content,
|
||||
});
|
||||
}
|
||||
|
||||
async messageRevokeWs(messageId: string): Promise<RoomMessageResponse> {
|
||||
return this.requestWs<RoomMessageResponse>('message.revoke', {
|
||||
message_id: messageId,
|
||||
});
|
||||
}
|
||||
|
||||
async messageSearchWs(
|
||||
roomId: string,
|
||||
query: string,
|
||||
options?: { limit?: number; offset?: number },
|
||||
): Promise<SearchResultData> {
|
||||
return this.requestWs<SearchResultData>('message.search', {
|
||||
room_id: roomId,
|
||||
query,
|
||||
limit: options?.limit,
|
||||
offset: options?.offset,
|
||||
});
|
||||
}
|
||||
|
||||
async messageEditHistoryWs(messageId: string): Promise<MessageEditHistoryResponse> {
|
||||
return this.requestWs<MessageEditHistoryResponse>('message.edit_history', {
|
||||
message_id: messageId,
|
||||
});
|
||||
}
|
||||
|
||||
async threadMessagesWs(
|
||||
threadId: string,
|
||||
options?: { beforeSeq?: number; afterSeq?: number; limit?: number },
|
||||
): Promise<RoomMessageListResponse> {
|
||||
const data = await this.requestWs<RoomMessageListResponse>('thread.messages', {
|
||||
thread_id: threadId,
|
||||
before_seq: options?.beforeSeq,
|
||||
after_seq: options?.afterSeq,
|
||||
limit: options?.limit,
|
||||
});
|
||||
return data || { messages: [], total: 0 };
|
||||
}
|
||||
|
||||
async reactionListBatchWs(roomId: string, messageIds: string[]): Promise<ReactionListData[]> {
|
||||
const data = await this.requestWs<ReactionListData[]>('reaction.list_batch', {
|
||||
room_id: roomId,
|
||||
message_ids: messageIds,
|
||||
});
|
||||
return Array.isArray(data) ? data : [];
|
||||
}
|
||||
|
||||
async messageCreate(
|
||||
roomId: string,
|
||||
content: string,
|
||||
@ -827,7 +952,23 @@ export class RoomWsClient {
|
||||
return url;
|
||||
}
|
||||
|
||||
private handleMessage(message: WsInMessage): void {
|
||||
private handleMessage(rawText: string): void {
|
||||
// Handle raw JSON pong before full parsing — resets heartbeat
|
||||
if (rawText.trim() === '{"type":"pong"}') {
|
||||
this.lastHeartbeat = Date.now();
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset heartbeat on any other message (server is alive)
|
||||
this.lastHeartbeat = Date.now();
|
||||
|
||||
let message: WsInMessage;
|
||||
try {
|
||||
message = JSON.parse(rawText);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if ('type' in message && message.type === 'error') {
|
||||
return;
|
||||
}
|
||||
@ -966,6 +1107,8 @@ export function createRoomWsClient(
|
||||
reconnectBaseDelay?: number;
|
||||
reconnectMaxDelay?: number;
|
||||
requestTimeout?: number;
|
||||
heartbeatInterval?: number;
|
||||
heartbeatTimeout?: number;
|
||||
wsToken?: string;
|
||||
},
|
||||
): RoomWsClient {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user