feat: add project tools (repos, issues, boards, arxiv, curl, members) and ThemeSwitcher component

This commit is contained in:
ZhenYi 2026-04-20 19:31:44 +08:00
parent d4b0a9ae67
commit 4d5c62e46a
8 changed files with 2795 additions and 0 deletions

View File

@ -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<ArxivAuthor>,
#[serde(rename = "published")]
published: String,
#[serde(default, rename = "link")]
link: Vec<ArxivLink>,
}
#[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<ArxivEntry>,
}
/// 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<serde_json::Value, ToolError> {
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<serde_json::Value> = 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::<Vec<_>>().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()]),
})
}

View File

@ -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<serde_json::Value, ToolError> {
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<Uuid, Vec<serde_json::Value>> = columns
.clone()
.into_iter()
.map(|c| {
let cards_in_col: Vec<serde_json::Value> = 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<Uuid, Vec<serde_json::Value>> = 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<serde_json::Value, ToolError> {
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::<serde_json::Value>::new(),
}))
}
// ─── update board ─────────────────────────────────────────────────────────────
pub async fn update_board_exec(
ctx: ToolContext,
args: serde_json::Value,
) -> Result<serde_json::Value, ToolError> {
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<serde_json::Value, ToolError> {
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<Option<i32>> = 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::<Option<i32>>()
.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<serde_json::Value, ToolError> {
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<serde_json::Value, ToolError> {
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()]),
})
}

View File

@ -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<serde_json::Value, ToolError> {
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<String, String> = response
.headers()
.iter()
.map(|(k, v)| {
(
k.to_string(),
v.to_str().unwrap_or("<binary>").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()]),
})
}

View File

@ -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<serde_json::Value, ToolError> {
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<i64, ToolError> {
let max_num: Option<Option<i64>> = Issue::find()
.filter(issue::Column::Project.eq(project_id))
.select_only()
.column_as(issue::Column::Number.max(), "max_num")
.into_tuple::<Option<i64>>()
.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<serde_json::Value, ToolError> {
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<Uuid> = 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<i64> = 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<Uuid, serde_json::Value> =
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<i64, serde_json::Value> = 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::<Vec<_>>(),
"labels": label_ids.iter().filter_map(|lid| label_map.get(lid)).collect::<Vec<_>>(),
}))
}
// ─── update ───────────────────────────────────────────────────────────────────
pub async fn update_issue_exec(
ctx: ToolContext,
args: serde_json::Value,
) -> Result<serde_json::Value, ToolError> {
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()]),
})
}

View File

@ -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<serde_json::Value, ToolError> {
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,
})
}

View File

@ -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))),
);
}

View File

