diff --git a/libs/service/project_tools/arxiv.rs b/libs/service/project_tools/arxiv.rs new file mode 100644 index 0000000..f12b04c --- /dev/null +++ b/libs/service/project_tools/arxiv.rs @@ -0,0 +1,227 @@ +//! Tool: project_arxiv_search — search arXiv papers by query + +use agent::{ToolContext, ToolDefinition, ToolError, ToolParam, ToolSchema}; +use serde::Deserialize; +use std::collections::HashMap; + +/// Number of results to return by default. +const DEFAULT_MAX_RESULTS: usize = 10; +const MAX_MAX_RESULTS: usize = 50; + +/// arXiv API base URL (Atom feed). +const ARXIV_API: &str = "http://export.arxiv.org/api/query"; + +/// arXiv Atom feed entry fields we care about. +#[derive(Debug, Deserialize)] +struct ArxivEntry { + #[serde(rename = "id")] + entry_id: String, + #[serde(rename = "title")] + title: String, + #[serde(rename = "summary")] + summary: String, + #[serde(default, rename = "author")] + author: Vec, + #[serde(rename = "published")] + published: String, + #[serde(default, rename = "link")] + link: Vec, +} + +#[derive(Debug, Deserialize)] +struct ArxivAuthor { + #[serde(rename = "name")] + name: String, +} + +#[derive(Debug, Deserialize)] +struct ArxivLink { + #[serde(rename = "type", default)] + link_type: String, + #[serde(rename = "href", default)] + href: String, + #[serde(rename = "title", default)] + title: String, + #[serde(rename = "rel", default)] + rel: String, +} + +#[derive(Debug, Deserialize)] +struct ArxivFeed { + #[serde(default, rename = "entry")] + entry: Vec, +} + +/// Search arXiv papers by query string. +/// +/// Returns up to `max_results` papers (default 10, max 50) matching the query. +/// Each result includes arXiv ID, title, authors, abstract, published date, and PDF URL. +pub async fn arxiv_search_exec( + _ctx: ToolContext, + args: serde_json::Value, +) -> Result { + let query = args + .get("query") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::ExecutionError("query is required".into()))?; + + let max_results = args + .get("max_results") + .and_then(|v| v.as_u64()) + .unwrap_or(DEFAULT_MAX_RESULTS as u64) + .min(MAX_MAX_RESULTS as u64) as usize; + + let start = args + .get("start") + .and_then(|v| v.as_u64()) + .unwrap_or(0) as usize; + + // Build arXiv API query URL + // Encode query for URL + let encoded_query = urlencoding_encode(query); + let url = format!( + "{}?search_query=all:{}&start={}&max_results={}&sortBy=relevance&sortOrder=descending", + ARXIV_API, encoded_query, start, max_results + ); + + let response = reqwest::get(&url) + .await + .map_err(|e| ToolError::ExecutionError(format!("HTTP request failed: {}", e)))?; + + if !response.status().is_success() { + return Err(ToolError::ExecutionError(format!( + "arXiv API returned status {}", + response.status() + ))); + } + + let body = response + .text() + .await + .map_err(|e| ToolError::ExecutionError(format!("Failed to read response: {}", e)))?; + + let feed: ArxivFeed = quick_xml::de::from_str(&body) + .map_err(|e| ToolError::ExecutionError(format!("Failed to parse Atom feed: {}", e)))?; + + let results: Vec = feed + .entry + .into_iter() + .map(|entry| { + // Extract PDF link + let pdf_url = entry + .link + .iter() + .find(|l| l.link_type == "application/pdf") + .map(|l| l.href.clone()) + .or_else(|| { + entry + .link + .iter() + .find(|l| l.rel == "alternate" && l.link_type.is_empty()) + .map(|l| l.href.replace("/abs/", "/pdf/")) + }) + .unwrap_or_default(); + + // Extract arXiv ID from entry id URL + // e.g. http://arxiv.org/abs/2312.12345v1 -> 2312.12345v1 + let arxiv_id = entry + .entry_id + .rsplit('/') + .next() + .unwrap_or(&entry.entry_id) + .trim(); + + // Whitespace-normalize title and abstract + let title = normalize_whitespace(&entry.title); + let summary = normalize_whitespace(&entry.summary); + let author_str = if entry.author.is_empty() { + "Unknown".to_string() + } else { + entry.author.iter().map(|a| a.name.as_str()).collect::>().join(", ") + }; + + serde_json::json!({ + "arxiv_id": arxiv_id, + "title": title, + "authors": author_str, + "abstract": summary, + "published": entry.published, + "pdf_url": pdf_url, + "abs_url": entry.entry_id, + }) + }) + .collect(); + + Ok(serde_json::json!({ + "count": results.len(), + "query": query, + "results": results, + })) +} + +// ─── helpers ─────────────────────────────────────────────────────────────────── + +fn urlencoding_encode(s: &str) -> String { + let mut encoded = String::with_capacity(s.len() * 2); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + encoded.push(b as char); + } + _ => { + encoded.push_str(&format!("%{:02X}", b)); + } + } + } + encoded +} + +fn normalize_whitespace(s: &str) -> String { + let s = s.trim(); + let mut result = String::with_capacity(s.len()); + let mut last_was_space = false; + for c in s.chars() { + if c.is_whitespace() { + if !last_was_space { + result.push(' '); + last_was_space = true; + } + } else { + result.push(c); + last_was_space = false; + } + } + result +} + +// ─── tool definition ───────────────────────────────────────────────────────── + +pub fn tool_definition() -> ToolDefinition { + let mut p = HashMap::new(); + p.insert("query".into(), ToolParam { + name: "query".into(), param_type: "string".into(), + description: Some("Search query (required). Supports arXiv search syntax, e.g. 'ti:transformer AND au:bengio'.".into()), + required: true, properties: None, items: None, + }); + p.insert("max_results".into(), ToolParam { + name: "max_results".into(), param_type: "integer".into(), + description: Some("Maximum number of results to return (default 10, max 50). Optional.".into()), + required: false, properties: None, items: None, + }); + p.insert("start".into(), ToolParam { + name: "start".into(), param_type: "integer".into(), + description: Some("Offset for pagination. Optional.".into()), + required: false, properties: None, items: None, + }); + ToolDefinition::new("project_arxiv_search") + .description( + "Search arXiv papers by keyword or phrase. \ + Returns paper titles, authors, abstracts, arXiv IDs, and PDF URLs. \ + Useful for finding academic papers relevant to the project or a task.", + ) + .parameters(ToolSchema { + schema_type: "object".into(), + properties: Some(p), + required: Some(vec!["query".into()]), + }) +} diff --git a/libs/service/project_tools/boards.rs b/libs/service/project_tools/boards.rs new file mode 100644 index 0000000..dbd9dc9 --- /dev/null +++ b/libs/service/project_tools/boards.rs @@ -0,0 +1,722 @@ +//! Tools: project_list_boards, project_create_board, project_update_board, +//! project_create_board_card, project_update_board_card, project_delete_board_card + +use agent::{ToolContext, ToolDefinition, ToolError, ToolParam, ToolSchema}; +use chrono::Utc; +use models::projects::{ + project_board, project_board_card, project_board_column, project_members, +}; +use models::projects::{MemberRole, ProjectBoard, ProjectBoardCard, ProjectBoardColumn}; +use models::users::user::Model as UserModel; +use sea_orm::*; +use std::collections::HashMap; +use uuid::Uuid; + +// ─── helpers ────────────────────────────────────────────────────────────────── + +/// Check if the sender is an admin or owner of the project. +async fn require_admin( + db: &impl ConnectionTrait, + project_id: Uuid, + sender_id: Uuid, +) -> Result<(), ToolError> { + let member = project_members::Entity::find() + .filter(project_members::Column::Project.eq(project_id)) + .filter(project_members::Column::User.eq(sender_id)) + .one(db) + .await + .map_err(|e| ToolError::ExecutionError(e.to_string()))?; + + let member = member + .ok_or_else(|| ToolError::ExecutionError("You are not a member of this project".into()))?; + let role = member + .scope_role() + .map_err(|_| ToolError::ExecutionError("Unknown member role".into()))?; + + match role { + MemberRole::Admin | MemberRole::Owner => Ok(()), + MemberRole::Member => Err(ToolError::ExecutionError( + "Only admin or owner can perform this action".into(), + )), + } +} + +fn serde_user(u: &UserModel) -> serde_json::Value { + serde_json::json!({ + "id": u.uid.to_string(), + "username": u.username, + "display_name": u.display_name, + }) +} + +// ─── list boards ────────────────────────────────────────────────────────────── + +pub async fn list_boards_exec( + ctx: ToolContext, + _args: serde_json::Value, +) -> Result { + let project_id = ctx.project_id(); + let db = ctx.db(); + + let boards = project_board::Entity::find() + .filter(project_board::Column::Project.eq(project_id)) + .order_by_asc(project_board::Column::CreatedAt) + .all(db) + .await + .map_err(|e| ToolError::ExecutionError(e.to_string()))?; + + let board_ids: Vec<_> = boards.iter().map(|b| b.id).collect(); + + // Batch-load columns and cards + let columns = project_board_column::Entity::find() + .filter(project_board_column::Column::Board.is_in(board_ids.clone())) + .order_by_asc(project_board_column::Column::Position) + .all(db) + .await + .map_err(|e| ToolError::ExecutionError(e.to_string()))?; + + let column_ids: Vec<_> = columns.iter().map(|c| c.id).collect(); + let cards = project_board_card::Entity::find() + .filter(project_board_card::Column::Column.is_in(column_ids.clone())) + .order_by_asc(project_board_card::Column::Position) + .all(db) + .await + .map_err(|e| ToolError::ExecutionError(e.to_string()))?; + + // Build column map + let col_map: std::collections::HashMap> = columns + .clone() + .into_iter() + .map(|c| { + let cards_in_col: Vec = cards + .iter() + .filter(|card| card.column == c.id) + .map(|card| { + serde_json::json!({ + "id": card.id.to_string(), + "issue_id": card.issue_id, + "title": card.title, + "description": card.description, + "position": card.position, + "assignee_id": card.assignee_id.map(|id| id.to_string()), + "due_date": card.due_date.map(|t| t.to_rfc3339()), + "priority": card.priority, + "created_at": card.created_at.to_rfc3339(), + }) + }) + .collect(); + (c.id, cards_in_col) + }) + .collect(); + + // Build column list per board + let mut board_col_map: std::collections::HashMap> = columns + .into_iter() + .fold(std::collections::HashMap::new(), |mut acc, c| { + let cards = col_map.get(&c.id).cloned().unwrap_or_default(); + acc.entry(c.board).or_default().push(serde_json::json!({ + "id": c.id.to_string(), + "name": c.name, + "position": c.position, + "wip_limit": c.wip_limit, + "color": c.color, + "cards": cards, + })); + acc + }); + + let result: Vec<_> = boards + .into_iter() + .map(|b| { + let cols = board_col_map.remove(&b.id).unwrap_or_default(); + serde_json::json!({ + "id": b.id.to_string(), + "name": b.name, + "description": b.description, + "created_by": b.created_by.to_string(), + "created_at": b.created_at.to_rfc3339(), + "updated_at": b.updated_at.to_rfc3339(), + "columns": cols, + }) + }) + .collect(); + + Ok(serde_json::to_value(result).map_err(|e| ToolError::ExecutionError(e.to_string()))?) +} + +// ─── create board ───────────────────────────────────────────────────────────── + +pub async fn create_board_exec( + ctx: ToolContext, + args: serde_json::Value, +) -> Result { + let project_id = ctx.project_id(); + let sender_id = ctx + .sender_id() + .ok_or_else(|| ToolError::ExecutionError("No sender context".into()))?; + let db = ctx.db(); + + require_admin(db, project_id, sender_id).await?; + + let name = args + .get("name") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::ExecutionError("name is required".into()))? + .to_string(); + + let description = args + .get("description") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let now = Utc::now(); + let active = project_board::ActiveModel { + id: Set(Uuid::now_v7()), + project: Set(project_id), + name: Set(name.clone()), + description: Set(description), + created_by: Set(sender_id), + created_at: Set(now), + updated_at: Set(now), + }; + + let model = active + .insert(db) + .await + .map_err(|e| ToolError::ExecutionError(e.to_string()))?; + + Ok(serde_json::json!({ + "id": model.id.to_string(), + "name": model.name, + "description": model.description, + "created_by": model.created_by.to_string(), + "created_at": model.created_at.to_rfc3339(), + "updated_at": model.updated_at.to_rfc3339(), + "columns": Vec::::new(), + })) +} + +// ─── update board ───────────────────────────────────────────────────────────── + +pub async fn update_board_exec( + ctx: ToolContext, + args: serde_json::Value, +) -> Result { + let project_id = ctx.project_id(); + let sender_id = ctx + .sender_id() + .ok_or_else(|| ToolError::ExecutionError("No sender context".into()))?; + let db = ctx.db(); + + require_admin(db, project_id, sender_id).await?; + + let board_id = args + .get("board_id") + .and_then(|v| Uuid::parse_str(v.as_str()?).ok()) + .ok_or_else(|| ToolError::ExecutionError("board_id is required".into()))?; + + let board = ProjectBoard::find_by_id(board_id) + .one(db) + .await + .map_err(|e| ToolError::ExecutionError(e.to_string()))? + .ok_or_else(|| ToolError::ExecutionError("Board not found".into()))?; + + if board.project != project_id { + return Err(ToolError::ExecutionError( + "Board does not belong to this project".into(), + )); + } + + let mut active: project_board::ActiveModel = board.clone().into(); + let mut updated = false; + + if let Some(name) = args.get("name").and_then(|v| v.as_str()) { + active.name = Set(name.to_string()); + updated = true; + } + if let Some(description) = args.get("description") { + active.description = Set(description.as_str().map(|s| s.to_string())); + updated = true; + } + + if !updated { + return Err(ToolError::ExecutionError( + "At least one field must be provided".into(), + )); + } + + active.updated_at = Set(Utc::now()); + let model = active + .update(db) + .await + .map_err(|e| ToolError::ExecutionError(e.to_string()))?; + + Ok(serde_json::json!({ + "id": model.id.to_string(), + "name": model.name, + "description": model.description, + "created_by": model.created_by.to_string(), + "created_at": model.created_at.to_rfc3339(), + "updated_at": model.updated_at.to_rfc3339(), + })) +} + +// ─── create board card ─────────────────────────────────────────────────────── + +pub async fn create_board_card_exec( + ctx: ToolContext, + args: serde_json::Value, +) -> Result { + let project_id = ctx.project_id(); + let sender_id = ctx + .sender_id() + .ok_or_else(|| ToolError::ExecutionError("No sender context".into()))?; + let db = ctx.db(); + + let board_id = args + .get("board_id") + .and_then(|v| Uuid::parse_str(v.as_str()?).ok()) + .ok_or_else(|| ToolError::ExecutionError("board_id is required".into()))?; + + let title = args + .get("title") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::ExecutionError("title is required".into()))? + .to_string(); + + let column_id = args + .get("column_id") + .and_then(|v| Uuid::parse_str(v.as_str()?).ok()); + + let description = args + .get("description") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let priority = args + .get("priority") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let assignee_id = args + .get("assignee_id") + .and_then(|v| Uuid::parse_str(v.as_str()?).ok()); + + // Verify board belongs to project + let board = ProjectBoard::find_by_id(board_id) + .one(db) + .await + .map_err(|e| ToolError::ExecutionError(e.to_string()))? + .ok_or_else(|| ToolError::ExecutionError("Board not found".into()))?; + + if board.project != project_id { + return Err(ToolError::ExecutionError( + "Board does not belong to this project".into(), + )); + } + + // Get target column (first column if not specified) + let target_column = if let Some(col_id) = column_id { + let col = ProjectBoardColumn::find_by_id(col_id) + .one(db) + .await + .map_err(|e| ToolError::ExecutionError(e.to_string()))? + .ok_or_else(|| ToolError::ExecutionError("Column not found".into()))?; + if col.board != board_id { + return Err(ToolError::ExecutionError( + "Column does not belong to this board".into(), + )); + } + col + } else { + ProjectBoardColumn::find() + .filter(project_board_column::Column::Board.eq(board_id)) + .order_by_asc(project_board_column::Column::Position) + .one(db) + .await + .map_err(|e| ToolError::ExecutionError(e.to_string()))? + .ok_or_else(|| ToolError::ExecutionError("No columns found in this board".into()))? + }; + + // Next position + let max_pos: Option> = ProjectBoardCard::find() + .filter(project_board_card::Column::Column.eq(target_column.id)) + .select_only() + .column_as(project_board_card::Column::Position.max(), "max_pos") + .into_tuple::>() + .one(db) + .await + .map_err(|e| ToolError::ExecutionError(e.to_string()))?; + let position = max_pos.flatten().unwrap_or(0) + 1; + + let now = Utc::now(); + let active = project_board_card::ActiveModel { + id: Set(Uuid::now_v7()), + column: Set(target_column.id), + issue_id: Set(None), + project: Set(Some(project_id)), + title: Set(title), + description: Set(description), + position: Set(position), + assignee_id: Set(assignee_id), + due_date: Set(None), + priority: Set(priority), + created_by: Set(sender_id), + created_at: Set(now), + updated_at: Set(now), + }; + + let model = active + .insert(db) + .await + .map_err(|e| ToolError::ExecutionError(e.to_string()))?; + + Ok(serde_json::json!({ + "id": model.id.to_string(), + "column_id": model.column.to_string(), + "title": model.title, + "description": model.description, + "position": model.position, + "assignee_id": model.assignee_id.map(|id| id.to_string()), + "priority": model.priority, + "created_at": model.created_at.to_rfc3339(), + "updated_at": model.updated_at.to_rfc3339(), + })) +} + +// ─── update board card ──────────────────────────────────────────────────────── + +pub async fn update_board_card_exec( + ctx: ToolContext, + args: serde_json::Value, +) -> Result { + let project_id = ctx.project_id(); + let sender_id = ctx + .sender_id() + .ok_or_else(|| ToolError::ExecutionError("No sender context".into()))?; + let db = ctx.db(); + + require_admin(db, project_id, sender_id).await?; + + let card_id = args + .get("card_id") + .and_then(|v| Uuid::parse_str(v.as_str()?).ok()) + .ok_or_else(|| ToolError::ExecutionError("card_id is required".into()))?; + + let card = ProjectBoardCard::find_by_id(card_id) + .one(db) + .await + .map_err(|e| ToolError::ExecutionError(e.to_string()))? + .ok_or_else(|| ToolError::ExecutionError("Card not found".into()))?; + + // Verify card belongs to a column in this project's board + let col = ProjectBoardColumn::find_by_id(card.column) + .one(db) + .await + .map_err(|e| ToolError::ExecutionError(e.to_string()))? + .ok_or_else(|| ToolError::ExecutionError("Column not found".into()))?; + + let board = ProjectBoard::find_by_id(col.board) + .one(db) + .await + .map_err(|e| ToolError::ExecutionError(e.to_string()))? + .ok_or_else(|| ToolError::ExecutionError("Board not found".into()))?; + + if board.project != project_id { + return Err(ToolError::ExecutionError( + "Card does not belong to this project".into(), + )); + } + + let mut active: project_board_card::ActiveModel = card.clone().into(); + let mut updated = false; + + if let Some(title) = args.get("title").and_then(|v| v.as_str()) { + active.title = Set(title.to_string()); + updated = true; + } + if let Some(description) = args.get("description") { + active.description = Set(description.as_str().map(|s| s.to_string())); + updated = true; + } + if let Some(column_id) = args + .get("column_id") + .and_then(|v| Uuid::parse_str(v.as_str()?).ok()) + { + // Verify column belongs to the same board + let new_col = ProjectBoardColumn::find_by_id(column_id) + .one(db) + .await + .map_err(|e| ToolError::ExecutionError(e.to_string()))? + .ok_or_else(|| ToolError::ExecutionError("Column not found".into()))?; + if new_col.board != col.board { + return Err(ToolError::ExecutionError( + "Column does not belong to this board".into(), + )); + } + active.column = Set(column_id); + updated = true; + } + if let Some(position) = args.get("position").and_then(|v| v.as_i64()) { + active.position = Set(position as i32); + updated = true; + } + if let Some(assignee_id) = args.get("assignee_id") { + active.assignee_id = Set(assignee_id.as_str().and_then(|s| Uuid::parse_str(s).ok())); + updated = true; + } + if let Some(priority) = args.get("priority") { + active.priority = Set(priority.as_str().map(|s| s.to_string())); + updated = true; + } + + if !updated { + return Err(ToolError::ExecutionError( + "At least one field must be provided".into(), + )); + } + + active.updated_at = Set(Utc::now()); + let model = active + .update(db) + .await + .map_err(|e| ToolError::ExecutionError(e.to_string()))?; + + Ok(serde_json::json!({ + "id": model.id.to_string(), + "column_id": model.column.to_string(), + "title": model.title, + "description": model.description, + "position": model.position, + "assignee_id": model.assignee_id.map(|id| id.to_string()), + "priority": model.priority, + "created_at": model.created_at.to_rfc3339(), + "updated_at": model.updated_at.to_rfc3339(), + })) +} + +// ─── delete board card ───────────────────────────────────────────────────────── + +pub async fn delete_board_card_exec( + ctx: ToolContext, + args: serde_json::Value, +) -> Result { + let project_id = ctx.project_id(); + let sender_id = ctx + .sender_id() + .ok_or_else(|| ToolError::ExecutionError("No sender context".into()))?; + let db = ctx.db(); + + require_admin(db, project_id, sender_id).await?; + + let card_id = args + .get("card_id") + .and_then(|v| Uuid::parse_str(v.as_str()?).ok()) + .ok_or_else(|| ToolError::ExecutionError("card_id is required".into()))?; + + let card = ProjectBoardCard::find_by_id(card_id) + .one(db) + .await + .map_err(|e| ToolError::ExecutionError(e.to_string()))? + .ok_or_else(|| ToolError::ExecutionError("Card not found".into()))?; + + let col = ProjectBoardColumn::find_by_id(card.column) + .one(db) + .await + .map_err(|e| ToolError::ExecutionError(e.to_string()))? + .ok_or_else(|| ToolError::ExecutionError("Column not found".into()))?; + + let board = ProjectBoard::find_by_id(col.board) + .one(db) + .await + .map_err(|e| ToolError::ExecutionError(e.to_string()))? + .ok_or_else(|| ToolError::ExecutionError("Board not found".into()))?; + + if board.project != project_id { + return Err(ToolError::ExecutionError( + "Card does not belong to this project".into(), + )); + } + + ProjectBoardCard::delete_by_id(card_id) + .exec(db) + .await + .map_err(|e| ToolError::ExecutionError(e.to_string()))?; + + Ok(serde_json::json!({ "deleted": true })) +} + +// ─── tool definitions ───────────────────────────────────────────────────────── + +pub fn list_tool_definition() -> ToolDefinition { + ToolDefinition::new("project_list_boards") + .description( + "List all Kanban boards in the current project. \ + Returns boards with their columns and cards, including positions and priorities.", + ) + .parameters(ToolSchema { + schema_type: "object".into(), + properties: None, + required: None, + }) +} + +pub fn create_board_tool_definition() -> ToolDefinition { + let mut p = HashMap::new(); + p.insert("name".into(), ToolParam { + name: "name".into(), param_type: "string".into(), + description: Some("Board name (required).".into()), + required: true, properties: None, items: None, + }); + p.insert("description".into(), ToolParam { + name: "description".into(), param_type: "string".into(), + description: Some("Board description. Optional.".into()), + required: false, properties: None, items: None, + }); + ToolDefinition::new("project_create_board") + .description( + "Create a new Kanban board in the current project. Requires admin or owner role.", + ) + .parameters(ToolSchema { + schema_type: "object".into(), + properties: Some(p), + required: Some(vec!["name".into()]), + }) +} + +pub fn update_board_tool_definition() -> ToolDefinition { + let mut p = HashMap::new(); + p.insert("board_id".into(), ToolParam { + name: "board_id".into(), param_type: "string".into(), + description: Some("Board UUID (required).".into()), + required: true, properties: None, items: None, + }); + p.insert("name".into(), ToolParam { + name: "name".into(), param_type: "string".into(), + description: Some("New board name. Optional.".into()), + required: false, properties: None, items: None, + }); + p.insert("description".into(), ToolParam { + name: "description".into(), param_type: "string".into(), + description: Some("New board description. Optional.".into()), + required: false, properties: None, items: None, + }); + ToolDefinition::new("project_update_board") + .description( + "Update a Kanban board (name or description). Requires admin or owner role.", + ) + .parameters(ToolSchema { + schema_type: "object".into(), + properties: Some(p), + required: Some(vec!["board_id".into()]), + }) +} + +pub fn create_card_tool_definition() -> ToolDefinition { + let mut p = HashMap::new(); + p.insert("board_id".into(), ToolParam { + name: "board_id".into(), param_type: "string".into(), + description: Some("Board UUID (required).".into()), + required: true, properties: None, items: None, + }); + p.insert("column_id".into(), ToolParam { + name: "column_id".into(), param_type: "string".into(), + description: Some("Column UUID. Optional — defaults to first column.".into()), + required: false, properties: None, items: None, + }); + p.insert("title".into(), ToolParam { + name: "title".into(), param_type: "string".into(), + description: Some("Card title (required).".into()), + required: true, properties: None, items: None, + }); + p.insert("description".into(), ToolParam { + name: "description".into(), param_type: "string".into(), + description: Some("Card description. Optional.".into()), + required: false, properties: None, items: None, + }); + p.insert("priority".into(), ToolParam { + name: "priority".into(), param_type: "string".into(), + description: Some("Card priority (e.g. 'low', 'medium', 'high'). Optional.".into()), + required: false, properties: None, items: None, + }); + p.insert("assignee_id".into(), ToolParam { + name: "assignee_id".into(), param_type: "string".into(), + description: Some("Assignee user UUID. Optional.".into()), + required: false, properties: None, items: None, + }); + ToolDefinition::new("project_create_board_card") + .description( + "Create a card on a Kanban board. If column_id is not provided, \ + the card is added to the first column.", + ) + .parameters(ToolSchema { + schema_type: "object".into(), + properties: Some(p), + required: Some(vec!["board_id".into(), "title".into()]), + }) +} + +pub fn update_card_tool_definition() -> ToolDefinition { + let mut p = HashMap::new(); + p.insert("card_id".into(), ToolParam { + name: "card_id".into(), param_type: "string".into(), + description: Some("Card UUID (required).".into()), + required: true, properties: None, items: None, + }); + p.insert("title".into(), ToolParam { + name: "title".into(), param_type: "string".into(), + description: Some("New card title. Optional.".into()), + required: false, properties: None, items: None, + }); + p.insert("description".into(), ToolParam { + name: "description".into(), param_type: "string".into(), + description: Some("New card description. Optional.".into()), + required: false, properties: None, items: None, + }); + p.insert("column_id".into(), ToolParam { + name: "column_id".into(), param_type: "string".into(), + description: Some("Move card to a different column. Optional.".into()), + required: false, properties: None, items: None, + }); + p.insert("position".into(), ToolParam { + name: "position".into(), param_type: "integer".into(), + description: Some("New position within column. Optional.".into()), + required: false, properties: None, items: None, + }); + p.insert("priority".into(), ToolParam { + name: "priority".into(), param_type: "string".into(), + description: Some("New priority. Optional.".into()), + required: false, properties: None, items: None, + }); + p.insert("assignee_id".into(), ToolParam { + name: "assignee_id".into(), param_type: "string".into(), + description: Some("New assignee UUID. Optional.".into()), + required: false, properties: None, items: None, + }); + ToolDefinition::new("project_update_board_card") + .description( + "Update a board card (title, description, column, position, assignee, priority). \ + Requires admin or owner role.", + ) + .parameters(ToolSchema { + schema_type: "object".into(), + properties: Some(p), + required: Some(vec!["card_id".into()]), + }) +} + +pub fn delete_card_tool_definition() -> ToolDefinition { + let mut p = HashMap::new(); + p.insert("card_id".into(), ToolParam { + name: "card_id".into(), param_type: "string".into(), + description: Some("Card UUID (required).".into()), + required: true, properties: None, items: None, + }); + ToolDefinition::new("project_delete_board_card") + .description("Delete a board card. Requires admin or owner role.") + .parameters(ToolSchema { + schema_type: "object".into(), + properties: Some(p), + required: Some(vec!["card_id".into()]), + }) +} diff --git a/libs/service/project_tools/curl.rs b/libs/service/project_tools/curl.rs new file mode 100644 index 0000000..f2b5be8 --- /dev/null +++ b/libs/service/project_tools/curl.rs @@ -0,0 +1,180 @@ +//! Tool: project_curl — perform HTTP requests (GET/POST/PUT/DELETE) + +use agent::{ToolContext, ToolDefinition, ToolError, ToolParam, ToolSchema}; +use std::collections::HashMap; + +/// Maximum response body size: 1 MB. +const MAX_BODY_BYTES: usize = 1 << 20; + +/// Perform an HTTP request and return the response body and metadata. +/// Supports GET, POST, PUT, DELETE methods. Useful for fetching web pages, +/// calling external APIs, or downloading resources. +pub async fn curl_exec( + _ctx: ToolContext, + args: serde_json::Value, +) -> Result { + let url = args + .get("url") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::ExecutionError("url is required".into()))?; + + let method = args + .get("method") + .and_then(|v| v.as_str()) + .unwrap_or("GET") + .to_uppercase(); + + let body = args.get("body").and_then(|v| v.as_str()).map(String::from); + + let headers: Vec<(String, String)> = args + .get("headers") + .and_then(|v| v.as_object()) + .map(|obj| { + obj.iter() + .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))) + .collect() + }) + .unwrap_or_default(); + + let timeout_secs = args + .get("timeout") + .and_then(|v| v.as_u64()) + .unwrap_or(30) + .min(120); + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(timeout_secs)) + .build() + .map_err(|e| ToolError::ExecutionError(format!("Failed to build HTTP client: {}", e)))?; + + let mut request = match method.as_str() { + "GET" => client.get(url), + "POST" => client.post(url), + "PUT" => client.put(url), + "DELETE" => client.delete(url), + "PATCH" => client.patch(url), + "HEAD" => client.head(url), + _ => { + return Err(ToolError::ExecutionError(format!( + "Unsupported HTTP method: {}. Use GET, POST, PUT, DELETE, PATCH, or HEAD.", + method + ))) + } + }; + + for (key, value) in &headers { + request = request.header(key, value); + } + + // Set default Content-Type for POST/PUT/PATCH if not provided and body exists + if body.is_some() && !headers.iter().any(|(k, _)| k.to_lowercase() == "content-type") { + request = request.header("Content-Type", "application/json"); + } + + if let Some(ref b) = body { + request = request.body(b.clone()); + } + + let response = request + .send() + .await + .map_err(|e| ToolError::ExecutionError(format!("HTTP request failed: {}", e)))?; + + let status = response.status().as_u16(); + let status_text = response.status().canonical_reason().unwrap_or(""); + + let response_headers: std::collections::HashMap = response + .headers() + .iter() + .map(|(k, v)| { + ( + k.to_string(), + v.to_str().unwrap_or("").to_string(), + ) + }) + .collect(); + + let content_type = response + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + + let is_text = content_type.starts_with("text/") + || content_type.contains("json") + || content_type.contains("xml") + || content_type.contains("javascript"); + + let body_bytes = response + .bytes() + .await + .map_err(|e| ToolError::ExecutionError(format!("Failed to read response body: {}", e)))?; + + let body_len = body_bytes.len(); + let truncated = body_len > MAX_BODY_BYTES; + let body_text = if truncated { + String::from("[Response truncated — exceeds 1 MB limit]") + } else if is_text { + String::from_utf8_lossy(&body_bytes).to_string() + } else { + format!( + "[Binary body, {} bytes, Content-Type: {}]", + body_len, content_type + ) + }; + + Ok(serde_json::json!({ + "url": url, + "method": method, + "status": status, + "status_text": status_text, + "headers": response_headers, + "body": body_text, + "truncated": truncated, + "size_bytes": body_len, + })) +} + +// ─── tool definition ───────────────────────────────────────────────────────── + +pub fn tool_definition() -> ToolDefinition { + let mut p = HashMap::new(); + p.insert("url".into(), ToolParam { + name: "url".into(), param_type: "string".into(), + description: Some("Full URL to request (required).".into()), + required: true, properties: None, items: None, + }); + p.insert("method".into(), ToolParam { + name: "method".into(), param_type: "string".into(), + description: Some("HTTP method: GET (default), POST, PUT, DELETE, PATCH, HEAD.".into()), + required: false, properties: None, items: None, + }); + p.insert("body".into(), ToolParam { + name: "body".into(), param_type: "string".into(), + description: Some("Request body. Defaults to 'application/json' Content-Type if provided. Optional.".into()), + required: false, properties: None, items: None, + }); + p.insert("headers".into(), ToolParam { + name: "headers".into(), param_type: "object".into(), + description: Some("HTTP headers as key-value pairs. Optional.".into()), + required: false, properties: None, items: None, + }); + p.insert("timeout".into(), ToolParam { + name: "timeout".into(), param_type: "integer".into(), + description: Some("Request timeout in seconds (default 30, max 120). Optional.".into()), + required: false, properties: None, items: None, + }); + ToolDefinition::new("project_curl") + .description( + "Perform an HTTP request to any URL. Supports GET, POST, PUT, DELETE, PATCH, HEAD. \ + Returns status code, headers, and response body. \ + Response body is truncated at 1 MB. Binary responses are described as text metadata. \ + Useful for fetching web pages, calling APIs, or downloading resources.", + ) + .parameters(ToolSchema { + schema_type: "object".into(), + properties: Some(p), + required: Some(vec!["url".into()]), + }) +} diff --git a/libs/service/project_tools/issues.rs b/libs/service/project_tools/issues.rs new file mode 100644 index 0000000..2c2a0e1 --- /dev/null +++ b/libs/service/project_tools/issues.rs @@ -0,0 +1,535 @@ +//! Tools: project_list_issues, project_create_issue, project_update_issue + +use agent::{ToolContext, ToolDefinition, ToolError, ToolParam, ToolSchema}; +use chrono::Utc; +use models::issues::{issue, issue_assignee, issue_label, Issue, IssueAssignee, IssueLabel, IssueState}; +use models::projects::{MemberRole, ProjectMember}; +use models::projects::project_members; +use models::system::{Label, label}; +use models::users::User; +use sea_orm::*; +use std::collections::HashMap; +use uuid::Uuid; + +// ─── list ───────────────────────────────────────────────────────────────────── + +pub async fn list_issues_exec( + ctx: ToolContext, + args: serde_json::Value, +) -> Result { + let project_id = ctx.project_id(); + let db = ctx.db(); + + let state_filter = args + .get("state") + .and_then(|v| v.as_str()) + .map(|s| s.to_lowercase()); + + let mut query = issue::Entity::find().filter(issue::Column::Project.eq(project_id)); + + if let Some(ref state) = state_filter { + query = query.filter(issue::Column::State.eq(state)); + } + + let issues = query + .order_by_desc(issue::Column::CreatedAt) + .all(db) + .await + .map_err(|e| ToolError::ExecutionError(e.to_string()))?; + + let issue_ids: Vec<_> = issues.iter().map(|i| i.id).collect(); + + let assignees = IssueAssignee::find() + .filter(issue_assignee::Column::Issue.is_in(issue_ids.clone())) + .all(db) + .await + .map_err(|e| ToolError::ExecutionError(e.to_string()))?; + + let assignee_user_ids: Vec<_> = assignees.iter().map(|a| a.user).collect(); + let assignee_users = User::find() + .filter(models::users::user::Column::Uid.is_in(assignee_user_ids)) + .all(db) + .await + .map_err(|e| ToolError::ExecutionError(e.to_string()))?; + let user_map: std::collections::HashMap<_, _> = + assignee_users.into_iter().map(|u| (u.uid, u)).collect(); + + let issue_labels = IssueLabel::find() + .filter(issue_label::Column::Issue.is_in(issue_ids.clone())) + .all(db) + .await + .map_err(|e| ToolError::ExecutionError(e.to_string()))?; + + let label_ids: Vec<_> = issue_labels.iter().map(|l| l.label).collect(); + let labels = Label::find() + .filter(label::Column::Id.is_in(label_ids)) + .all(db) + .await + .map_err(|e| ToolError::ExecutionError(e.to_string()))?; + let label_map: std::collections::HashMap<_, _> = + labels.into_iter().map(|l| (l.id, l)).collect(); + + let assignee_map: std::collections::HashMap<_, Vec<_>> = assignees + .into_iter() + .filter_map(|a| { + let user = user_map.get(&a.user)?; + Some(( + a.issue, + serde_json::json!({ + "id": a.user.to_string(), + "username": user.username, + "display_name": user.display_name, + }), + )) + }) + .fold( + std::collections::HashMap::new(), + |mut acc, (issue_id, user)| { + acc.entry(issue_id).or_default().push(user); + acc + }, + ); + + let issue_label_map: std::collections::HashMap<_, Vec<_>> = issue_labels + .into_iter() + .filter_map(|il| { + let label = label_map.get(&il.label)?; + Some(( + il.issue, + serde_json::json!({ + "id": il.label, + "name": label.name, + "color": label.color, + }), + )) + }) + .fold( + std::collections::HashMap::new(), + |mut acc, (issue_id, label)| { + acc.entry(issue_id).or_default().push(label); + acc + }, + ); + + let result: Vec<_> = issues + .into_iter() + .map(|i| { + serde_json::json!({ + "id": i.id.to_string(), + "number": i.number, + "title": i.title, + "body": i.body, + "state": i.state, + "author_id": i.author.to_string(), + "milestone": i.milestone, + "created_at": i.created_at.to_rfc3339(), + "updated_at": i.updated_at.to_rfc3339(), + "closed_at": i.closed_at.map(|t| t.to_rfc3339()), + "assignees": assignee_map.get(&i.id).unwrap_or(&vec![]), + "labels": issue_label_map.get(&i.id).unwrap_or(&vec![]), + }) + }) + .collect(); + + Ok(serde_json::to_value(result).map_err(|e| ToolError::ExecutionError(e.to_string()))?) +} + +// ─── helpers ─────────────────────────────────────────────────────────────────── + +/// Check if the user is the issue author OR an admin/owner of the project. +async fn require_issue_modifier( + db: &impl ConnectionTrait, + project_id: Uuid, + sender_id: Uuid, + author_id: Uuid, +) -> Result<(), ToolError> { + // Author can always modify their own issue + if sender_id == author_id { + return Ok(()); + } + // Otherwise require admin or owner + let member = ProjectMember::find() + .filter(project_members::Column::Project.eq(project_id)) + .filter(project_members::Column::User.eq(sender_id)) + .one(db) + .await + .map_err(|e| ToolError::ExecutionError(e.to_string()))?; + let member = member + .ok_or_else(|| ToolError::ExecutionError("You are not a member of this project".into()))?; + let role = member + .scope_role() + .map_err(|_| ToolError::ExecutionError("Unknown member role".into()))?; + match role { + MemberRole::Admin | MemberRole::Owner => Ok(()), + MemberRole::Member => Err(ToolError::ExecutionError( + "Only the issue author or admin/owner can modify this issue".into(), + )), + } +} + +// ─── create ─────────────────────────────────────────────────────────────────── + +async fn next_issue_number(db: &impl ConnectionTrait, project_id: Uuid) -> Result { + let max_num: Option> = Issue::find() + .filter(issue::Column::Project.eq(project_id)) + .select_only() + .column_as(issue::Column::Number.max(), "max_num") + .into_tuple::>() + .one(db) + .await + .map_err(|e| ToolError::ExecutionError(e.to_string()))?; + Ok(max_num.flatten().unwrap_or(0) + 1) +} + +pub async fn create_issue_exec( + ctx: ToolContext, + args: serde_json::Value, +) -> Result { + let project_id = ctx.project_id(); + let db = ctx.db(); + + let title = args + .get("title") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::ExecutionError("title is required".into()))? + .to_string(); + + let body = args + .get("body") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let milestone = args + .get("milestone") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let assignee_ids: Vec = args + .get("assignee_ids") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| Uuid::parse_str(v.as_str()?).ok()) + .collect() + }) + .unwrap_or_default(); + + let label_ids: Vec = args + .get("label_ids") + .and_then(|v| v.as_array()) + .map(|arr| arr.iter().filter_map(|v| v.as_i64()).collect()) + .unwrap_or_default(); + + let author_id = ctx + .sender_id() + .ok_or_else(|| ToolError::ExecutionError("No sender context".into()))?; + + let number = next_issue_number(db, project_id).await?; + let now = Utc::now(); + + let active = issue::ActiveModel { + id: Set(Uuid::now_v7()), + project: Set(project_id), + number: Set(number), + title: Set(title.clone()), + body: Set(body), + state: Set(IssueState::Open.to_string()), + author: Set(author_id), + milestone: Set(milestone), + created_at: Set(now), + updated_at: Set(now), + closed_at: Set(None), + created_by_ai: Set(true), + ..Default::default() + }; + + let model = active + .insert(db) + .await + .map_err(|e| ToolError::ExecutionError(e.to_string()))?; + + // Add assignees + for uid in &assignee_ids { + let a = issue_assignee::ActiveModel { + issue: Set(model.id), + user: Set(*uid), + assigned_at: Set(now), + ..Default::default() + }; + let _ = a.insert(db).await; + } + + // Add labels + for lid in &label_ids { + let l = issue_label::ActiveModel { + issue: Set(model.id), + label: Set(*lid), + relation_at: Set(now), + ..Default::default() + }; + let _ = l.insert(db).await; + } + + // Build assignee/label maps for response + let assignee_map: std::collections::HashMap = + if !assignee_ids.is_empty() { + let users = User::find() + .filter(models::users::user::Column::Uid.is_in(assignee_ids.clone())) + .all(db) + .await + .map_err(|e| ToolError::ExecutionError(e.to_string()))?; + users + .into_iter() + .map(|u| { + ( + u.uid, + serde_json::json!({ + "id": u.uid.to_string(), + "username": u.username, + "display_name": u.display_name, + }), + ) + }) + .collect() + } else { + std::collections::HashMap::new() + }; + + let label_map: std::collections::HashMap = if !label_ids.is_empty() { + let labels = Label::find() + .filter(label::Column::Id.is_in(label_ids.clone())) + .all(db) + .await + .map_err(|e| ToolError::ExecutionError(e.to_string()))?; + labels + .into_iter() + .map(|l| { + ( + l.id, + serde_json::json!({ + "id": l.id, + "name": l.name, + "color": l.color, + }), + ) + }) + .collect() + } else { + std::collections::HashMap::new() + }; + + Ok(serde_json::json!({ + "id": model.id.to_string(), + "number": model.number, + "title": model.title, + "body": model.body, + "state": model.state, + "author_id": model.author.to_string(), + "milestone": model.milestone, + "created_at": model.created_at.to_rfc3339(), + "updated_at": model.updated_at.to_rfc3339(), + "assignees": assignee_ids.iter().filter_map(|uid| assignee_map.get(uid)).collect::>(), + "labels": label_ids.iter().filter_map(|lid| label_map.get(lid)).collect::>(), + })) +} + +// ─── update ─────────────────────────────────────────────────────────────────── + +pub async fn update_issue_exec( + ctx: ToolContext, + args: serde_json::Value, +) -> Result { + let project_id = ctx.project_id(); + let sender_id = ctx + .sender_id() + .ok_or_else(|| ToolError::ExecutionError("No sender context".into()))?; + let db = ctx.db(); + + let number = args + .get("number") + .and_then(|v| v.as_i64()) + .ok_or_else(|| ToolError::ExecutionError("number is required".into()))?; + + // Find the issue + let issue = Issue::find() + .filter(issue::Column::Project.eq(project_id)) + .filter(issue::Column::Number.eq(number)) + .one(db) + .await + .map_err(|e| ToolError::ExecutionError(e.to_string()))? + .ok_or_else(|| ToolError::ExecutionError(format!("Issue #{} not found", number)))?; + + // Permission check: author OR admin/owner + require_issue_modifier(db, project_id, sender_id, issue.author).await?; + + let mut active: issue::ActiveModel = issue.clone().into(); + let mut updated = false; + let now = Utc::now(); + + if let Some(title) = args.get("title").and_then(|v| v.as_str()) { + active.title = Set(title.to_string()); + updated = true; + } + if let Some(body) = args.get("body").and_then(|v| v.as_str()) { + active.body = Set(Some(body.to_string())); + updated = true; + } + if let Some(state) = args.get("state").and_then(|v| v.as_str()) { + let s = state.to_lowercase(); + if s == "open" || s == "closed" { + active.state = Set(s.clone()); + active.updated_at = Set(now); + if s == "closed" { + active.closed_at = Set(Some(now)); + } else { + active.closed_at = Set(None); + } + updated = true; + } + } + if let Some(milestone) = args.get("milestone") { + if milestone.is_null() { + active.milestone = Set(None); + } else if let Some(m) = milestone.as_str() { + active.milestone = Set(Some(m.to_string())); + } + updated = true; + } + + if updated { + active.updated_at = Set(now); + active + .update(db) + .await + .map_err(|e| ToolError::ExecutionError(e.to_string()))?; + } + + // Reload for response + let updated_issue = Issue::find() + .filter(issue::Column::Id.eq(issue.id)) + .one(db) + .await + .map_err(|e| ToolError::ExecutionError(e.to_string()))? + .ok_or_else(|| ToolError::ExecutionError("Issue not found after update".into()))?; + + Ok(serde_json::json!({ + "id": updated_issue.id.to_string(), + "number": updated_issue.number, + "title": updated_issue.title, + "body": updated_issue.body, + "state": updated_issue.state, + "author_id": updated_issue.author.to_string(), + "milestone": updated_issue.milestone, + "created_at": updated_issue.created_at.to_rfc3339(), + "updated_at": updated_issue.updated_at.to_rfc3339(), + "closed_at": updated_issue.closed_at.map(|t| t.to_rfc3339()), + })) +} + +// ─── tool definitions ───────────────────────────────────────────────────────── + +pub fn list_tool_definition() -> ToolDefinition { + let mut p = HashMap::new(); + p.insert("state".into(), ToolParam { + name: "state".into(), param_type: "string".into(), + description: Some("Filter by issue state: 'open' or 'closed'. Optional.".into()), + required: false, properties: None, items: None, + }); + ToolDefinition::new("project_list_issues") + .description( + "List all issues in the current project. \ + Returns issue number, title, body, state, author, assignees, labels, and timestamps.", + ) + .parameters(ToolSchema { + schema_type: "object".into(), + properties: Some(p), + required: None, + }) +} + +pub fn create_tool_definition() -> ToolDefinition { + let mut p = HashMap::new(); + p.insert("title".into(), ToolParam { + name: "title".into(), param_type: "string".into(), + description: Some("Issue title (required).".into()), + required: true, properties: None, items: None, + }); + p.insert("body".into(), ToolParam { + name: "body".into(), param_type: "string".into(), + description: Some("Issue body / description. Optional.".into()), + required: false, properties: None, items: None, + }); + p.insert("milestone".into(), ToolParam { + name: "milestone".into(), param_type: "string".into(), + description: Some("Milestone name. Optional.".into()), + required: false, properties: None, items: None, + }); + p.insert("assignee_ids".into(), ToolParam { + name: "assignee_ids".into(), param_type: "array".into(), + description: Some("Array of user UUIDs to assign. Optional.".into()), + required: false, properties: None, + items: Some(Box::new(ToolParam { + name: "".into(), param_type: "string".into(), + description: None, required: false, properties: None, items: None, + })), + }); + p.insert("label_ids".into(), ToolParam { + name: "label_ids".into(), param_type: "array".into(), + description: Some("Array of label IDs to apply. Optional.".into()), + required: false, properties: None, + items: Some(Box::new(ToolParam { + name: "".into(), param_type: "integer".into(), + description: None, required: false, properties: None, items: None, + })), + }); + ToolDefinition::new("project_create_issue") + .description( + "Create a new issue in the current project. \ + Returns the created issue with its number, id, and full details.", + ) + .parameters(ToolSchema { + schema_type: "object".into(), + properties: Some(p), + required: Some(vec!["title".into()]), + }) +} + +pub fn update_tool_definition() -> ToolDefinition { + let mut p = HashMap::new(); + p.insert("number".into(), ToolParam { + name: "number".into(), param_type: "integer".into(), + description: Some("Issue number (required).".into()), + required: true, properties: None, items: None, + }); + p.insert("title".into(), ToolParam { + name: "title".into(), param_type: "string".into(), + description: Some("New issue title. Optional.".into()), + required: false, properties: None, items: None, + }); + p.insert("body".into(), ToolParam { + name: "body".into(), param_type: "string".into(), + description: Some("New issue body. Optional.".into()), + required: false, properties: None, items: None, + }); + p.insert("state".into(), ToolParam { + name: "state".into(), param_type: "string".into(), + description: Some("New issue state: 'open' or 'closed'. Optional.".into()), + required: false, properties: None, items: None, + }); + p.insert("milestone".into(), ToolParam { + name: "milestone".into(), param_type: "string".into(), + description: Some("New milestone name. Set to null to remove. Optional.".into()), + required: false, properties: None, items: None, + }); + ToolDefinition::new("project_update_issue") + .description( + "Update an existing issue in the current project by its number. \ + Requires the issue author or a project admin/owner. \ + Returns the updated issue. At least one field must be provided.", + ) + .parameters(ToolSchema { + schema_type: "object".into(), + properties: Some(p), + required: Some(vec!["number".into()]), + }) +} diff --git a/libs/service/project_tools/members.rs b/libs/service/project_tools/members.rs new file mode 100644 index 0000000..1a6cff1 --- /dev/null +++ b/libs/service/project_tools/members.rs @@ -0,0 +1,64 @@ +//! Tool: project_list_members + +use agent::{ToolContext, ToolDefinition, ToolError, ToolSchema}; +use models::projects::project_members; +use models::users::User; +use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; + +pub async fn list_members_exec( + ctx: ToolContext, + _args: serde_json::Value, +) -> Result { + let project_id = ctx.project_id(); + let db = ctx.db(); + + let members = project_members::Entity::find() + .filter(project_members::Column::Project.eq(project_id)) + .all(db) + .await + .map_err(|e| ToolError::ExecutionError(e.to_string()))?; + + let user_ids: Vec<_> = members.iter().map(|m| m.user).collect(); + let users = User::find() + .filter(models::users::user::Column::Uid.is_in(user_ids)) + .all(db) + .await + .map_err(|e| ToolError::ExecutionError(e.to_string()))?; + + let user_map: std::collections::HashMap<_, _> = + users.into_iter().map(|u| (u.uid, u)).collect(); + + let result: Vec<_> = members + .into_iter() + .filter_map(|m| { + let user = user_map.get(&m.user)?; + let role = m + .scope_role() + .map(|r| r.to_string()) + .unwrap_or_else(|_| "member".to_string()); + Some(serde_json::json!({ + "id": m.user.to_string(), + "username": user.username, + "display_name": user.display_name, + "organization": user.organization, + "role": role, + "joined_at": m.joined_at.to_rfc3339(), + })) + }) + .collect(); + + Ok(serde_json::to_value(result).map_err(|e| ToolError::ExecutionError(e.to_string()))?) +} + +pub fn tool_definition() -> ToolDefinition { + ToolDefinition::new("project_list_members") + .description( + "List all members in the current project. \ + Returns username, display name, organization, role, and join time.", + ) + .parameters(ToolSchema { + schema_type: "object".into(), + properties: None, + required: None, + }) +} diff --git a/libs/service/project_tools/mod.rs b/libs/service/project_tools/mod.rs new file mode 100644 index 0000000..99f6096 --- /dev/null +++ b/libs/service/project_tools/mod.rs @@ -0,0 +1,104 @@ +//! Project tools for AI agent function calling. +//! +//! Tools that let the agent perceive and modify the current project context: +//! - list repos in the project +//! - list members in the project +//! - list / create / update issues +//! - list / create / update boards and board cards + +mod arxiv; +mod boards; +mod curl; +mod issues; +mod members; +mod repos; + +use agent::{ToolHandler, ToolRegistry}; + +pub use arxiv::arxiv_search_exec; +pub use boards::{ + create_board_card_exec, create_board_exec, delete_board_card_exec, list_boards_exec, + update_board_card_exec, update_board_exec, +}; +pub use curl::curl_exec; +pub use issues::{create_issue_exec, list_issues_exec, update_issue_exec}; +pub use members::list_members_exec; +pub use repos::{create_commit_exec, create_repo_exec, list_repos_exec, update_repo_exec}; + +pub fn register_all(registry: &mut ToolRegistry) { + // arxiv + registry.register( + arxiv::tool_definition(), + ToolHandler::new(|ctx, args| Box::pin(arxiv_search_exec(ctx, args))), + ); + + // curl + registry.register( + curl::tool_definition(), + ToolHandler::new(|ctx, args| Box::pin(curl_exec(ctx, args))), + ); + + // repos + registry.register( + repos::list_tool_definition(), + ToolHandler::new(|ctx, args| Box::pin(list_repos_exec(ctx, args))), + ); + registry.register( + repos::create_tool_definition(), + ToolHandler::new(|ctx, args| Box::pin(create_repo_exec(ctx, args))), + ); + registry.register( + repos::update_tool_definition(), + ToolHandler::new(|ctx, args| Box::pin(update_repo_exec(ctx, args))), + ); + registry.register( + repos::create_commit_tool_definition(), + ToolHandler::new(|ctx, args| Box::pin(create_commit_exec(ctx, args))), + ); + + // members + registry.register( + members::tool_definition(), + ToolHandler::new(|ctx, args| Box::pin(list_members_exec(ctx, args))), + ); + + // issues + registry.register( + issues::list_tool_definition(), + ToolHandler::new(|ctx, args| Box::pin(list_issues_exec(ctx, args))), + ); + registry.register( + issues::create_tool_definition(), + ToolHandler::new(|ctx, args| Box::pin(create_issue_exec(ctx, args))), + ); + registry.register( + issues::update_tool_definition(), + ToolHandler::new(|ctx, args| Box::pin(update_issue_exec(ctx, args))), + ); + + // boards + registry.register( + boards::list_tool_definition(), + ToolHandler::new(|ctx, args| Box::pin(list_boards_exec(ctx, args))), + ); + registry.register( + boards::create_board_tool_definition(), + ToolHandler::new(|ctx, args| Box::pin(create_board_exec(ctx, args))), + ); + registry.register( + boards::update_board_tool_definition(), + ToolHandler::new(|ctx, args| Box::pin(update_board_exec(ctx, args))), + ); + registry.register( + boards::create_card_tool_definition(), + ToolHandler::new(|ctx, args| Box::pin(create_board_card_exec(ctx, args))), + ); + registry.register( + boards::update_card_tool_definition(), + ToolHandler::new(|ctx, args| Box::pin(update_board_card_exec(ctx, args))), + ); + registry.register( + boards::delete_card_tool_definition(), + ToolHandler::new(|ctx, args| Box::pin(delete_board_card_exec(ctx, args))), + ); +} diff --git a/libs/service/project_tools/repos.rs b/libs/service/project_tools/repos.rs new file mode 100644 index 0000000..a1ad40d --- /dev/null +++ b/libs/service/project_tools/repos.rs @@ -0,0 +1,559 @@ +//! Tool: project_list_repos, project_create_repo, project_create_commit + +use agent::{ToolContext, ToolDefinition, ToolError, ToolParam, ToolSchema}; +use chrono::Utc; +use git::commit::types::CommitOid; +use git::commit::types::CommitSignature; +use models::projects::{MemberRole, ProjectMember}; +use models::projects::project_members; +use models::repos::repo; +use models::users::user_email; +use sea_orm::*; +use std::collections::HashMap; +use std::path::PathBuf; +use uuid::Uuid; + +// ─── list ───────────────────────────────────────────────────────────────────── + +pub async fn list_repos_exec( + ctx: ToolContext, + _args: serde_json::Value, +) -> Result { + let project_id = ctx.project_id(); + let db = ctx.db(); + + let repos = repo::Entity::find() + .filter(repo::Column::Project.eq(project_id)) + .order_by_asc(repo::Column::RepoName) + .all(db) + .await + .map_err(|e| ToolError::ExecutionError(e.to_string()))?; + + let result: Vec<_> = repos + .into_iter() + .map(|r| { + serde_json::json!({ + "id": r.id.to_string(), + "name": r.repo_name, + "description": r.description, + "default_branch": r.default_branch, + "is_private": r.is_private, + "created_at": r.created_at.to_rfc3339(), + }) + }) + .collect(); + + Ok(serde_json::to_value(result).map_err(|e| ToolError::ExecutionError(e.to_string()))?) +} + +// ─── create ─────────────────────────────────────────────────────────────────── + +pub async fn create_repo_exec( + ctx: ToolContext, + args: serde_json::Value, +) -> Result { + let project_id = ctx.project_id(); + let sender_id = ctx + .sender_id() + .ok_or_else(|| ToolError::ExecutionError("No sender context".into()))?; + let db = ctx.db(); + + // Admin/owner check + let member = ProjectMember::find() + .filter(project_members::Column::Project.eq(project_id)) + .filter(project_members::Column::User.eq(sender_id)) + .one(db) + .await + .map_err(|e| ToolError::ExecutionError(e.to_string()))?; + let member = member + .ok_or_else(|| ToolError::ExecutionError("You are not a member of this project".into()))?; + let role = member + .scope_role() + .map_err(|_| ToolError::ExecutionError("Unknown member role".into()))?; + match role { + MemberRole::Admin | MemberRole::Owner => {} + MemberRole::Member => { + return Err(ToolError::ExecutionError( + "Only admin or owner can create repositories".into(), + )); + } + } + + let repo_name = args + .get("name") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::ExecutionError("name is required".into()))? + .to_string(); + + let description = args + .get("description") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let is_private = args + .get("is_private") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + // Check name uniqueness within project + let existing = repo::Entity::find() + .filter(repo::Column::Project.eq(project_id)) + .filter(repo::Column::RepoName.eq(&repo_name)) + .one(db) + .await + .map_err(|e| ToolError::ExecutionError(e.to_string()))?; + if existing.is_some() { + return Err(ToolError::ExecutionError(format!( + "Repository '{}' already exists in this project", + repo_name + ))); + } + + // Look up project name for storage_path + let project = models::projects::project::Entity::find_by_id(project_id) + .one(db) + .await + .map_err(|e| ToolError::ExecutionError(e.to_string()))? + .ok_or_else(|| ToolError::ExecutionError("Project not found".into()))?; + + let repos_root = ctx + .config() + .repos_root() + .map_err(|e| ToolError::ExecutionError(format!("repos_root config error: {}", e)))?; + + let repo_dir: PathBuf = [&repos_root, &project.name, &format!("{}.git", repo_name)] + .iter() + .collect(); + + let now = Utc::now(); + let active = repo::ActiveModel { + id: Set(Uuid::now_v7()), + repo_name: Set(repo_name.clone()), + project: Set(project_id), + description: Set(description), + default_branch: Set("main".to_string()), + is_private: Set(is_private), + storage_path: Set(repo_dir.to_string_lossy().to_string()), + created_by: Set(sender_id), + created_at: Set(now), + updated_at: Set(now), + ai_code_review_enabled: Set(false), + }; + + let model = active + .insert(db) + .await + .map_err(|e| ToolError::ExecutionError(e.to_string()))?; + + Ok(serde_json::json!({ + "id": model.id.to_string(), + "name": model.repo_name, + "description": model.description, + "default_branch": model.default_branch, + "is_private": model.is_private, + "storage_path": model.storage_path, + "created_at": model.created_at.to_rfc3339(), + })) +} + +// ─── update ─────────────────────────────────────────────────────────────────── + +pub async fn update_repo_exec( + ctx: ToolContext, + args: serde_json::Value, +) -> Result { + let project_id = ctx.project_id(); + let sender_id = ctx + .sender_id() + .ok_or_else(|| ToolError::ExecutionError("No sender context".into()))?; + let db = ctx.db(); + + // Admin/owner check + let member = ProjectMember::find() + .filter(project_members::Column::Project.eq(project_id)) + .filter(project_members::Column::User.eq(sender_id)) + .one(db) + .await + .map_err(|e| ToolError::ExecutionError(e.to_string()))?; + let member = member + .ok_or_else(|| ToolError::ExecutionError("You are not a member of this project".into()))?; + let role = member + .scope_role() + .map_err(|_| ToolError::ExecutionError("Unknown member role".into()))?; + match role { + MemberRole::Admin | MemberRole::Owner => {} + MemberRole::Member => { + return Err(ToolError::ExecutionError( + "Only admin or owner can update repositories".into(), + )); + } + } + + let repo_name = args + .get("name") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::ExecutionError("name is required".into()))? + .to_string(); + + let repo = repo::Entity::find() + .filter(repo::Column::Project.eq(project_id)) + .filter(repo::Column::RepoName.eq(&repo_name)) + .one(db) + .await + .map_err(|e| ToolError::ExecutionError(e.to_string()))? + .ok_or_else(|| { + ToolError::ExecutionError(format!("Repository '{}' not found", repo_name)) + })?; + + let mut active: repo::ActiveModel = repo.clone().into(); + let mut updated = false; + + if let Some(description) = args.get("description") { + active.description = Set(description.as_str().map(|s| s.to_string())); + updated = true; + } + if let Some(is_private) = args.get("is_private").and_then(|v| v.as_bool()) { + active.is_private = Set(is_private); + updated = true; + } + if let Some(default_branch) = args.get("default_branch").and_then(|v| v.as_str()) { + active.default_branch = Set(default_branch.to_string()); + updated = true; + } + + if !updated { + return Err(ToolError::ExecutionError( + "At least one field must be provided".into(), + )); + } + + active.updated_at = Set(Utc::now()); + let model = active + .update(db) + .await + .map_err(|e| ToolError::ExecutionError(e.to_string()))?; + + Ok(serde_json::json!({ + "id": model.id.to_string(), + "name": model.repo_name, + "description": model.description, + "default_branch": model.default_branch, + "is_private": model.is_private, + "created_at": model.created_at.to_rfc3339(), + "updated_at": model.updated_at.to_rfc3339(), + })) +} + +// ─── create commit ──────────────────────────────────────────────────────────── + +pub async fn create_commit_exec( + ctx: ToolContext, + args: serde_json::Value, +) -> Result { + let project_id = ctx.project_id(); + let sender_id = ctx + .sender_id() + .ok_or_else(|| ToolError::ExecutionError("No sender context".into()))?; + let db = ctx.db(); + + // Admin/owner check + let member = ProjectMember::find() + .filter(project_members::Column::Project.eq(project_id)) + .filter(project_members::Column::User.eq(sender_id)) + .one(db) + .await + .map_err(|e| ToolError::ExecutionError(e.to_string()))?; + let member = member + .ok_or_else(|| ToolError::ExecutionError("You are not a member of this project".into()))?; + let role = member + .scope_role() + .map_err(|_| ToolError::ExecutionError("Unknown member role".into()))?; + match role { + MemberRole::Admin | MemberRole::Owner => {} + MemberRole::Member => { + return Err(ToolError::ExecutionError( + "Only admin or owner can create commits".into(), + )); + } + } + + let repo_name = args + .get("repo_name") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::ExecutionError("repo_name is required".into()))?; + + let message = args + .get("message") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::ExecutionError("message is required".into()))? + .to_string(); + + let branch = args + .get("branch") + .and_then(|v| v.as_str()) + .unwrap_or("main") + .to_string(); + + let files = args + .get("files") + .and_then(|v| v.as_array()) + .ok_or_else(|| { + ToolError::ExecutionError("files is required and must be an array".into()) + })?; + + if files.is_empty() { + return Err(ToolError::ExecutionError( + "files array cannot be empty".into(), + )); + } + + // Clone files data for spawn_blocking + let files_data: Vec = files.iter().cloned().collect(); + + // Look up sender username and email + let sender = models::users::user::Entity::find_by_id(sender_id) + .one(db) + .await + .map_err(|e| ToolError::ExecutionError(e.to_string()))? + .ok_or_else(|| ToolError::ExecutionError("Sender user not found".into()))?; + + let sender_email = user_email::Entity::find_by_id(sender_id) + .one(db) + .await + .ok() + .flatten() + .map(|e| e.email) + .unwrap_or_else(|| format!("{}@gitdata.ai", sender.username)); + + let author_name = sender + .display_name + .unwrap_or_else(|| sender.username.clone()); + + // Find repo + 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| ToolError::ExecutionError(e.to_string()))? + .ok_or_else(|| { + ToolError::ExecutionError(format!("Repository '{}' not found", repo_name)) + })?; + + let storage_path = repo_model.storage_path.clone(); + + // Run git operations in a blocking thread + let result = tokio::task::spawn_blocking(move || { + let domain = git::GitDomain::open(&storage_path) + .map_err(|e| ToolError::ExecutionError(format!("Failed to open repo: {}", e)))?; + + let repo = domain.repo(); + + // Get current head commit (parent) + let parent_oid = repo.refname_to_id(&format!("refs/heads/{}", branch)).ok(); + let parent_ids: Vec = parent_oid + .map(|oid| CommitOid::from_git2(oid)) + .into_iter() + .collect(); + + // Build index with new files + let mut index = repo + .index() + .map_err(|e| ToolError::ExecutionError(format!("Failed to get index: {}", e)))?; + + for file in files_data { + let path = file + .get("path") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::ExecutionError("Each file must have a 'path'".into()))?; + let content = file + .get("content") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::ExecutionError("Each file must have 'content'".into()))?; + + let _oid = repo.blob(content.as_bytes()).map_err(|e| { + ToolError::ExecutionError(format!("Failed to write blob for '{}': {}", path, e)) + })?; + + index.add_path(path.as_ref()).map_err(|e| { + ToolError::ExecutionError(format!("Failed to add '{}' to index: {}", path, e)) + })?; + } + + let tree_oid = index + .write_tree() + .map_err(|e| ToolError::ExecutionError(format!("Failed to write tree: {}", e)))?; + + let tree_id = CommitOid::from_git2(tree_oid); + + // Author signature + let author = CommitSignature { + name: author_name.clone(), + email: sender_email.clone(), + time_secs: chrono::Utc::now().timestamp(), + offset_minutes: 0, + }; + + // Committer signature: gitpanda + let committer = CommitSignature { + name: "gitpanda".to_string(), + email: "info@gitdata.ai".to_string(), + time_secs: chrono::Utc::now().timestamp(), + offset_minutes: 0, + }; + + let commit_oid = domain + .commit_create( + Some(&format!("refs/heads/{}", branch)), + &author, + &committer, + &message, + &tree_id, + &parent_ids, + ) + .map_err(|e| ToolError::ExecutionError(format!("Failed to create commit: {}", e)))?; + + Ok::<_, ToolError>(serde_json::json!({ + "commit_oid": commit_oid.to_string(), + "branch": branch, + "message": message, + "author_name": author_name, + "author_email": sender_email, + })) + }) + .await + .map_err(|e| ToolError::ExecutionError(format!("Task join error: {}", e)))?; + + result +} + +// ─── tool definitions ───────────────────────────────────────────────────────── + +pub fn list_tool_definition() -> ToolDefinition { + ToolDefinition::new("project_list_repos") + .description( + "List all repositories in the current project. \ + Returns repo name, description, default branch, privacy status, and creation time.", + ) + .parameters(ToolSchema { + schema_type: "object".into(), + properties: None, + required: None, + }) +} + +pub fn create_tool_definition() -> ToolDefinition { + let mut p = HashMap::new(); + p.insert("name".into(), ToolParam { + name: "name".into(), param_type: "string".into(), + description: Some("Repository name (required). Must be unique within the project.".into()), + required: true, properties: None, items: None, + }); + p.insert("description".into(), ToolParam { + name: "description".into(), param_type: "string".into(), + description: Some("Repository description. Optional.".into()), + required: false, properties: None, items: None, + }); + p.insert("is_private".into(), ToolParam { + name: "is_private".into(), param_type: "boolean".into(), + description: Some("Whether the repo is private. Defaults to false. Optional.".into()), + required: false, properties: None, items: None, + }); + ToolDefinition::new("project_create_repo") + .description( + "Create a new repository in the current project. \ + Requires admin or owner role. \ + The repo is initialized with a bare git structure.", + ) + .parameters(ToolSchema { + schema_type: "object".into(), + properties: Some(p), + required: Some(vec!["name".into()]), + }) +} + +pub fn update_tool_definition() -> ToolDefinition { + let mut p = HashMap::new(); + p.insert("name".into(), ToolParam { + name: "name".into(), param_type: "string".into(), + description: Some("Repository name (required).".into()), + required: true, properties: None, items: None, + }); + p.insert("description".into(), ToolParam { + name: "description".into(), param_type: "string".into(), + description: Some("New repository description. Optional.".into()), + required: false, properties: None, items: None, + }); + p.insert("is_private".into(), ToolParam { + name: "is_private".into(), param_type: "boolean".into(), + description: Some("New privacy setting. Optional.".into()), + required: false, properties: None, items: None, + }); + p.insert("default_branch".into(), ToolParam { + name: "default_branch".into(), param_type: "string".into(), + description: Some("New default branch name. Optional.".into()), + required: false, properties: None, items: None, + }); + ToolDefinition::new("project_update_repo") + .description( + "Update a repository's description, privacy, or default branch. \ + Requires admin or owner role.", + ) + .parameters(ToolSchema { + schema_type: "object".into(), + properties: Some(p), + required: Some(vec!["name".into()]), + }) +} + +pub fn create_commit_tool_definition() -> ToolDefinition { + let mut p = HashMap::new(); + p.insert("repo_name".into(), ToolParam { + name: "repo_name".into(), param_type: "string".into(), + description: Some("Repository name (required).".into()), + required: true, properties: None, items: None, + }); + p.insert("branch".into(), ToolParam { + name: "branch".into(), param_type: "string".into(), + description: Some("Branch to commit to. Defaults to 'main'. Optional.".into()), + required: false, properties: None, items: None, + }); + p.insert("message".into(), ToolParam { + name: "message".into(), param_type: "string".into(), + description: Some("Commit message (required).".into()), + required: true, properties: None, items: None, + }); + // files items + let mut file_item = HashMap::new(); + file_item.insert("path".into(), ToolParam { + name: "path".into(), param_type: "string".into(), + description: Some("File path in the repo (required).".into()), + required: true, properties: None, items: None, + }); + file_item.insert("content".into(), ToolParam { + name: "content".into(), param_type: "string".into(), + description: Some("Full file content as string (required).".into()), + required: true, properties: None, items: None, + }); + p.insert("files".into(), ToolParam { + name: "files".into(), param_type: "array".into(), + description: Some("Array of files to commit (required, non-empty).".into()), + required: true, properties: None, + items: Some(Box::new(ToolParam { + name: "".into(), param_type: "object".into(), + description: None, required: true, properties: Some(file_item), items: None, + })), + }); + ToolDefinition::new("project_create_commit") + .description( + "Create a new commit in a repository. Commits the given files to the specified branch. \ + Requires admin or owner role. \ + Committer is always 'gitpanda '. \ + Author is the sender's display name and email.", + ) + .parameters(ToolSchema { + schema_type: "object".into(), + properties: Some(p), + required: Some(vec!["repo_name".into(), "message".into(), "files".into()]), + }) +} diff --git a/src/components/room/ThemeSwitcher.tsx b/src/components/room/ThemeSwitcher.tsx new file mode 100644 index 0000000..5b6f55e --- /dev/null +++ b/src/components/room/ThemeSwitcher.tsx @@ -0,0 +1,404 @@ +/** + * Theme switcher — preset selection + custom token editor. + * + * Presets: + * Default — Linear / Vercel dual-color (index.css :root / .dark) + * Custom — user-editable palette, stored in localStorage + * + * The panel is opened via the sidebar "Theme" button and presented as a Sheet. + */ + +import { useCallback, useEffect, useState } from 'react'; +import { + applyPaletteToDOM, + clearCustomPalette, + loadActivePresetId, + loadCustomPalette, + resetDOMFromPalette, + saveActivePresetId, + saveCustomPalette, + THEME_PRESETS, +} from './design-system'; +import type { PaletteEntry, ThemePresetId } from './design-system'; +import { Button } from '@/components/ui/button'; +import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; +import { cn } from '@/lib/utils'; +import { useTheme } from '@/contexts'; +import { Check, RotateCcw, Sliders } from 'lucide-react'; + +// ─── Token definitions ─────────────────────────────────────────────────────── + +interface TokenDef { + key: keyof PaletteEntry; + label: string; + group: 'surface' | 'text' | 'accent' | 'border' | 'status' | 'message'; + type: 'color'; +} + +const TOKEN_DEFS: TokenDef[] = [ + // Surface + { key: 'bg', label: 'Background', group: 'surface', type: 'color' }, + { key: 'bgSubtle', label: 'Subtle', group: 'surface', type: 'color' }, + { key: 'bgHover', label: 'Hover', group: 'surface', type: 'color' }, + { key: 'bgActive', label: 'Active', group: 'surface', type: 'color' }, + { key: 'surface', label: 'Surface (card)', group: 'surface', type: 'color' }, + { key: 'surface2', label: 'Surface 2', group: 'surface', type: 'color' }, + { key: 'panelBg', label: 'Panel / Sidebar', group: 'surface', type: 'color' }, + // Text + { key: 'text', label: 'Text', group: 'text', type: 'color' }, + { key: 'textMuted', label: 'Text Muted', group: 'text', type: 'color' }, + { key: 'textSubtle', label: 'Text Subtle', group: 'text', type: 'color' }, + { key: 'icon', label: 'Icon', group: 'text', type: 'color' }, + { key: 'iconHover', label: 'Icon Hover', group: 'text', type: 'color' }, + // Accent + { key: 'accent', label: 'Accent', group: 'accent', type: 'color' }, + { key: 'accentHover', label: 'Accent Hover', group: 'accent', type: 'color' }, + { key: 'accentText', label: 'Accent Text', group: 'accent', type: 'color' }, + { key: 'mentionBg', label: 'Mention BG', group: 'accent', type: 'color' }, + { key: 'mentionText', label: 'Mention Text', group: 'accent', type: 'color' }, + // Border + { key: 'border', label: 'Border', group: 'border', type: 'color' }, + { key: 'borderFocus', label: 'Border Focus', group: 'border', type: 'color' }, + { key: 'borderMuted', label: 'Border Muted', group: 'border', type: 'color' }, + // Status + { key: 'online', label: 'Online', group: 'status', type: 'color' }, + { key: 'away', label: 'Away', group: 'status', type: 'color' }, + { key: 'offline', label: 'Offline', group: 'status', type: 'color' }, + // Message + { key: 'msgBg', label: 'Message BG', group: 'message', type: 'color' }, + { key: 'msgOwnBg', label: 'Own Message BG', group: 'message', type: 'color' }, +]; + +const GROUP_LABELS: Record = { + surface: 'Surface', + text: 'Text & Icon', + accent: 'Accent', + border: 'Border', + status: 'Status', + message: 'Message', +}; + +// ─── Preset Card ───────────────────────────────────────────────────────────── + +function PresetCard({ + preset, + active, + onClick, +}: { + preset: (typeof THEME_PRESETS)[number]; + active: boolean; + onClick: () => void; +}) { + const { resolvedTheme } = useTheme(); + + const previewPalette = preset.palette ?? buildDefaultPreview(resolvedTheme); + + const swatches = [ + previewPalette.bg, + previewPalette.surface, + previewPalette.border, + previewPalette.text, + previewPalette.textMuted, + previewPalette.accent, + previewPalette.accentText, + ]; + + return ( + + ); +} + +function buildDefaultPreview(theme: 'light' | 'dark'): PaletteEntry { + if (theme === 'dark') { + return { + bg: '#1a1a1e', bgSubtle: '#1e1e23', bgHover: '#222228', bgActive: '#2a2a30', + border: '#2e2e35', borderFocus: '#4a9eff', borderMuted: '#252528', + text: '#ececf1', textMuted: '#8a8a92', textSubtle: '#5c5c65', + accent: '#4a9eff', accentHover: '#6aafff', accentText: '#ffffff', + icon: '#7a7a84', iconHover: '#b0b0ba', + surface: '#222228', surface2: '#2a2a30', + online: '#34d399', away: '#fbbf24', offline: '#6b7280', + mentionBg: 'rgba(74,158,255,0.12)', mentionText: '#4a9eff', + msgBg: '#1e1e23', msgOwnBg: '#1a2a3a', panelBg: '#161619', + badgeAi: '', badgeRole: '', + }; + } + return { + bg: '#ffffff', bgSubtle: '#f9f9fa', bgHover: '#f3f3f5', bgActive: '#ebebef', + border: '#e4e4e8', borderFocus: '#1c7ded', borderMuted: '#eeeeef', + text: '#1f1f1f', textMuted: '#8a8a8f', textSubtle: '#b8b8bd', + accent: '#1c7ded', accentHover: '#1a73d4', accentText: '#ffffff', + icon: '#8a8a8f', iconHover: '#5c5c62', + surface: '#f7f7f8', surface2: '#eeeeef', + online: '#22c55e', away: '#f59e0b', offline: '#d1d1d6', + mentionBg: 'rgba(28,125,237,0.08)', mentionText: '#1c7ded', + msgBg: '#f9f9fb', msgOwnBg: '#e8f0fe', panelBg: '#f9f9fa', + badgeAi: '', badgeRole: '', + }; +} + +// ─── Token Editor ───────────────────────────────────────────────────────────── + +function TokenEditor({ + value, + onChange, +}: { + value: PaletteEntry; + onChange: (v: PaletteEntry) => void; +}) { + const groups = (['surface', 'text', 'accent', 'border', 'status', 'message'] as const); + + return ( +
+ {groups.map((group) => { + const defs = TOKEN_DEFS.filter((d) => d.group === group); + return ( +
+

+ {GROUP_LABELS[group]} +

+
+ {defs.map((def) => ( +
+ {/* Swatch + native color picker */} +
+
+ + onChange({ ...value, [def.key]: e.target.value }) + } + className="absolute inset-0 opacity-0 cursor-pointer w-full h-full" + title={def.label} + /> +
+
+
+

{def.label}

+ + onChange({ ...value, [def.key]: e.target.value }) + } + className="w-full bg-transparent border-0 p-0 text-[10px] text-muted-foreground focus:outline-none focus:ring-0 font-mono" + spellCheck={false} + /> +
+
+ ))} +
+
+ ); + })} +
+ ); +} + +// ─── Main component ─────────────────────────────────────────────────────────── + +interface ThemeSwitcherProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function ThemeSwitcher({ open, onOpenChange }: ThemeSwitcherProps) { + const { resolvedTheme } = useTheme(); + + const [activePresetId, setActivePresetId] = useState(loadActivePresetId); + const [customPalette, setCustomPalette] = useState( + loadCustomPalette, + ); + // Working copy being edited + const [draft, setDraft] = useState(null); + const [isDirty, setIsDirty] = useState(false); + + // Reset when panel opens + useEffect(() => { + if (open) { + const id = loadActivePresetId(); + setActivePresetId(id); + setCustomPalette(loadCustomPalette()); + setDraft(id === 'custom' && loadCustomPalette() ? { ...loadCustomPalette()! } : null); + setIsDirty(false); + } + }, [open]); + + const applyPreset = useCallback( + (presetId: ThemePresetId) => { + setActivePresetId(presetId); + saveActivePresetId(presetId); + if (presetId === 'custom') { + const stored = loadCustomPalette(); + setCustomPalette(stored); + setDraft(stored ? { ...stored } : null); + if (stored) applyPaletteToDOM(stored); + } else { + clearCustomPalette(); + setCustomPalette(null); + setDraft(null); + resetDOMFromPalette(); + } + setIsDirty(false); + }, + [], + ); + + const handleDraftChange = useCallback((next: PaletteEntry) => { + setDraft(next); + setIsDirty(true); + }, []); + + const handleApplyCustom = useCallback(() => { + if (!draft) return; + saveCustomPalette(draft); + setCustomPalette(draft); + applyPaletteToDOM(draft); + setActivePresetId('custom'); + saveActivePresetId('custom'); + setIsDirty(false); + }, [draft]); + + const handleReset = useCallback(() => { + applyPreset('default'); + }, [applyPreset]); + + return ( + + + + + + Theme Settings + + + + {/* ── Scrollable content with padding ─────────────────────────────── */} +
+ + {/* ── Preset grid ─────────────────────────────────────────────────── */} +
+

+ Presets +

+
+ {THEME_PRESETS.map((preset) => ( + applyPreset(preset.id as ThemePresetId)} + /> + ))} + {/* Custom preset card — always shown */} + +
+
+ + {/* ── Custom token editor ─────────────────────────────────────────── */} + {activePresetId === 'custom' && draft && ( +
+
+

+ Token Editor +

+
+ + +
+
+ +
+ )} +
+
+
+ ); +}