@ -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<serde_json::Value, ToolError> {
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<serde_json::Value, ToolError> {
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<serde_json::Value, ToolError> {
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<serde_json::Value, ToolError> {
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<serde_json::Value> = 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<CommitOid> = 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 <info@gitdata.ai>
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 <info@gitdata.ai>'. \
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()]),
})
}

View File

@ -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<TokenDef['group'], string> = {
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 (
<button
type="button"
onClick={onClick}
className={cn(
'flex flex-col gap-2 rounded-lg border p-3 text-left transition-all w-full',
'hover:border-border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
active ? 'border-primary ring-1 ring-primary' : 'border-border',
)}
>
{/* Color swatches */}
<div className="flex gap-1">
{swatches.map((color, i) => (
<div
key={i}
className="h-6 flex-1 rounded-sm"
style={{ background: color }}
/>
))}
</div>
{/* Label */}
<div className="flex items-center justify-between">
<span className="text-xs font-medium">{preset.label}</span>
{active && <Check className="h-3.5 w-3.5 text-primary" />}
</div>
<p className="text-[10px] text-muted-foreground leading-tight">
{preset.description}
</p>
</button>
);
}
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 (
<div className="space-y-4">
{groups.map((group) => {
const defs = TOKEN_DEFS.filter((d) => d.group === group);
return (
<div key={group}>
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">
{GROUP_LABELS[group]}
</p>
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
{defs.map((def) => (
<div key={def.key} className="flex items-center gap-2">
{/* Swatch + native color picker */}
<div className="relative shrink-0">
<div
className="h-7 w-7 rounded border cursor-pointer overflow-hidden"
style={{ background: value[def.key] as string }}
>
<input
type="color"
value={(value[def.key] as string).startsWith('#')
? (value[def.key] as string)
: '#888888'}
onChange={(e) =>
onChange({ ...value, [def.key]: e.target.value })
}
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full"
title={def.label}
/>
</div>
</div>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium truncate">{def.label}</p>
<input
type="text"
value={value[def.key] as string}
onChange={(e) =>
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}
/>
</div>
</div>
))}
</div>
</div>
);
})}
</div>
);
}
// ─── Main component ───────────────────────────────────────────────────────────
interface ThemeSwitcherProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function ThemeSwitcher({ open, onOpenChange }: ThemeSwitcherProps) {
const { resolvedTheme } = useTheme();
const [activePresetId, setActivePresetId] = useState<ThemePresetId>(loadActivePresetId);
const [customPalette, setCustomPalette] = useState<PaletteEntry | null>(
loadCustomPalette,
);
// Working copy being edited
const [draft, setDraft] = useState<PaletteEntry | null>(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 (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex flex-col overflow-y-auto w-[360px] sm:max-w-[360px]">
<SheetHeader>
<SheetTitle className="flex items-center gap-2">
<Sliders className="h-4 w-4" />
Theme Settings
</SheetTitle>
</SheetHeader>
{/* ── Scrollable content with padding ─────────────────────────────── */}
<div className="flex flex-col gap-5 px-5 pb-5 overflow-y-auto">
{/* ── Preset grid ─────────────────────────────────────────────────── */}
<div className="mt-6">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-3">
Presets
</p>
<div className="grid grid-cols-1 gap-2">
{THEME_PRESETS.map((preset) => (
<PresetCard
key={preset.id}
preset={preset}
active={activePresetId === preset.id}
onClick={() => applyPreset(preset.id as ThemePresetId)}
/>
))}
{/* Custom preset card — always shown */}
<button
type="button"
onClick={() => {
// Switch to custom, seed draft from current effective palette
const seed = activePresetId === 'custom' && customPalette
? { ...customPalette }
: { ...buildDefaultPreview(resolvedTheme) };
setDraft(seed);
setActivePresetId('custom');
setIsDirty(false);
if (activePresetId !== 'custom') {
saveActivePresetId('custom');
}
}}
className={cn(
'flex flex-col gap-2 rounded-lg border p-3 text-left transition-all',
'hover:border-border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
activePresetId === 'custom'
? 'border-primary ring-1 ring-primary'
: 'border-border border-dashed',
)}
>
{/* Mini palette swatches */}
<div className="flex gap-1">
{['#ffffff', '#f9f9fa', '#e4e4e8', '#1f1f1f', '#8a8a8f', '#1c7ded', '#ffffff'].map(
(c, i) => (
<div key={i} className="h-6 flex-1 rounded-sm" style={{ background: c }} />
),
)}
</div>
<div className="flex items-center justify-between">
<span className="text-xs font-medium">Custom</span>
{activePresetId === 'custom' && (
<Check className="h-3.5 w-3.5 text-primary" />
)}
</div>
<p className="text-[10px] text-muted-foreground leading-tight">
Define your own colors
</p>
</button>
</div>
</div>
{/* ── Custom token editor ─────────────────────────────────────────── */}
{activePresetId === 'custom' && draft && (
<div className="border-t pt-5">
<div className="flex items-center justify-between mb-4">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Token Editor
</p>
<div className="flex gap-1">
<Button
variant="outline"
size="sm"
className="h-7 gap-1 text-xs"
onClick={handleReset}
>
<RotateCcw className="h-3 w-3" />
Reset
</Button>
<Button
size="sm"
className="h-7 gap-1 text-xs"
onClick={handleApplyCustom}
disabled={!isDirty}
>
Apply
</Button>
</div>
</div>
<TokenEditor value={draft} onChange={handleDraftChange} />
</div>
)}
</div>
</SheetContent>
</Sheet>
);
}