Compare commits
11 Commits
d4b0a9ae67
...
43e2d26ea2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43e2d26ea2 | ||
|
|
e43d9fc8bf | ||
|
|
7736869fc4 | ||
|
|
ce29eb3062 | ||
|
|
3eeb054452 | ||
|
|
33a4a5c6c9 | ||
|
|
b23c6a03c3 | ||
|
|
dee79f3f7f | ||
|
|
a0ab16e6ea | ||
|
|
4e955d9ae3 | ||
|
|
4d5c62e46a |
3
Cargo.lock
generated
3
Cargo.lock
generated
@ -5861,6 +5861,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -6405,6 +6406,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"async-openai",
|
||||
"chrono",
|
||||
"config",
|
||||
"dashmap",
|
||||
"db",
|
||||
"deadpool-redis",
|
||||
@ -7156,6 +7158,7 @@ dependencies = [
|
||||
"http 1.4.0",
|
||||
"jwt-simple",
|
||||
"lopdf",
|
||||
"mime_guess2",
|
||||
"models",
|
||||
"moka",
|
||||
"p256",
|
||||
|
||||
@ -147,7 +147,7 @@ calamine = "0.26"
|
||||
csv = "1.3"
|
||||
lopdf = "0.34"
|
||||
pulldown-cmark = "0.12"
|
||||
quick-xml = "0.37"
|
||||
quick-xml = { version = "0.37", features = ["serialize"] }
|
||||
sqlparser = "0.55"
|
||||
lazy_static = "1.5"
|
||||
md5 = "0.7"
|
||||
|
||||
@ -1,7 +1,138 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { query } from "@/lib/db";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({ status: "ok" }, { status: 200 });
|
||||
let migrationDone = false;
|
||||
|
||||
async function ensureTables() {
|
||||
if (migrationDone) return;
|
||||
migrationDone = true;
|
||||
|
||||
console.log("[Health] Checking database tables...");
|
||||
|
||||
// 1. admin_audit_log
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS admin_audit_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
username VARCHAR(255) NOT NULL,
|
||||
action VARCHAR(50) NOT NULL,
|
||||
resource VARCHAR(255) NOT NULL,
|
||||
resource_id VARCHAR(255),
|
||||
request_params JSONB,
|
||||
ip_address VARCHAR(255),
|
||||
user_agent TEXT,
|
||||
result VARCHAR(20) NOT NULL DEFAULT 'success',
|
||||
error_message TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
|
||||
// 2. admin_user
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS admin_user (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(255) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
|
||||
// 3. admin_role
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS admin_role (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) UNIQUE NOT NULL,
|
||||
description TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
|
||||
// 4. admin_permission
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS admin_permission (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
code VARCHAR(255) UNIQUE NOT NULL,
|
||||
description TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
|
||||
// 5. admin_user_role
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS admin_user_role (
|
||||
user_id INTEGER NOT NULL REFERENCES admin_user(id) ON DELETE CASCADE,
|
||||
role_id INTEGER NOT NULL REFERENCES admin_role(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (user_id, role_id)
|
||||
)
|
||||
`);
|
||||
|
||||
// 6. admin_role_permission
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS admin_role_permission (
|
||||
role_id INTEGER NOT NULL REFERENCES admin_role(id) ON DELETE CASCADE,
|
||||
permission_id INTEGER NOT NULL REFERENCES admin_permission(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (role_id, permission_id)
|
||||
)
|
||||
`);
|
||||
|
||||
// 索引
|
||||
await query(`CREATE INDEX IF NOT EXISTS idx_admin_user_username ON admin_user(username)`);
|
||||
await query(`CREATE INDEX IF NOT EXISTS idx_admin_audit_log_user_id ON admin_audit_log(user_id)`);
|
||||
await query(`CREATE INDEX IF NOT EXISTS idx_admin_audit_log_created_at ON admin_audit_log(created_at DESC)`);
|
||||
await query(`CREATE INDEX IF NOT EXISTS idx_admin_audit_log_action ON admin_audit_log(action)`);
|
||||
await query(`CREATE INDEX IF NOT EXISTS idx_admin_audit_log_resource ON admin_audit_log(resource)`);
|
||||
|
||||
// Seed data
|
||||
await query(`
|
||||
INSERT INTO admin_permission (name, code, description) VALUES
|
||||
('用户管理', 'user:read', '查看用户列表'),
|
||||
('用户创建', 'user:create', '创建管理员用户'),
|
||||
('用户更新', 'user:update', '更新管理员用户'),
|
||||
('用户删除', 'user:delete', '删除管理员用户'),
|
||||
('角色管理', 'role:read', '查看角色'),
|
||||
('角色创建', 'role:create', '创建角色'),
|
||||
('角色更新', 'role:update', '更新角色'),
|
||||
('角色删除', 'role:delete', '删除角色'),
|
||||
('权限管理', 'permission:read', '查看权限'),
|
||||
('权限创建', 'permission:create', '创建权限'),
|
||||
('权限更新', 'permission:update', '更新权限'),
|
||||
('权限删除', 'permission:delete', '删除权限'),
|
||||
('日志查看', 'log:read', '查看审计日志'),
|
||||
('会话管理', 'session:manage', '管理在线用户会话'),
|
||||
('平台数据', 'platform:read', '查看平台数据'),
|
||||
('平台管理', 'platform:manage', '管理平台数据')
|
||||
ON CONFLICT (code) DO NOTHING
|
||||
`);
|
||||
|
||||
await query(`
|
||||
INSERT INTO admin_role (name, description) VALUES
|
||||
('超级管理员', '拥有所有权限')
|
||||
ON CONFLICT (name) DO NOTHING
|
||||
`);
|
||||
|
||||
await query(`
|
||||
INSERT INTO admin_role_permission (role_id, permission_id)
|
||||
SELECT r.id, p.id
|
||||
FROM admin_role r
|
||||
CROSS JOIN admin_permission p
|
||||
WHERE r.name = '超级管理员'
|
||||
ON CONFLICT DO NOTHING
|
||||
`);
|
||||
|
||||
console.log("[Health] Database tables ready");
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await ensureTables();
|
||||
return NextResponse.json({ status: "ok" }, { status: 200 });
|
||||
} catch (e) {
|
||||
console.error("[Health] DB check failed:", e);
|
||||
return NextResponse.json({ status: "error", reason: String(e) }, { status: 503 });
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@ use models::projects::project;
|
||||
use models::repos::repo;
|
||||
use models::rooms::{room, room_message};
|
||||
use models::users::user;
|
||||
use config::AppConfig;
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
@ -29,6 +30,7 @@ pub type StreamCallback = Box<
|
||||
pub struct AiRequest {
|
||||
pub db: AppDatabase,
|
||||
pub cache: AppCache,
|
||||
pub config: AppConfig,
|
||||
pub model: model::Model,
|
||||
pub project: project::Model,
|
||||
pub sender: user::Model,
|
||||
|
||||
@ -321,13 +321,6 @@ impl ChatService {
|
||||
);
|
||||
|
||||
if has_tool_calls && tools_enabled {
|
||||
// Send final text chunk
|
||||
on_chunk(AiStreamChunk {
|
||||
content: text_accumulated.clone(),
|
||||
done: true,
|
||||
})
|
||||
.await;
|
||||
|
||||
// Build ToolCall list from accumulated chunks
|
||||
let tool_calls: Vec<_> = tool_call_chunks
|
||||
.into_iter()
|
||||
@ -340,12 +333,23 @@ impl ChatService {
|
||||
.collect();
|
||||
|
||||
if !tool_calls.is_empty() {
|
||||
// Capture thinking text, send it as a completed chunk, then clear for the next turn
|
||||
let thinking_text = text_accumulated.clone();
|
||||
if !thinking_text.is_empty() {
|
||||
on_chunk(AiStreamChunk {
|
||||
content: thinking_text.clone(),
|
||||
done: true,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
text_accumulated.clear();
|
||||
|
||||
// Append assistant message with tool calls to history
|
||||
messages.push(ChatCompletionRequestMessage::Assistant(
|
||||
ChatCompletionRequestAssistantMessage {
|
||||
content: Some(
|
||||
ChatCompletionRequestAssistantMessageContent::Text(
|
||||
text_accumulated,
|
||||
thinking_text,
|
||||
),
|
||||
),
|
||||
name: None,
|
||||
@ -400,6 +404,7 @@ impl ChatService {
|
||||
let mut ctx = ToolContext::new(
|
||||
request.db.clone(),
|
||||
request.cache.clone(),
|
||||
request.config.clone(),
|
||||
request.room.id,
|
||||
Some(request.sender.uid),
|
||||
)
|
||||
|
||||
@ -7,6 +7,7 @@ use std::sync::Arc;
|
||||
|
||||
use db::cache::AppCache;
|
||||
use db::database::AppDatabase;
|
||||
use config::AppConfig;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::registry::ToolRegistry;
|
||||
@ -21,6 +22,7 @@ pub struct ToolContext {
|
||||
struct Inner {
|
||||
pub db: AppDatabase,
|
||||
pub cache: AppCache,
|
||||
pub config: AppConfig,
|
||||
pub room_id: Uuid,
|
||||
pub sender_id: Option<Uuid>,
|
||||
pub project_id: Uuid,
|
||||
@ -32,11 +34,18 @@ struct Inner {
|
||||
}
|
||||
|
||||
impl ToolContext {
|
||||
pub fn new(db: AppDatabase, cache: AppCache, room_id: Uuid, sender_id: Option<Uuid>) -> Self {
|
||||
pub fn new(
|
||||
db: AppDatabase,
|
||||
cache: AppCache,
|
||||
config: AppConfig,
|
||||
room_id: Uuid,
|
||||
sender_id: Option<Uuid>,
|
||||
) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Inner {
|
||||
db,
|
||||
cache,
|
||||
config,
|
||||
room_id,
|
||||
sender_id,
|
||||
project_id: Uuid::nil(),
|
||||
@ -111,6 +120,11 @@ impl ToolContext {
|
||||
&self.inner.cache
|
||||
}
|
||||
|
||||
/// Application config.
|
||||
pub fn config(&self) -> &AppConfig {
|
||||
&self.inner.config
|
||||
}
|
||||
|
||||
/// Room where the original message was sent.
|
||||
pub fn room_id(&self) -> Uuid {
|
||||
self.inner.room_id
|
||||
|
||||
@ -179,6 +179,10 @@ pub fn init_room_routes(cfg: &mut web::ServiceConfig) {
|
||||
.route(
|
||||
"/rooms/{room_id}/upload",
|
||||
web::post().to(upload::upload),
|
||||
)
|
||||
.route(
|
||||
"/rooms/{room_id}/attachments/{attachment_id}",
|
||||
web::get().to(upload::get_attachment),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -189,9 +189,18 @@ pub async fn message_search(
|
||||
.user()
|
||||
.ok_or_else(|| ApiError::from(service::error::AppError::Unauthorized))?;
|
||||
let ctx = WsUserContext::new(user_id);
|
||||
let req = room::types::RoomMessageSearchRequest {
|
||||
q: query.q.clone(),
|
||||
start_time: None,
|
||||
end_time: None,
|
||||
sender_id: None,
|
||||
content_type: None,
|
||||
limit: query.limit,
|
||||
offset: query.offset,
|
||||
};
|
||||
let resp = service
|
||||
.room
|
||||
.message_search(room_id, &query.q, query.limit, query.offset, &ctx)
|
||||
.room_message_search(room_id, req, &ctx)
|
||||
.await
|
||||
.map_err(ApiError::from)?;
|
||||
Ok(ApiResponse::ok(resp).to_response())
|
||||
|
||||
@ -1,13 +1,17 @@
|
||||
use actix_multipart::Multipart;
|
||||
use actix_web::{HttpResponse, Result, web};
|
||||
use actix_web::http::header::{CONTENT_DISPOSITION, CONTENT_TYPE};
|
||||
use chrono::Utc;
|
||||
use futures_util::StreamExt;
|
||||
use models::rooms::room_attachment;
|
||||
use sea_orm::{ActiveModelTrait, EntityTrait, Set};
|
||||
use service::AppService;
|
||||
use session::Session;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
|
||||
pub struct UploadResponse {
|
||||
pub id: String,
|
||||
pub url: String,
|
||||
pub file_name: String,
|
||||
pub file_size: i64,
|
||||
@ -130,11 +134,104 @@ pub async fn upload(
|
||||
))
|
||||
})?;
|
||||
|
||||
// Write to room_attachment table (message will be linked when message is created)
|
||||
let attachment_id = Uuid::now_v7();
|
||||
let attachment: room_attachment::ActiveModel = room_attachment::ActiveModel {
|
||||
id: Set(attachment_id),
|
||||
room: Set(room_id),
|
||||
message: Set(None),
|
||||
uploader: Set(user_id),
|
||||
file_name: Set(file_name.clone()),
|
||||
file_size: Set(file_size),
|
||||
content_type: Set(content_type.clone()),
|
||||
s3_key: Set(key),
|
||||
created_at: Set(Utc::now()),
|
||||
};
|
||||
attachment
|
||||
.insert(&service.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
crate::error::ApiError(service::error::AppError::InternalServerError(
|
||||
e.to_string(),
|
||||
))
|
||||
})?;
|
||||
|
||||
// Return the structured attachment URL instead of the /files/... path
|
||||
// (the /files/... path has no handler on the API server)
|
||||
let attachment_url = format!("/api/rooms/{}/attachments/{}", room_id, attachment_id);
|
||||
|
||||
Ok(crate::ApiResponse::ok(UploadResponse {
|
||||
url,
|
||||
id: attachment_id.to_string(),
|
||||
url: attachment_url,
|
||||
file_name,
|
||||
file_size,
|
||||
content_type,
|
||||
})
|
||||
.to_response())
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/rooms/{room_id}/attachments/{attachment_id}",
|
||||
params(
|
||||
("room_id" = Uuid, Path, description = "Room ID"),
|
||||
("attachment_id" = Uuid, Path, description = "Attachment ID"),
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Download file"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Not a room member"),
|
||||
(status = 404, description = "Not found"),
|
||||
),
|
||||
tag = "Room"
|
||||
)]
|
||||
pub async fn get_attachment(
|
||||
service: web::Data<AppService>,
|
||||
session: Session,
|
||||
path: web::Path<(Uuid, Uuid)>,
|
||||
) -> Result<HttpResponse, crate::error::ApiError> {
|
||||
let user_id = session
|
||||
.user()
|
||||
.ok_or_else(|| crate::error::ApiError(service::error::AppError::Unauthorized))?;
|
||||
|
||||
let (room_id, attachment_id) = path.into_inner();
|
||||
|
||||
service
|
||||
.room
|
||||
.require_room_member(room_id, user_id)
|
||||
.await
|
||||
.map_err(crate::error::ApiError::from)?;
|
||||
|
||||
let attachment = room_attachment::Entity::find_by_id(attachment_id)
|
||||
.one(&service.db)
|
||||
.await
|
||||
.map_err(|e| crate::error::ApiError(service::error::AppError::InternalServerError(e.to_string())))?
|
||||
.ok_or_else(|| crate::error::ApiError(service::error::AppError::NotFound))?;
|
||||
|
||||
// Ensure the attachment belongs to the requested room
|
||||
if attachment.room != room_id {
|
||||
return Err(crate::error::ApiError(service::error::AppError::NotFound));
|
||||
}
|
||||
|
||||
let storage = service
|
||||
.storage
|
||||
.as_ref()
|
||||
.ok_or_else(|| crate::error::ApiError(service::error::AppError::InternalServerError("Storage not configured".to_string())))?;
|
||||
|
||||
let (data, content_type) = storage
|
||||
.read(&attachment.s3_key)
|
||||
.await
|
||||
.map_err(|e| crate::error::ApiError(service::error::AppError::InternalServerError(e.to_string())))?;
|
||||
|
||||
HttpResponse::Ok()
|
||||
.content_type(content_type)
|
||||
.insert_header((
|
||||
CONTENT_TYPE,
|
||||
content_type,
|
||||
))
|
||||
.insert_header((
|
||||
CONTENT_DISPOSITION,
|
||||
format!("inline; filename=\"{}\"", attachment.file_name),
|
||||
))
|
||||
.body(data)
|
||||
}
|
||||
|
||||
@ -222,6 +222,7 @@ impl WsRequestHandler {
|
||||
content_type: params.content_type.clone(),
|
||||
thread: params.thread_id,
|
||||
in_reply_to: params.in_reply_to,
|
||||
attachment_ids: params.attachment_ids.clone().unwrap_or_default(),
|
||||
},
|
||||
&ctx,
|
||||
)
|
||||
|
||||
@ -210,6 +210,7 @@ pub struct WsRequestParams {
|
||||
pub stream: Option<bool>,
|
||||
pub min_score: Option<f32>,
|
||||
pub query: Option<String>,
|
||||
pub attachment_ids: Option<Vec<Uuid>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
|
||||
@ -22,6 +22,7 @@ db = { workspace = true }
|
||||
session = { workspace = true }
|
||||
queue = { workspace = true }
|
||||
agent = { path = "../agent" }
|
||||
config = { path = "../config" }
|
||||
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@ -74,6 +74,7 @@ impl From<room_message::Model> for super::RoomMessageResponse {
|
||||
revoked_by: value.revoked_by,
|
||||
in_reply_to: value.in_reply_to,
|
||||
highlighted_content: None,
|
||||
attachment_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -431,6 +432,7 @@ impl RoomService {
|
||||
revoked_by: msg.revoked_by,
|
||||
in_reply_to: msg.in_reply_to,
|
||||
highlighted_content: None,
|
||||
attachment_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,10 +2,10 @@ use crate::error::RoomError;
|
||||
use crate::service::RoomService;
|
||||
use crate::ws_context::WsUserContext;
|
||||
use chrono::Utc;
|
||||
use models::rooms::{room, room_message, room_thread};
|
||||
use models::rooms::{room, room_attachment, room_message, room_thread};
|
||||
use models::users::user as user_model;
|
||||
use queue::RoomMessageEnvelope;
|
||||
use sea_orm::*;
|
||||
use sea_orm::{sea_query::Expr, *};
|
||||
use serde_json;
|
||||
use uuid::Uuid;
|
||||
|
||||
@ -97,11 +97,34 @@ impl RoomService {
|
||||
revoked: msg.revoked,
|
||||
revoked_by: msg.revoked_by,
|
||||
highlighted_content: None,
|
||||
attachment_ids: Vec::new(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
messages.reverse();
|
||||
|
||||
// Batch-load attachment IDs for all returned messages
|
||||
if !messages.is_empty() {
|
||||
let msg_ids: Vec<Uuid> = messages.iter().map(|m| m.id).collect();
|
||||
let attachments = room_attachment::Entity::find()
|
||||
.filter(room_attachment::Column::Message.is_in(msg_ids))
|
||||
.all(&self.db)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut attachment_map: std::collections::HashMap<Uuid, Vec<Uuid>> =
|
||||
std::collections::HashMap::new();
|
||||
for att in attachments {
|
||||
attachment_map.entry(att.message).or_default().push(att.id);
|
||||
}
|
||||
|
||||
for msg in &mut messages {
|
||||
if let Some(ids) = attachment_map.remove(&msg.id) {
|
||||
msg.attachment_ids = ids;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(super::RoomMessageListResponse { messages, total })
|
||||
}
|
||||
|
||||
@ -198,6 +221,27 @@ impl RoomService {
|
||||
|
||||
txn.commit().await?;
|
||||
|
||||
// Link uploaded attachments to this message
|
||||
let attachment_ids = request.attachment_ids.clone();
|
||||
if !attachment_ids.is_empty() {
|
||||
if let Err(e) = room_attachment::Entity::update_many()
|
||||
.col_expr(
|
||||
room_attachment::Column::Message,
|
||||
Expr::value(Some(id)),
|
||||
)
|
||||
.filter(room_attachment::Column::Id.is_in(attachment_ids.clone()))
|
||||
.exec(&self.db)
|
||||
.await
|
||||
{
|
||||
slog::warn!(
|
||||
self.log,
|
||||
"Failed to link attachments to message {}: {}",
|
||||
id,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
self.publish_room_event(
|
||||
project_id,
|
||||
super::RoomEventType::NewMessage,
|
||||
@ -278,6 +322,7 @@ impl RoomService {
|
||||
revoked: None,
|
||||
revoked_by: None,
|
||||
highlighted_content: None,
|
||||
attachment_ids,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -326,6 +326,7 @@ impl RoomService {
|
||||
revoked: msg.revoked,
|
||||
revoked_by: msg.revoked_by,
|
||||
highlighted_content: None,
|
||||
attachment_ids: Vec::new(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
|
||||
@ -53,6 +53,7 @@ static MENTION_BRACKET_RE: LazyLock<regex_lite::Regex, fn() -> regex_lite::Regex
|
||||
pub struct RoomService {
|
||||
pub db: AppDatabase,
|
||||
pub cache: AppCache,
|
||||
pub config: config::AppConfig,
|
||||
pub room_manager: Arc<RoomConnectionManager>,
|
||||
pub queue: MessageProducer,
|
||||
pub redis_url: String,
|
||||
@ -68,6 +69,7 @@ impl RoomService {
|
||||
pub fn new(
|
||||
db: AppDatabase,
|
||||
cache: AppCache,
|
||||
config: config::AppConfig,
|
||||
queue: MessageProducer,
|
||||
room_manager: Arc<RoomConnectionManager>,
|
||||
redis_url: String,
|
||||
@ -82,6 +84,7 @@ impl RoomService {
|
||||
Self {
|
||||
db,
|
||||
cache,
|
||||
config,
|
||||
room_manager,
|
||||
queue,
|
||||
redis_url,
|
||||
@ -898,6 +901,7 @@ impl RoomService {
|
||||
let request = AiRequest {
|
||||
db: self.db.clone(),
|
||||
cache: self.cache.clone(),
|
||||
config: self.config.clone(),
|
||||
model,
|
||||
project: project.clone(),
|
||||
sender,
|
||||
@ -913,7 +917,7 @@ impl RoomService {
|
||||
presence_penalty: 0.0,
|
||||
think: ai_config.think,
|
||||
tools: Some(chat_service.tools()),
|
||||
max_tool_depth: 3,
|
||||
max_tool_depth: 1000,
|
||||
};
|
||||
|
||||
let use_streaming = ai_config.stream;
|
||||
|
||||
@ -185,6 +185,8 @@ pub struct RoomMessageCreateRequest {
|
||||
#[serde(rename = "thread_id")]
|
||||
pub thread: Option<Uuid>,
|
||||
pub in_reply_to: Option<Uuid>,
|
||||
#[serde(default)]
|
||||
pub attachment_ids: Vec<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)]
|
||||
@ -222,6 +224,8 @@ pub struct RoomMessageResponse {
|
||||
/// Highlighted content with <mark> tags around matched terms (for search results)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub highlighted_content: Option<String>,
|
||||
#[serde(default)]
|
||||
pub attachment_ids: Vec<Uuid>,
|
||||
}
|
||||
|
||||
/// Search result wrapper (keeps API compatibility)
|
||||
|
||||
@ -61,6 +61,7 @@ rust_decimal = { workspace = true }
|
||||
calamine = { workspace = true }
|
||||
csv = { workspace = true }
|
||||
quick-xml = { workspace = true }
|
||||
mime_guess2 = { workspace = true }
|
||||
lopdf = { workspace = true }
|
||||
pulldown-cmark = { workspace = true }
|
||||
sqlparser = { workspace = true }
|
||||
|
||||
@ -231,6 +231,7 @@ impl AppService {
|
||||
let mut registry = ToolRegistry::new();
|
||||
git_tools::register_all(&mut registry);
|
||||
file_tools::register_all(&mut registry);
|
||||
project_tools::register_all(&mut registry);
|
||||
Some(Arc::new(
|
||||
ChatService::new(client).with_tool_registry(registry),
|
||||
))
|
||||
@ -289,6 +290,7 @@ impl AppService {
|
||||
let room = RoomService::new(
|
||||
db.clone(),
|
||||
cache.clone(),
|
||||
config.clone(),
|
||||
message_producer.clone(),
|
||||
room_manager,
|
||||
redis_url,
|
||||
@ -362,6 +364,7 @@ pub mod git;
|
||||
pub mod git_tools;
|
||||
pub mod issue;
|
||||
pub mod project;
|
||||
pub mod project_tools;
|
||||
pub mod pull_request;
|
||||
pub mod search;
|
||||
pub mod skill;
|
||||
|
||||
227
libs/service/project_tools/arxiv.rs
Normal file
227
libs/service/project_tools/arxiv.rs
Normal 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()]),
|
||||
})
|
||||
}
|
||||
722
libs/service/project_tools/boards.rs
Normal file
722
libs/service/project_tools/boards.rs
Normal 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()]),
|
||||
})
|
||||
}
|
||||
180
libs/service/project_tools/curl.rs
Normal file
180
libs/service/project_tools/curl.rs
Normal 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()]),
|
||||
})
|
||||
}
|
||||
535
libs/service/project_tools/issues.rs
Normal file
535
libs/service/project_tools/issues.rs
Normal 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()]),
|
||||
})
|
||||
}
|
||||
64
libs/service/project_tools/members.rs
Normal file
64
libs/service/project_tools/members.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
104
libs/service/project_tools/mod.rs
Normal file
104
libs/service/project_tools/mod.rs
Normal 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))),
|
||||
);
|
||||
}
|
||||
559
libs/service/project_tools/repos.rs
Normal file
559
libs/service/project_tools/repos.rs
Normal 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()]),
|
||||
})
|
||||
}
|
||||
@ -57,4 +57,14 @@ impl AppStorage {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read a file by key and return (bytes, content_type).
|
||||
pub async fn read(&self, key: &str) -> anyhow::Result<(Vec<u8>, String)> {
|
||||
let path = self.base_path.join(key);
|
||||
let data = tokio::fs::read(&path).await?;
|
||||
let content_type = mime_guess2::from_path(&path)
|
||||
.first_or_octet_stream()
|
||||
.to_string();
|
||||
Ok((data, content_type))
|
||||
}
|
||||
}
|
||||
|
||||
@ -29,6 +29,7 @@ function ProjectRoomInner() {
|
||||
members,
|
||||
} = useRoom();
|
||||
|
||||
const [showChannelSidebar, setShowChannelSidebar] = useState(true);
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
@ -119,16 +120,18 @@ function ProjectRoomInner() {
|
||||
return (
|
||||
<div className="discord-layout">
|
||||
{/* Channel sidebar */}
|
||||
<DiscordChannelSidebar
|
||||
projectName={projectName}
|
||||
rooms={rooms}
|
||||
selectedRoomId={activeRoomId}
|
||||
onSelectRoom={handleSelectRoom}
|
||||
onCreateRoom={handleOpenCreate}
|
||||
categories={categories.map((c) => ({ id: c.id, name: c.name }))}
|
||||
onCreateCategory={handleCreateCategory}
|
||||
onMoveRoomToCategory={handleMoveRoomToCategory}
|
||||
/>
|
||||
{showChannelSidebar && (
|
||||
<DiscordChannelSidebar
|
||||
projectName={projectName}
|
||||
rooms={rooms}
|
||||
selectedRoomId={activeRoomId}
|
||||
onSelectRoom={handleSelectRoom}
|
||||
onCreateRoom={handleOpenCreate}
|
||||
categories={categories.map((c) => ({ id: c.id, name: c.name }))}
|
||||
onCreateCategory={handleCreateCategory}
|
||||
onMoveRoomToCategory={handleMoveRoomToCategory}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main chat area */}
|
||||
{activeRoom ? (
|
||||
@ -136,6 +139,8 @@ function ProjectRoomInner() {
|
||||
room={activeRoom}
|
||||
isAdmin={isAdmin}
|
||||
onClose={handleClose}
|
||||
onToggleChannelSidebar={() => setShowChannelSidebar((v) => !v)}
|
||||
channelSidebarOpen={showChannelSidebar}
|
||||
onDelete={() => setDeleteDialogOpen(true)}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@ -4434,6 +4434,7 @@ export type RoomMessageCreateRequest = {
|
||||
content_type?: string | null;
|
||||
thread_id?: string | null;
|
||||
in_reply_to?: string | null;
|
||||
attachment_ids?: string[];
|
||||
};
|
||||
|
||||
export type RoomMessageListResponse = {
|
||||
@ -4456,6 +4457,7 @@ export type RoomMessageResponse = {
|
||||
send_at: string;
|
||||
revoked?: string | null;
|
||||
revoked_by?: string | null;
|
||||
attachment_ids?: string[];
|
||||
};
|
||||
|
||||
export type RoomMessageUpdateRequest = {
|
||||
|
||||
@ -10,32 +10,47 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {BookOpen, Box, ChevronDown, Compass, Home, LayoutGrid, Monitor, Moon, Plus, Sun, Users} from 'lucide-react';
|
||||
import {
|
||||
BookOpen,
|
||||
Box,
|
||||
ChevronDown,
|
||||
Compass,
|
||||
Home,
|
||||
LayoutGrid,
|
||||
Monitor,
|
||||
Moon,
|
||||
Plus,
|
||||
Sliders,
|
||||
Sun,
|
||||
Users
|
||||
} from 'lucide-react';
|
||||
import {useNavigate} from 'react-router-dom';
|
||||
import {Avatar, AvatarFallback, AvatarImage} from '@/components/ui/avatar';
|
||||
import {useState} from 'react';
|
||||
import {ThemeSwitcher} from '@/components/room/ThemeSwitcher';
|
||||
|
||||
const btnClass = 'flex w-full h-9 justify-start items-center rounded-md font-medium hover:bg-muted cursor-pointer bg-transparent border-0 text-left text-sm';
|
||||
|
||||
export function SidebarSystem({collapsed}: {collapsed: boolean}) {
|
||||
export function SidebarSystem({collapsed}: { collapsed: boolean }) {
|
||||
const {theme, setTheme} = useTheme();
|
||||
const navigate = useNavigate();
|
||||
const workspaceCtx = tryUseWorkspace();
|
||||
const workspaces = workspaceCtx?.workspaces;
|
||||
const currentWorkspace = workspaceCtx?.currentWorkspace;
|
||||
const [themeSheetOpen, setThemeSheetOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{/* Workspace switcher — only shown when inside WorkspaceProvider */}
|
||||
{workspaceCtx && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className={cn(btnClass, collapsed ? 'justify-center px-0' : 'px-2')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className={cn(btnClass, collapsed ? 'justify-center px-0' : 'px-2')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span className={cn('flex h-6 items-center shrink-0', collapsed ? 'w-6 justify-center' : 'w-6')}>
|
||||
{currentWorkspace ? (
|
||||
<Avatar className="h-4 w-4">
|
||||
@ -48,70 +63,74 @@ export function SidebarSystem({collapsed}: {collapsed: boolean}) {
|
||||
<LayoutGrid className="h-4 w-4"/>
|
||||
)}
|
||||
</span>
|
||||
{!collapsed && (
|
||||
<span className="flex-1 truncate text-sm leading-none">
|
||||
{!collapsed && (
|
||||
<span className="flex-1 truncate text-sm leading-none">
|
||||
{currentWorkspace?.name || 'Workspaces'}
|
||||
</span>
|
||||
)}
|
||||
{!collapsed && <ChevronDown className="h-3 w-3 ml-auto shrink-0"/>}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="right" align="start" className="w-56">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>Workspaces</DropdownMenuLabel>
|
||||
{workspaces?.workspaces.map((ws) => (
|
||||
<DropdownMenuItem
|
||||
key={ws.id}
|
||||
onClick={() => navigate(`/w/${ws.slug}`)}
|
||||
className="gap-2"
|
||||
>
|
||||
<Avatar className="h-5 w-5">
|
||||
<AvatarImage src={ws.avatar_url || ''}/>
|
||||
<AvatarFallback className="text-[9px]">
|
||||
{ws.name.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="flex-1 truncate">{ws.name}</span>
|
||||
<span className="text-xs text-muted-foreground">@{ws.slug}</span>
|
||||
)}
|
||||
{!collapsed && <ChevronDown className="h-3 w-3 ml-auto shrink-0"/>}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="right" align="start" className="w-56">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>Workspaces</DropdownMenuLabel>
|
||||
{workspaces?.workspaces.map((ws) => (
|
||||
<DropdownMenuItem
|
||||
key={ws.id}
|
||||
onClick={() => navigate(`/w/${ws.slug}`)}
|
||||
className="gap-2"
|
||||
>
|
||||
<Avatar className="h-5 w-5">
|
||||
<AvatarImage src={ws.avatar_url || ''}/>
|
||||
<AvatarFallback className="text-[9px]">
|
||||
{ws.name.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="flex-1 truncate">{ws.name}</span>
|
||||
<span className="text-xs text-muted-foreground">@{ws.slug}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator/>
|
||||
<DropdownMenuItem onClick={() => navigate('/w/me')} className="gap-2">
|
||||
<Users className="h-4 w-4"/>
|
||||
<span>All Workspaces</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator/>
|
||||
<DropdownMenuItem onClick={() => navigate('/w/me')} className="gap-2">
|
||||
<Users className="h-4 w-4"/>
|
||||
<span>All Workspaces</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => navigate('/init/workspace')} className="gap-2">
|
||||
<Plus className="h-4 w-4"/>
|
||||
<span>Create Workspace</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenuItem onClick={() => navigate('/init/workspace')} className="gap-2">
|
||||
<Plus className="h-4 w-4"/>
|
||||
<span>Create Workspace</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
<button type="button" className={cn(btnClass, collapsed ? 'justify-center px-0' : 'px-2')} onClick={() => navigate('/')}>
|
||||
<span className={cn('flex h-6 items-center shrink-0', collapsed ? 'w-6 justify-center' : 'w-6')}>
|
||||
<button type="button" className={cn(btnClass, collapsed ? 'justify-center px-0' : 'px-2')}
|
||||
onClick={() => navigate('/')}>
|
||||
<span className={cn('flex h-6 w-6 items-center justify-center shrink-0')}>
|
||||
<Home className="h-4 w-4"/>
|
||||
</span>
|
||||
{!collapsed && <span className="text-sm leading-none">Home</span>}
|
||||
</button>
|
||||
|
||||
<button type="button" className={cn(btnClass, collapsed ? 'justify-center px-0' : 'px-2')} onClick={() => navigate('/explore')}>
|
||||
<span className={cn('flex h-6 items-center shrink-0', collapsed ? 'w-6 justify-center' : 'w-6')}>
|
||||
<Compass className="h-4 w-4" />
|
||||
<button type="button" className={cn(btnClass, collapsed ? 'justify-center px-0' : 'px-2')}
|
||||
onClick={() => navigate('/explore')}>
|
||||
<span className={cn('flex h-6 w-6 items-center justify-center shrink-0')}>
|
||||
<Compass className="h-4 w-4"/>
|
||||
</span>
|
||||
{!collapsed && <span className="text-sm leading-none">Explore</span>}
|
||||
</button>
|
||||
|
||||
<button type="button" className={cn(btnClass, collapsed ? 'justify-center px-0' : 'px-2')} onClick={() => navigate('/market')}>
|
||||
<span className={cn('flex h-6 items-center shrink-0', collapsed ? 'w-6 justify-center' : 'w-6')}>
|
||||
<Box className="h-4 w-4" />
|
||||
<button type="button" className={cn(btnClass, collapsed ? 'justify-center px-0' : 'px-2')}
|
||||
onClick={() => navigate('/market')}>
|
||||
<span className={cn('flex h-6 w-6 items-center justify-center shrink-0')}>
|
||||
<Box className="h-4 w-4"/>
|
||||
</span>
|
||||
{!collapsed && <span className="text-sm leading-none">Marketplace</span>}
|
||||
</button>
|
||||
|
||||
<button type="button" className={cn(btnClass, collapsed ? 'justify-center px-0' : 'px-2')} onClick={() => window.open('/docs', '_blank')}>
|
||||
<span className={cn('flex h-6 items-center shrink-0', collapsed ? 'w-6 justify-center' : 'w-6')}>
|
||||
<BookOpen className="h-4 w-4" />
|
||||
<button type="button" className={cn(btnClass, collapsed ? 'justify-center px-0' : 'px-2')}
|
||||
onClick={() => window.open('/docs', '_blank')}>
|
||||
<span className={cn('flex h-6 w-6 items-center justify-center shrink-0')}>
|
||||
<BookOpen className="h-4 w-4"/>
|
||||
</span>
|
||||
{!collapsed && <span className="text-sm leading-none">Docs</span>}
|
||||
</button>
|
||||
@ -125,13 +144,13 @@ export function SidebarSystem({collapsed}: {collapsed: boolean}) {
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span className={cn('flex h-6 items-center shrink-0', collapsed ? 'w-6 justify-center' : 'w-6')}>
|
||||
<span className={cn('flex h-6 w-6 items-center justify-center shrink-0')}>
|
||||
{theme === 'dark' ? (
|
||||
<Moon className="h-4 w-4" />
|
||||
<Moon className="h-4 w-4"/>
|
||||
) : theme === 'light' ? (
|
||||
<Sun className="h-4 w-4" />
|
||||
<Sun className="h-4 w-4"/>
|
||||
) : (
|
||||
<Monitor className="h-4 w-4" />
|
||||
<Monitor className="h-4 w-4"/>
|
||||
)}
|
||||
</span>
|
||||
{!collapsed && <span className="text-sm leading-none">Theme</span>}
|
||||
@ -139,36 +158,34 @@ export function SidebarSystem({collapsed}: {collapsed: boolean}) {
|
||||
<DropdownMenuContent side="right" align="start">
|
||||
{!collapsed && (
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>Theme Settings</DropdownMenuLabel>
|
||||
<DropdownMenuLabel>Theme</DropdownMenuLabel>
|
||||
<DropdownMenuItem onClick={() => setThemeSheetOpen(true)}>
|
||||
<Sliders className="mr-2 h-4 w-4"/>
|
||||
<span>Design System</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator/>
|
||||
<DropdownMenuItem onClick={() => setTheme('light')}>
|
||||
<Sun className="mr-2 h-4 w-4" />
|
||||
<Sun className="mr-2 h-4 w-4"/>
|
||||
<span>Light</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme('dark')}>
|
||||
<Moon className="mr-2 h-4 w-4" />
|
||||
<Moon className="mr-2 h-4 w-4"/>
|
||||
<span>Dark</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme('system')}>
|
||||
<Monitor className="mr-2 h-4 w-4" />
|
||||
<Monitor className="mr-2 h-4 w-4"/>
|
||||
<span>System</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
)}
|
||||
{collapsed && (
|
||||
<>
|
||||
<DropdownMenuItem onClick={() => setTheme('light')}>
|
||||
<Sun className="mr-2 h-4 w-4" />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme('dark')}>
|
||||
<Moon className="mr-2 h-4 w-4" />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme('system')}>
|
||||
<Monitor className="mr-2 h-4 w-4" />
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
<DropdownMenuItem onClick={() => setThemeSheetOpen(true)}>
|
||||
<Sliders className="mr-2 h-4 w-4"/>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<ThemeSwitcher open={themeSheetOpen} onOpenChange={setThemeSheetOpen}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -10,7 +10,7 @@ import type { MessageWithMeta } from '@/contexts';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Hash, Lock, Users, Search, ChevronLeft,
|
||||
AtSign, Pin, Settings,
|
||||
AtSign, Pin, Settings, PanelLeft,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
useCallback,
|
||||
@ -38,9 +38,11 @@ interface DiscordChatPanelProps {
|
||||
isAdmin: boolean;
|
||||
onClose: () => void;
|
||||
onDelete: () => void;
|
||||
onToggleChannelSidebar: () => void;
|
||||
channelSidebarOpen: boolean;
|
||||
}
|
||||
|
||||
export function DiscordChatPanel({ room, isAdmin, onClose, onDelete }: DiscordChatPanelProps) {
|
||||
export function DiscordChatPanel({ room, isAdmin, onClose, onDelete, onToggleChannelSidebar, channelSidebarOpen }: DiscordChatPanelProps) {
|
||||
const {
|
||||
messages,
|
||||
members,
|
||||
@ -67,7 +69,7 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete }: DiscordCh
|
||||
const [selectedMessageForHistory, setSelectedMessageForHistory] = useState<string>('');
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [showMentions, setShowMentions] = useState(false);
|
||||
const [showMemberList, setShowMemberList] = useState(true);
|
||||
const [showMemberList, setShowMemberList] = useState(false);
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
const [activeThread, setActiveThread] = useState<{ thread: RoomThreadResponse; parentMessage: MessageWithMeta } | null>(null);
|
||||
const [isUpdatingRoom, setIsUpdatingRoom] = useState(false);
|
||||
@ -79,7 +81,8 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete }: DiscordCh
|
||||
|
||||
const handleSend = useCallback(
|
||||
(content: string) => {
|
||||
sendMessage(content, 'text', replyingTo?.id ?? undefined);
|
||||
const attachmentIds = messageInputRef.current?.getAttachmentIds() ?? [];
|
||||
sendMessage(content, 'text', replyingTo?.id ?? undefined, attachmentIds.length > 0 ? attachmentIds : undefined);
|
||||
setReplyingTo(null);
|
||||
messageInputRef.current?.clearContent();
|
||||
},
|
||||
@ -260,6 +263,18 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete }: DiscordCh
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md transition-colors"
|
||||
style={{
|
||||
color: channelSidebarOpen ? 'var(--room-accent)' : 'var(--room-text-muted)',
|
||||
background: channelSidebarOpen ? 'var(--room-channel-active)' : 'transparent',
|
||||
}}
|
||||
onClick={onToggleChannelSidebar}
|
||||
title={channelSidebarOpen ? 'Hide channels' : 'Show channels'}
|
||||
>
|
||||
<PanelLeft className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
||||
@ -204,7 +204,7 @@ export const RoomSettingsPanel = memo(function RoomSettingsPanel({
|
||||
onClick={handleSave}
|
||||
disabled={!name.trim() || isPending}
|
||||
className="w-full border-none"
|
||||
style={{ background: 'var(--room-accent)', color: '#fff' }}
|
||||
style={{ background: 'var(--room-accent)', color: 'var(--accent-fg)' }}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
@ -264,7 +264,7 @@ export const RoomSettingsPanel = memo(function RoomSettingsPanel({
|
||||
{config.think && (
|
||||
<span
|
||||
className="rounded px-1 py-0.5 text-[10px] shrink-0"
|
||||
style={{ background: 'rgba(59,130,246,0.1)', color: 'var(--room-accent)' }}
|
||||
style={{ background: 'var(--accent-subtle)', color: 'var(--room-accent)' }}
|
||||
>
|
||||
think
|
||||
</span>
|
||||
@ -439,7 +439,7 @@ export const RoomSettingsPanel = memo(function RoomSettingsPanel({
|
||||
<Button
|
||||
onClick={handleAddAi}
|
||||
disabled={!selectedModelId || isAddingAi}
|
||||
style={{ background: 'var(--room-accent)', color: '#fff', border: 'none' }}
|
||||
style={{ background: 'var(--room-accent)', color: 'var(--accent-fg)', border: 'none' }}
|
||||
>
|
||||
{isAddingAi ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
Add Model
|
||||
|
||||
404
src/components/room/ThemeSwitcher.tsx
Normal file
404
src/components/room/ThemeSwitcher.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -1,89 +1,239 @@
|
||||
/**
|
||||
* AI Studio design system — room-wide tokens.
|
||||
* Clean, modern palette. No Discord reference.
|
||||
* AI Studio design system — palette hooks.
|
||||
*
|
||||
* Architecture:
|
||||
* CSS custom properties (index.css) ← index.css :root / .dark ← index.css @layer semantic
|
||||
* ↑ read only ← custom mode writes here
|
||||
*
|
||||
* In default mode the hook returns the CSS variable values (live, theme-aware).
|
||||
* In "custom" mode it returns the stored localStorage palette and also writes
|
||||
* those values to the DOM so they override the CSS layer.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTheme } from '@/contexts';
|
||||
|
||||
// ─── Palette ──────────────────────────────────────────────────────────────────
|
||||
// ─── Preset definitions ───────────────────────────────────────────────────────
|
||||
|
||||
export const PALETTE = {
|
||||
light: {
|
||||
// Backgrounds
|
||||
bg: '#ffffff',
|
||||
bgSubtle: '#f9f9fa',
|
||||
bgHover: '#f3f3f5',
|
||||
bgActive: '#ebebef',
|
||||
// Borders
|
||||
border: '#e4e4e8',
|
||||
borderFocus:'#1c7ded',
|
||||
borderMuted:'#eeeeef',
|
||||
// Text
|
||||
text: '#1f1f1f',
|
||||
textMuted: '#8a8a8f',
|
||||
textSubtle: '#b8b8bd',
|
||||
// Accent (primary action)
|
||||
accent: '#1c7ded',
|
||||
accentHover:'#1a73d4',
|
||||
accentText: '#ffffff',
|
||||
// Icon
|
||||
icon: '#8a8a8f',
|
||||
iconHover: '#5c5c62',
|
||||
// Surfaces
|
||||
surface: '#f7f7f8',
|
||||
surface2: '#eeeeef',
|
||||
// Status
|
||||
online: '#22c55e',
|
||||
away: '#f59e0b',
|
||||
offline: '#d1d1d6',
|
||||
// Mention highlight
|
||||
mentionBg: 'rgba(28,125,237,0.08)',
|
||||
mentionText:'#1c7ded',
|
||||
// Message bubbles
|
||||
msgBg: '#f9f9fb',
|
||||
msgOwnBg: '#e8f0fe',
|
||||
// Panel
|
||||
panelBg: '#f5f5f7',
|
||||
// Badges
|
||||
badgeAi: 'bg-blue-50 text-blue-600',
|
||||
badgeRole: 'bg-gray-100 text-gray-600',
|
||||
},
|
||||
dark: {
|
||||
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: 'bg-blue-900/40 text-blue-300',
|
||||
badgeRole: 'bg-gray-800 text-gray-400',
|
||||
},
|
||||
} as const;
|
||||
export interface PaletteEntry {
|
||||
bg: string; // page background
|
||||
bgSubtle: string; // slightly elevated surface
|
||||
bgHover: string; // hover state
|
||||
bgActive: string; // active/pressed state
|
||||
border: string; // default border
|
||||
borderFocus: string; // focus ring / active border
|
||||
borderMuted: string; // subtle dividers
|
||||
text: string; // primary text
|
||||
textMuted: string; // secondary / metadata
|
||||
textSubtle: string; // timestamps, hints
|
||||
accent: string; // brand / action color
|
||||
accentHover: string;
|
||||
accentText: string; // text on accent bg
|
||||
icon: string; // default icon color
|
||||
iconHover: string; // icon hover color
|
||||
surface: string; // card / elevated surface
|
||||
surface2: string; // deeper surface
|
||||
online: string; // online status dot
|
||||
away: string; // away / idle dot
|
||||
offline: string; // offline dot
|
||||
mentionBg: string; // mention highlight bg (with alpha)
|
||||
mentionText: string; // mention highlight text
|
||||
msgBg: string; // received message bubble bg
|
||||
msgOwnBg: string; // own message bubble bg
|
||||
panelBg: string; // sidebar / panel background
|
||||
badgeAi: string; // tailwind classes for AI badge
|
||||
badgeRole: string; // tailwind classes for role badge
|
||||
}
|
||||
|
||||
export type ThemePalette = typeof PALETTE.light;
|
||||
export type ThemePresetId = 'default' | 'custom';
|
||||
|
||||
export interface ThemePreset {
|
||||
id: ThemePresetId;
|
||||
label: string;
|
||||
description: string;
|
||||
/** Pre-built palette object, or null = read from CSS vars (default mode) */
|
||||
palette: PaletteEntry | null;
|
||||
}
|
||||
|
||||
// ─── Presets ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export const THEME_PRESETS: ThemePreset[] = [
|
||||
{
|
||||
id: 'default',
|
||||
label: 'Default',
|
||||
description: 'Linear / Vercel inspired — neutral + single indigo accent',
|
||||
palette: null, // reads live from CSS vars
|
||||
},
|
||||
];
|
||||
|
||||
/** Well-known CSS vars that map to PaletteEntry keys */
|
||||
const PALETTE_VAR_MAP: Record<keyof PaletteEntry, string> = {
|
||||
bg: '--bg',
|
||||
bgSubtle: '--surface-2',
|
||||
bgHover: '--surface-3',
|
||||
bgActive: '--surface-3',
|
||||
border: '--border',
|
||||
borderFocus: '--ring',
|
||||
borderMuted: '--border-2',
|
||||
text: '--fg',
|
||||
textMuted: '--fg-muted',
|
||||
textSubtle: '--fg-subtle',
|
||||
accent: '--accent',
|
||||
accentHover: '--accent-hover',
|
||||
accentText: '--accent-fg',
|
||||
icon: '--fg-muted',
|
||||
iconHover: '--fg',
|
||||
surface: '--surface-2',
|
||||
surface2: '--surface-3',
|
||||
online: '--success',
|
||||
away: '--warning',
|
||||
offline: '--room-offline',
|
||||
mentionBg: '--accent-subtle',
|
||||
mentionText: '--accent',
|
||||
msgBg: '--surface-2',
|
||||
msgOwnBg: '--accent-subtle',
|
||||
panelBg: '--sidebar-bg',
|
||||
badgeAi: 'bg-accent/10 text-accent font-medium',
|
||||
badgeRole: 'bg-muted text-muted-foreground font-medium',
|
||||
};
|
||||
|
||||
/** Read a CSS custom property value from the DOM, fallback to a default */
|
||||
function readCssVar(name: string, fallback: string): string {
|
||||
if (typeof document === 'undefined') return fallback;
|
||||
return getComputedStyle(document.documentElement)
|
||||
.getPropertyValue(name)
|
||||
.trim() || fallback;
|
||||
}
|
||||
|
||||
// ─── Custom palette storage ───────────────────────────────────────────────────
|
||||
|
||||
const CUSTOM_KEY = 'theme-custom-palette';
|
||||
const PRESET_KEY = 'theme-preset';
|
||||
|
||||
export function loadCustomPalette(): PaletteEntry | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(CUSTOM_KEY);
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw) as PaletteEntry;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveCustomPalette(palette: PaletteEntry) {
|
||||
localStorage.setItem(CUSTOM_KEY, JSON.stringify(palette));
|
||||
}
|
||||
|
||||
export function clearCustomPalette() {
|
||||
localStorage.removeItem(CUSTOM_KEY);
|
||||
localStorage.setItem(PRESET_KEY, 'default');
|
||||
}
|
||||
|
||||
export function loadActivePresetId(): ThemePresetId {
|
||||
return (localStorage.getItem(PRESET_KEY) as ThemePresetId) || 'default';
|
||||
}
|
||||
|
||||
export function saveActivePresetId(id: ThemePresetId) {
|
||||
localStorage.setItem(PRESET_KEY, id);
|
||||
// Notify all useAIPalette hooks to re-read
|
||||
window.dispatchEvent(new CustomEvent('theme-preset-change', { detail: id }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a custom palette to the DOM root so it overrides the CSS layer.
|
||||
* Only the keys present in PALETTE_VAR_MAP are written.
|
||||
*/
|
||||
export function applyPaletteToDOM(palette: PaletteEntry) {
|
||||
const root = document.documentElement;
|
||||
for (const [key, cssVar] of Object.entries(PALETTE_VAR_MAP)) {
|
||||
if (key === 'badgeAi' || key === 'badgeRole') continue; // class strings, skip
|
||||
root.style.setProperty(cssVar, (palette as unknown as Record<string, string>)[key]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Reset DOM overrides back to the CSS layer (removes custom inline styles) */
|
||||
export function resetDOMFromPalette() {
|
||||
const root = document.documentElement;
|
||||
for (const cssVar of Object.values(PALETTE_VAR_MAP)) {
|
||||
if (cssVar === '--badge-ai' || cssVar === '--badge-role') continue;
|
||||
root.style.removeProperty(cssVar);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Hook ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useAIPalette() {
|
||||
export function useAIPalette(): PaletteEntry {
|
||||
const { resolvedTheme } = useTheme();
|
||||
return resolvedTheme === 'dark' ? PALETTE.dark : PALETTE.light;
|
||||
const [customPalette, setCustomPalette] = useState<PaletteEntry | null>(
|
||||
loadCustomPalette,
|
||||
);
|
||||
const [activePresetId, setActivePresetId] = useState<ThemePresetId>(loadActivePresetId);
|
||||
|
||||
// Re-read from localStorage when the custom palette changes
|
||||
// (e.g. after ThemeSwitcher saves a new custom palette)
|
||||
useEffect(() => {
|
||||
const onPresetChange = () => {
|
||||
setActivePresetId(loadActivePresetId());
|
||||
setCustomPalette(loadCustomPalette());
|
||||
};
|
||||
window.addEventListener('theme-preset-change', onPresetChange);
|
||||
return () => window.removeEventListener('theme-preset-change', onPresetChange);
|
||||
}, []);
|
||||
|
||||
// ── Derive palette ──────────────────────────────────────────────────────
|
||||
|
||||
if (activePresetId === 'custom' && customPalette) {
|
||||
// Custom mode: return the stored palette (DOM is already updated by the
|
||||
// ThemeSwitcher, but we also return it here so callers get the right values)
|
||||
return customPalette;
|
||||
}
|
||||
|
||||
// Default mode: read live from CSS variables
|
||||
return useMemo<PaletteEntry>(() => {
|
||||
// re-compute when light/dark resolvedTheme changes
|
||||
void resolvedTheme;
|
||||
|
||||
return {
|
||||
bg: readCssVar('--bg', '#ffffff'),
|
||||
bgSubtle: readCssVar('--surface-2', '#f9f9fa'),
|
||||
bgHover: readCssVar('--surface-3', '#f3f3f5'),
|
||||
bgActive: readCssVar('--surface-3', '#ebebef'),
|
||||
border: readCssVar('--border', '#e4e4e8'),
|
||||
borderFocus: readCssVar('--ring', '#1c7ded'),
|
||||
borderMuted: readCssVar('--border-2', '#eeeeef'),
|
||||
text: readCssVar('--fg', '#1f1f1f'),
|
||||
textMuted: readCssVar('--fg-muted', '#8a8a8f'),
|
||||
textSubtle: readCssVar('--fg-subtle', '#b8b8bd'),
|
||||
accent: readCssVar('--accent', '#1c7ded'),
|
||||
accentHover: readCssVar('--accent-hover', '#1a73d4'),
|
||||
accentText: readCssVar('--accent-fg', '#ffffff'),
|
||||
icon: readCssVar('--fg-muted', '#8a8a8f'),
|
||||
iconHover: readCssVar('--fg', '#5c5c62'),
|
||||
surface: readCssVar('--surface-2', '#f7f7f8'),
|
||||
surface2: readCssVar('--surface-3', '#eeeeef'),
|
||||
online: readCssVar('--success', '#22c55e'),
|
||||
away: readCssVar('--warning', '#f59e0b'),
|
||||
offline: readCssVar('--room-offline', '#d1d1d6'),
|
||||
mentionBg: readCssVar('--accent-subtle', 'rgba(28,125,237,0.08)'),
|
||||
mentionText: readCssVar('--accent', '#1c7ded'),
|
||||
msgBg: readCssVar('--surface-2', '#f9f9fb'),
|
||||
msgOwnBg: readCssVar('--accent-subtle', '#e8f0fe'),
|
||||
panelBg: readCssVar('--sidebar-bg', '#f9f9fa'),
|
||||
badgeAi: 'bg-accent/10 text-accent font-medium',
|
||||
badgeRole: 'bg-muted text-muted-foreground font-medium',
|
||||
};
|
||||
}, [resolvedTheme]);
|
||||
}
|
||||
|
||||
/** Trigger a custom palette: saves to localStorage, applies to DOM, sets preset */
|
||||
export function activateCustomPalette(palette: PaletteEntry) {
|
||||
saveCustomPalette(palette);
|
||||
saveActivePresetId('custom');
|
||||
applyPaletteToDOM(palette);
|
||||
}
|
||||
|
||||
/** Reset back to the default CSS-layer theme */
|
||||
export function deactivateCustomPalette() {
|
||||
clearCustomPalette();
|
||||
resetDOMFromPalette();
|
||||
}
|
||||
|
||||
@ -5,14 +5,15 @@
|
||||
* Supports @mentions, file uploads, emoji picker, and rich message AST.
|
||||
*/
|
||||
|
||||
import { forwardRef } from 'react';
|
||||
import { forwardRef, useImperativeHandle, useRef } from 'react';
|
||||
import { IMEditor } from './editor/IMEditor';
|
||||
import { useRoom } from '@/contexts';
|
||||
import type { MessageAST } from './editor/types';
|
||||
import type { IMEditorHandle } from './editor/IMEditor';
|
||||
|
||||
export interface MessageInputProps {
|
||||
roomName: string;
|
||||
onSend: (content: string) => void;
|
||||
onSend: (content: string, attachmentIds?: string[]) => void;
|
||||
replyingTo?: { id: string; display_name?: string; content: string } | null;
|
||||
onCancelReply?: () => void;
|
||||
}
|
||||
@ -22,13 +23,27 @@ export interface MessageInputHandle {
|
||||
clearContent: () => void;
|
||||
getContent: () => string;
|
||||
insertMention: (type: string, id: string, label: string) => void;
|
||||
getAttachmentIds: () => string[];
|
||||
}
|
||||
|
||||
export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(function MessageInput(
|
||||
{ roomName, onSend, replyingTo, onCancelReply },
|
||||
ref,
|
||||
) {
|
||||
const { members } = useRoom();
|
||||
const { members, activeRoomId } = useRoom();
|
||||
|
||||
// Ref passed to the inner IMEditor
|
||||
const innerEditorRef = useRef<IMEditorHandle | null>(null);
|
||||
|
||||
// Expose a subset of IMEditorHandle (plus getAttachmentIds) as MessageInputHandle
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: () => innerEditorRef.current?.focus(),
|
||||
clearContent: () => innerEditorRef.current?.clearContent(),
|
||||
getContent: () => innerEditorRef.current?.getContent() ?? '',
|
||||
insertMention: (type: string, id: string, label: string) =>
|
||||
innerEditorRef.current?.insertMention(type, id, label),
|
||||
getAttachmentIds: () => innerEditorRef.current?.getAttachmentIds() ?? [],
|
||||
}), []);
|
||||
|
||||
// Transform room data into MentionItems
|
||||
const mentionItems = {
|
||||
@ -43,11 +58,12 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
|
||||
commands: [], // TODO: add slash commands
|
||||
};
|
||||
|
||||
// File upload handler — integrate with your upload API
|
||||
// File upload handler — POST to /rooms/{room_id}/upload
|
||||
const handleUploadFile = async (file: File): Promise<{ id: string; url: string }> => {
|
||||
if (!activeRoomId) throw new Error('No active room');
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const res = await fetch('/api/upload', { method: 'POST', body: formData });
|
||||
const res = await fetch(`/rooms/${activeRoomId}/upload`, { method: 'POST', body: formData });
|
||||
if (!res.ok) throw new Error('Upload failed');
|
||||
return res.json();
|
||||
};
|
||||
@ -59,7 +75,7 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
|
||||
|
||||
return (
|
||||
<IMEditor
|
||||
ref={ref}
|
||||
ref={innerEditorRef}
|
||||
replyingTo={replyingTo}
|
||||
onCancelReply={onCancelReply}
|
||||
onSend={handleSend}
|
||||
|
||||
@ -5,478 +5,536 @@
|
||||
* Colors: Clean modern palette, no Discord reference
|
||||
*/
|
||||
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
|
||||
import { useEditor, EditorContent, Extension } from '@tiptap/react';
|
||||
import {forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react';
|
||||
import {EditorContent, Extension, useEditor} from '@tiptap/react';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import Placeholder from '@tiptap/extension-placeholder';
|
||||
import { CustomEmojiNode } from './EmojiNode';
|
||||
import type { MentionItem, MessageAST, MentionType } from './types';
|
||||
import { Paperclip, Smile, Send, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { COMMON_EMOJIS } from '../../shared';
|
||||
import { useTheme } from '@/contexts';
|
||||
import { useImageCompress } from '@/hooks/useImageCompress';
|
||||
import {CustomEmojiNode} from './EmojiNode';
|
||||
import type {MentionItem, MentionType, MessageAST} from './types';
|
||||
import {Paperclip, Send, Smile, X} from 'lucide-react';
|
||||
import {cn} from '@/lib/utils';
|
||||
import {COMMON_EMOJIS} from '../../shared';
|
||||
import {useTheme} from '@/contexts';
|
||||
import {useImageCompress} from '@/hooks/useImageCompress';
|
||||
|
||||
export interface IMEditorProps {
|
||||
replyingTo?: { id: string; display_name?: string; content: string } | null;
|
||||
onCancelReply?: () => void;
|
||||
onSend: (content: string, ast: MessageAST) => void;
|
||||
mentionItems: {
|
||||
users: MentionItem[];
|
||||
channels: MentionItem[];
|
||||
ai: MentionItem[];
|
||||
commands: MentionItem[];
|
||||
};
|
||||
onUploadFile?: (file: File) => Promise<{ id: string; url: string }>;
|
||||
placeholder?: string;
|
||||
replyingTo?: { id: string; display_name?: string; content: string } | null;
|
||||
onCancelReply?: () => void;
|
||||
onSend: (content: string, ast: MessageAST) => void;
|
||||
mentionItems: {
|
||||
users: MentionItem[];
|
||||
channels: MentionItem[];
|
||||
ai: MentionItem[];
|
||||
commands: MentionItem[];
|
||||
};
|
||||
onUploadFile?: (file: File) => Promise<{ id: string; url: string }>;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export interface IMEditorHandle {
|
||||
focus: () => void;
|
||||
clearContent: () => void;
|
||||
getContent: () => string;
|
||||
insertMention: (type: string, id: string, label: string) => void;
|
||||
focus: () => void;
|
||||
clearContent: () => void;
|
||||
getContent: () => string;
|
||||
insertMention: (type: string, id: string, label: string) => void;
|
||||
getAttachmentIds: () => string[];
|
||||
}
|
||||
|
||||
// ─── Color System (Google AI Studio / Linear palette, no Discord) ────────────
|
||||
|
||||
const LIGHT = {
|
||||
bg: '#ffffff',
|
||||
bgHover: '#f7f7f8',
|
||||
bgActive: '#ececf1',
|
||||
border: '#e3e3e5',
|
||||
borderFocus: '#1c7ded',
|
||||
text: '#1f1f1f',
|
||||
textMuted: '#8a8a8e',
|
||||
textSubtle: '#b0b0b4',
|
||||
icon: '#8a8a8e',
|
||||
iconHover: '#5c5c60',
|
||||
sendBg: '#1c7ded',
|
||||
sendBgHover: '#1a73d4',
|
||||
sendIcon: '#ffffff',
|
||||
sendDisabled:'#e3e3e5',
|
||||
popupBg: '#ffffff',
|
||||
popupBorder: '#e3e3e5',
|
||||
popupHover: '#f5f5f7',
|
||||
popupSelected:'#e8f0fe',
|
||||
replyBg: '#f5f5f7',
|
||||
badgeAi: '#dbeafe text-blue-700',
|
||||
badgeChan: '#f3f4f6 text-gray-500',
|
||||
badgeCmd: '#fef3c7 text-amber-700',
|
||||
bg: '#ffffff',
|
||||
bgHover: '#f7f7f8',
|
||||
bgActive: '#ececf1',
|
||||
border: '#e3e3e5',
|
||||
borderFocus: '#1c7ded',
|
||||
text: '#1f1f1f',
|
||||
textMuted: '#8a8a8e',
|
||||
textSubtle: '#b0b0b4',
|
||||
icon: '#8a8a8e',
|
||||
iconHover: '#5c5c60',
|
||||
sendBg: '#1c7ded',
|
||||
sendBgHover: '#1a73d4',
|
||||
sendIcon: '#ffffff',
|
||||
sendDisabled: '#e3e3e5',
|
||||
popupBg: '#ffffff',
|
||||
popupBorder: '#e3e3e5',
|
||||
popupHover: '#f5f5f7',
|
||||
popupSelected: '#e8f0fe',
|
||||
replyBg: '#f5f5f7',
|
||||
badgeAi: '#dbeafe text-blue-700',
|
||||
badgeChan: '#f3f4f6 text-gray-500',
|
||||
badgeCmd: '#fef3c7 text-amber-700',
|
||||
};
|
||||
|
||||
const DARK = {
|
||||
bg: '#1a1a1e',
|
||||
bgHover: '#222226',
|
||||
bgActive: '#2a2a2f',
|
||||
border: '#2e2e33',
|
||||
borderFocus: '#4a9eff',
|
||||
text: '#ececf1',
|
||||
textMuted: '#8a8a91',
|
||||
textSubtle: '#5c5c63',
|
||||
icon: '#7a7a82',
|
||||
iconHover: '#b0b0b8',
|
||||
sendBg: '#4a9eff',
|
||||
sendBgHover: '#6aafff',
|
||||
sendIcon: '#ffffff',
|
||||
sendDisabled:'#2e2e33',
|
||||
popupBg: '#222226',
|
||||
popupBorder: '#2e2e33',
|
||||
popupHover: '#2a2a30',
|
||||
popupSelected:'#2a3a55',
|
||||
replyBg: '#1f1f23',
|
||||
badgeAi: 'bg-blue-900/40 text-blue-300',
|
||||
badgeChan: 'bg-gray-800 text-gray-400',
|
||||
badgeCmd: 'bg-amber-900/30 text-amber-300',
|
||||
bg: '#1a1a1e',
|
||||
bgHover: '#222226',
|
||||
bgActive: '#2a2a2f',
|
||||
border: '#2e2e33',
|
||||
borderFocus: '#4a9eff',
|
||||
text: '#ececf1',
|
||||
textMuted: '#8a8a91',
|
||||
textSubtle: '#5c5c63',
|
||||
icon: '#7a7a82',
|
||||
iconHover: '#b0b0b8',
|
||||
sendBg: '#4a9eff',
|
||||
sendBgHover: '#6aafff',
|
||||
sendIcon: '#ffffff',
|
||||
sendDisabled: '#2e2e33',
|
||||
popupBg: '#222226',
|
||||
popupBorder: '#2e2e33',
|
||||
popupHover: '#2a2a30',
|
||||
popupSelected: '#2a3a55',
|
||||
replyBg: '#1f1f23',
|
||||
badgeAi: 'bg-blue-900/40 text-blue-300',
|
||||
badgeChan: 'bg-gray-800 text-gray-400',
|
||||
badgeCmd: 'bg-amber-900/30 text-amber-300',
|
||||
};
|
||||
|
||||
type Palette = typeof LIGHT;
|
||||
|
||||
// ─── Emoji Picker ─────────────────────────────────────────────────────────────
|
||||
|
||||
function EmojiPicker({ onClose, onSelect, p }: { onClose: () => void; onSelect: (emoji: string) => void; p: Palette }) {
|
||||
return (
|
||||
<div
|
||||
className="absolute bottom-full left-0 mb-2 z-50"
|
||||
style={{
|
||||
background: p.popupBg,
|
||||
border: `1px solid ${p.popupBorder}`,
|
||||
borderRadius: 12,
|
||||
boxShadow: p === DARK
|
||||
? '0 8px 32px rgba(0,0,0,0.6)'
|
||||
: '0 8px 32px rgba(0,0,0,0.10)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-between px-3 pt-3 pb-2"
|
||||
style={{ borderBottom: `1px solid ${p.popupBorder}` }}
|
||||
>
|
||||
<span className="text-[11px] font-semibold tracking-wide uppercase" style={{ color: p.textMuted }}>
|
||||
function EmojiPicker({onClose, onSelect, p}: { onClose: () => void; onSelect: (emoji: string) => void; p: Palette }) {
|
||||
return (
|
||||
<div
|
||||
className="absolute bottom-full left-0 mb-2 z-50"
|
||||
style={{
|
||||
background: p.popupBg,
|
||||
border: `1px solid ${p.popupBorder}`,
|
||||
borderRadius: 12,
|
||||
boxShadow: p === DARK
|
||||
? '0 8px 32px rgba(0,0,0,0.6)'
|
||||
: '0 8px 32px rgba(0,0,0,0.10)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-between px-3 pt-3 pb-2"
|
||||
style={{borderBottom: `1px solid ${p.popupBorder}`}}
|
||||
>
|
||||
<span className="text-[11px] font-semibold tracking-wide uppercase" style={{color: p.textMuted}}>
|
||||
Emoji
|
||||
</span>
|
||||
<button onClick={onClose} className="flex items-center justify-center w-5 h-5 rounded cursor-pointer transition-colors" style={{ color: p.icon }}>
|
||||
<X size={11} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid p-2 gap-0.5" style={{ gridTemplateColumns: 'repeat(6, 1fr)' }}>
|
||||
{COMMON_EMOJIS.map(emoji => (
|
||||
<button
|
||||
key={emoji}
|
||||
onClick={() => onSelect(emoji)}
|
||||
className="w-9 h-9 flex items-center justify-center rounded-lg transition-all duration-100 cursor-pointer hover:scale-110 text-[18px]"
|
||||
style={{ background: 'transparent' }}
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
<button onClick={onClose}
|
||||
className="flex items-center justify-center w-5 h-5 rounded cursor-pointer transition-colors"
|
||||
style={{color: p.icon}}>
|
||||
<X size={11}/>
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid p-2 gap-0.5" style={{gridTemplateColumns: 'repeat(6, 1fr)'}}>
|
||||
{COMMON_EMOJIS.map(emoji => (
|
||||
<button
|
||||
key={emoji}
|
||||
onClick={() => onSelect(emoji)}
|
||||
className="w-9 h-9 flex items-center justify-center rounded-lg transition-all duration-100 cursor-pointer hover:scale-110 text-[18px]"
|
||||
style={{background: 'transparent'}}
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Keyboard Extension ───────────────────────────────────────────────────────
|
||||
|
||||
const KeyboardSend = Extension.create({
|
||||
name: 'keyboardSend',
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Enter: ({ editor }) => {
|
||||
if (editor.isEmpty) return true;
|
||||
const text = editor.getText().trim();
|
||||
if (!text) return true;
|
||||
(editor.storage as any).keyboardSend?.onSend?.(text, editor.getJSON() as MessageAST);
|
||||
return true;
|
||||
},
|
||||
'Shift-Enter': ({ editor }) => {
|
||||
editor.chain().focus().setHardBreak().run();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
addStorage() {
|
||||
return { onSend: null as ((t: string, a: MessageAST) => void) | null };
|
||||
},
|
||||
name: 'keyboardSend',
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Enter: ({editor}) => {
|
||||
if (editor.isEmpty) return true;
|
||||
const text = editor.getText().trim();
|
||||
if (!text) return true;
|
||||
(editor.storage as any).keyboardSend?.onSend?.(text, editor.getJSON() as MessageAST);
|
||||
return true;
|
||||
},
|
||||
'Shift-Enter': ({editor}) => {
|
||||
editor.chain().focus().setHardBreak().run();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
addStorage() {
|
||||
return {onSend: null as ((t: string, a: MessageAST) => void) | null};
|
||||
},
|
||||
});
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function filterMentionItems(all: MentionItem[], q: string): MentionItem[] {
|
||||
return all.filter(m => m.label.toLowerCase().includes(q.toLowerCase())).slice(0, 8);
|
||||
return all.filter(m => m.label.toLowerCase().includes(q.toLowerCase())).slice(0, 8);
|
||||
}
|
||||
|
||||
function getBadge(type: MentionType): { label: string; cls: string } | null {
|
||||
if (type === 'ai') return { label: 'AI', cls: 'bg-blue-50 text-blue-600' };
|
||||
if (type === 'channel') return { label: '#', cls: 'bg-gray-100 text-gray-500' };
|
||||
if (type === 'command') return { label: 'cmd', cls: 'bg-amber-50 text-amber-600' };
|
||||
return null;
|
||||
if (type === 'ai') return {label: 'AI', cls: 'bg-blue-50 text-blue-600'};
|
||||
if (type === 'channel') return {label: '#', cls: 'bg-gray-100 text-gray-500'};
|
||||
if (type === 'command') return {label: 'cmd', cls: 'bg-amber-50 text-amber-600'};
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Mention Dropdown ────────────────────────────────────────────────────────
|
||||
|
||||
function MentionDropdown({
|
||||
items, selectedIndex, onSelect, p, query,
|
||||
}: {
|
||||
items: MentionItem[];
|
||||
selectedIndex: number;
|
||||
onSelect: (item: MentionItem) => void;
|
||||
p: Palette;
|
||||
query: string;
|
||||
items, selectedIndex, onSelect, p, query,
|
||||
}: {
|
||||
items: MentionItem[];
|
||||
selectedIndex: number;
|
||||
onSelect: (item: MentionItem) => void;
|
||||
p: Palette;
|
||||
query: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="absolute left-0 z-50 overflow-hidden"
|
||||
style={{
|
||||
background: p.popupBg,
|
||||
border: `1px solid ${p.popupBorder}`,
|
||||
borderRadius: 10,
|
||||
boxShadow: p === DARK ? '0 12px 40px rgba(0,0,0,0.55)' : '0 8px 30px rgba(0,0,0,0.09)',
|
||||
minWidth: 240,
|
||||
maxWidth: 300,
|
||||
}}
|
||||
>
|
||||
{items.length === 0 ? (
|
||||
<div className="px-4 py-5 text-sm text-center" style={{ color: p.textMuted }}>
|
||||
No results for “{query}”
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-1 max-h-60 overflow-y-auto">
|
||||
{items.map((item, i) => {
|
||||
const badge = getBadge(item.type);
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => onSelect(item)}
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 transition-colors text-left cursor-pointer"
|
||||
style={{ background: i === selectedIndex ? p.popupSelected : 'transparent' }}
|
||||
>
|
||||
{item.avatar ? (
|
||||
<img src={item.avatar} alt={item.label} className="w-7 h-7 rounded-full shrink-0" />
|
||||
) : (
|
||||
<span
|
||||
className="w-7 h-7 rounded-full shrink-0 flex items-center justify-center text-xs font-semibold"
|
||||
style={{ background: p === DARK ? '#2a2a30' : '#eeeef0', color: p.text }}
|
||||
>
|
||||
return (
|
||||
<div
|
||||
className="absolute left-0 z-50 overflow-hidden"
|
||||
style={{
|
||||
background: p.popupBg,
|
||||
border: `1px solid ${p.popupBorder}`,
|
||||
borderRadius: 10,
|
||||
boxShadow: p === DARK ? '0 12px 40px rgba(0,0,0,0.55)' : '0 8px 30px rgba(0,0,0,0.09)',
|
||||
minWidth: 240,
|
||||
maxWidth: 300,
|
||||
}}
|
||||
>
|
||||
{items.length === 0 ? (
|
||||
<div className="px-4 py-5 text-sm text-center" style={{color: p.textMuted}}>
|
||||
No results for “{query}”
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-1 max-h-60 overflow-y-auto">
|
||||
{items.map((item, i) => {
|
||||
const badge = getBadge(item.type);
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => onSelect(item)}
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 transition-colors text-left cursor-pointer"
|
||||
style={{background: i === selectedIndex ? p.popupSelected : 'transparent'}}
|
||||
>
|
||||
{item.avatar ? (
|
||||
<img src={item.avatar} alt={item.label} className="w-7 h-7 rounded-full shrink-0"/>
|
||||
) : (
|
||||
<span
|
||||
className="w-7 h-7 rounded-full shrink-0 flex items-center justify-center text-xs font-semibold"
|
||||
style={{background: p === DARK ? '#2a2a30' : '#eeeef0', color: p.text}}
|
||||
>
|
||||
{item.label.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex-1 truncate text-sm font-medium" style={{ color: p.text }}>
|
||||
)}
|
||||
<span className="flex-1 truncate text-sm font-medium" style={{color: p.text}}>
|
||||
{item.label}
|
||||
</span>
|
||||
{badge && (
|
||||
<span className={cn('shrink-0 text-[10px] font-bold px-1.5 py-0.5 rounded-full', badge.cls)}>
|
||||
{badge && (
|
||||
<span
|
||||
className={cn('shrink-0 text-[10px] font-bold px-1.5 py-0.5 rounded-full', badge.cls)}>
|
||||
{badge.label}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ────────────────────────────────────────────────────────────
|
||||
|
||||
export const IMEditor = forwardRef<IMEditorHandle, IMEditorProps>(function IMEditor(
|
||||
{ replyingTo, onCancelReply, onSend, mentionItems, onUploadFile, placeholder = 'Message…' },
|
||||
ref,
|
||||
{replyingTo, onCancelReply, onSend, mentionItems, onUploadFile, placeholder = 'Message…'},
|
||||
ref,
|
||||
) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const p = resolvedTheme === 'dark' ? DARK : LIGHT;
|
||||
const { compress } = useImageCompress();
|
||||
const {resolvedTheme} = useTheme();
|
||||
const p = resolvedTheme === 'dark' ? DARK : LIGHT;
|
||||
const {compress} = useImageCompress();
|
||||
|
||||
const [showEmoji, setShowEmoji] = useState(false);
|
||||
const [mentionOpen, setMentionOpen] = useState(false);
|
||||
const [mentionQuery, setMentionQuery] = useState('');
|
||||
const [mentionItems2, setMentionItems2] = useState<MentionItem[]>([]);
|
||||
const [mentionIdx, setMentionIdx] = useState(0);
|
||||
const [, setMentionPos] = useState({ top: 0, left: 0 });
|
||||
const [focused, setFocused] = useState(false);
|
||||
const [showEmoji, setShowEmoji] = useState(false);
|
||||
const [mentionOpen, setMentionOpen] = useState(false);
|
||||
const [mentionQuery, setMentionQuery] = useState('');
|
||||
const [mentionItems2, setMentionItems2] = useState<MentionItem[]>([]);
|
||||
const [mentionIdx, setMentionIdx] = useState(0);
|
||||
const [, setMentionPos] = useState({top: 0, left: 0});
|
||||
const [focused, setFocused] = useState(false);
|
||||
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const allItems = [
|
||||
...mentionItems.users,
|
||||
...mentionItems.channels,
|
||||
...mentionItems.ai,
|
||||
...mentionItems.commands,
|
||||
];
|
||||
const allItems = [
|
||||
...mentionItems.users,
|
||||
...mentionItems.channels,
|
||||
...mentionItems.ai,
|
||||
...mentionItems.commands,
|
||||
];
|
||||
|
||||
const selectMention = useCallback((item: MentionItem) => {
|
||||
if (!editor) return;
|
||||
// Use backend-parseable format: @[type:id:label]
|
||||
const mentionStr = `@[${item.type}:${item.id}:${item.label}] `;
|
||||
editor.chain().focus().insertContent(mentionStr).run();
|
||||
setMentionOpen(false);
|
||||
}, []);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({ undoRedo: { depth: 100 } }),
|
||||
Placeholder.configure({ placeholder }),
|
||||
CustomEmojiNode,
|
||||
KeyboardSend,
|
||||
],
|
||||
editorProps: {
|
||||
handlePaste: (_v, e) => {
|
||||
const img = Array.from(e.clipboardData?.items ?? []).find(i => i.type.startsWith('image'));
|
||||
if (img) {
|
||||
e.preventDefault();
|
||||
const file = img.getAsFile();
|
||||
if (file && onUploadFile) void doUpload(file);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
handleDrop: (_v, e) => {
|
||||
if (e.dataTransfer?.files?.[0] && onUploadFile) {
|
||||
e.preventDefault();
|
||||
void doUpload(e.dataTransfer.files[0]);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
onUpdate: ({ editor: ed }) => {
|
||||
const text = ed.getText();
|
||||
const { from } = ed.state.selection;
|
||||
|
||||
let ts = from;
|
||||
for (let i = from - 1; i >= 1; i--) {
|
||||
const c = text[i - 1];
|
||||
if (c === '@') { ts = i; break; }
|
||||
if (/\s/.test(c)) break;
|
||||
}
|
||||
const q = text.slice(ts - 1, from);
|
||||
|
||||
if (q.startsWith('@') && q.length > 1) {
|
||||
const results = filterMentionItems(allItems, q.slice(1));
|
||||
setMentionQuery(q.slice(1));
|
||||
setMentionItems2(results);
|
||||
setMentionIdx(0);
|
||||
setMentionOpen(true);
|
||||
|
||||
if (wrapRef.current) {
|
||||
const sel = window.getSelection();
|
||||
if (sel?.rangeCount) {
|
||||
const r = sel.getRangeAt(0).getBoundingClientRect();
|
||||
const cr = wrapRef.current.getBoundingClientRect();
|
||||
setMentionPos({ top: r.bottom - cr.top + 6, left: Math.max(0, r.left - cr.left) });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const selectMention = useCallback((item: MentionItem) => {
|
||||
if (!editor) return;
|
||||
// Use backend-parseable format: @[type:id:label]
|
||||
const mentionStr = `@[${item.type}:${item.id}:${item.label}] `;
|
||||
editor.chain().focus().insertContent(mentionStr).run();
|
||||
setMentionOpen(false);
|
||||
}
|
||||
},
|
||||
onFocus: () => setFocused(true),
|
||||
onBlur: () => setFocused(false),
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (editor) (editor.storage as any).keyboardSend = { onSend };
|
||||
}, [editor, onSend]);
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({undoRedo: {depth: 100}}),
|
||||
Placeholder.configure({placeholder}),
|
||||
CustomEmojiNode,
|
||||
KeyboardSend,
|
||||
],
|
||||
editorProps: {
|
||||
handlePaste: (_v, e) => {
|
||||
const img = Array.from(e.clipboardData?.items ?? []).find(i => i.type.startsWith('image'));
|
||||
if (img) {
|
||||
e.preventDefault();
|
||||
const file = img.getAsFile();
|
||||
if (file && onUploadFile) void doUpload(file);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
handleDrop: (_v, e) => {
|
||||
if (e.dataTransfer?.files?.[0] && onUploadFile) {
|
||||
e.preventDefault();
|
||||
void doUpload(e.dataTransfer.files[0]);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
onUpdate: ({editor: ed}) => {
|
||||
const text = ed.getText();
|
||||
const {from} = ed.state.selection;
|
||||
|
||||
const doUpload = async (file: File) => {
|
||||
if (!editor || !onUploadFile) return;
|
||||
try {
|
||||
// Compress image before upload (only if it's an image and > 500KB)
|
||||
let uploadFile = file;
|
||||
if (file.type.startsWith('image/') && file.size > 500 * 1024) {
|
||||
const result = await compress(file, { maxSizeMB: 1, maxWidthOrHeight: 1920, useWebWorker: true });
|
||||
uploadFile = result.file;
|
||||
}
|
||||
const res = await onUploadFile(uploadFile);
|
||||
editor.chain().focus().insertContent({ type: 'file', attrs: { id: res.id, name: uploadFile.name, url: res.url, size: uploadFile.size, type: uploadFile.type, status: 'done' } }).insertContent(' ').run();
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
let ts = from;
|
||||
for (let i = from - 1; i >= 1; i--) {
|
||||
const c = text[i - 1];
|
||||
if (c === '@') {
|
||||
ts = i;
|
||||
break;
|
||||
}
|
||||
if (/\s/.test(c)) break;
|
||||
}
|
||||
const q = text.slice(ts - 1, from);
|
||||
|
||||
const send = () => {
|
||||
if (!editor || editor.isEmpty) return;
|
||||
const text = editor.getText().trim();
|
||||
if (!text) return;
|
||||
onSend(text, editor.getJSON() as MessageAST);
|
||||
editor.commands.clearContent();
|
||||
};
|
||||
if (q.startsWith('@') && q.length > 1) {
|
||||
const results = filterMentionItems(allItems, q.slice(1));
|
||||
setMentionQuery(q.slice(1));
|
||||
setMentionItems2(results);
|
||||
setMentionIdx(0);
|
||||
setMentionOpen(true);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: () => editor?.commands.focus(),
|
||||
clearContent: () => editor?.commands.clearContent(),
|
||||
getContent: () => editor?.getText() ?? '',
|
||||
insertMention: (type: string, id: string, label: string) => {
|
||||
if (!editor) return;
|
||||
const mentionStr = `@[${type}:${id}:${label}] `;
|
||||
editor.chain().focus().insertContent(mentionStr).run();
|
||||
},
|
||||
}));
|
||||
if (wrapRef.current) {
|
||||
const sel = window.getSelection();
|
||||
if (sel?.rangeCount) {
|
||||
const r = sel.getRangeAt(0).getBoundingClientRect();
|
||||
const cr = wrapRef.current.getBoundingClientRect();
|
||||
setMentionPos({top: r.bottom - cr.top + 6, left: Math.max(0, r.left - cr.left)});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setMentionOpen(false);
|
||||
}
|
||||
},
|
||||
onFocus: () => setFocused(true),
|
||||
onBlur: () => setFocused(false),
|
||||
});
|
||||
|
||||
const hasContent = !!editor && !editor.isEmpty;
|
||||
useEffect(() => {
|
||||
if (editor) (editor.storage as any).keyboardSend = {onSend};
|
||||
}, [editor, onSend]);
|
||||
|
||||
// Dynamic styles
|
||||
const borderColor = focused ? p.borderFocus : p.border;
|
||||
const boxShadow = focused
|
||||
? (p === DARK ? `0 0 0 3px rgba(74,158,255,0.18)` : `0 0 0 3px rgba(28,125,237,0.12)`)
|
||||
: 'none';
|
||||
const doUpload = async (file: File) => {
|
||||
if (!editor || !onUploadFile) return;
|
||||
try {
|
||||
// Compress image before upload (only if it's an image and > 500KB)
|
||||
let uploadFile = file;
|
||||
if (file.type.startsWith('image/') && file.size > 500 * 1024) {
|
||||
const result = await compress(file, {maxSizeMB: 1, maxWidthOrHeight: 1920, useWebWorker: true});
|
||||
uploadFile = result.file;
|
||||
}
|
||||
const res = await onUploadFile(uploadFile);
|
||||
editor.chain().focus().insertContent({
|
||||
type: 'file',
|
||||
attrs: {
|
||||
id: res.id,
|
||||
name: uploadFile.name,
|
||||
url: res.url,
|
||||
size: uploadFile.size,
|
||||
type: uploadFile.type,
|
||||
status: 'done'
|
||||
}
|
||||
}).insertContent(' ').run();
|
||||
} catch { /* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col" ref={wrapRef}>
|
||||
{/* Reply strip */}
|
||||
{replyingTo && (
|
||||
<div
|
||||
className="flex items-center gap-3 px-4 py-2.5"
|
||||
style={{ background: p.replyBg, borderRadius: '12px 12px 0 0', borderBottom: `1px solid ${p.border}` }}
|
||||
>
|
||||
<div className="flex-1 min-w-0 flex items-center gap-2">
|
||||
<span className="text-[11px] font-semibold shrink-0" style={{ color: p.borderFocus }}>Replying to</span>
|
||||
<span className="truncate text-sm font-medium" style={{ color: p.text }}>{replyingTo.display_name}</span>
|
||||
<span className="truncate shrink-1 text-sm" style={{ color: p.textMuted }}>— {replyingTo.content}</span>
|
||||
</div>
|
||||
{onCancelReply && (
|
||||
<button onClick={onCancelReply} className="flex items-center justify-center w-6 h-6 rounded-full cursor-pointer shrink-0 transition-colors" style={{ color: p.icon }}>
|
||||
<X size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
const send = () => {
|
||||
if (!editor || editor.isEmpty) return;
|
||||
const text = editor.getText().trim();
|
||||
if (!text) return;
|
||||
onSend(text, editor.getJSON() as MessageAST);
|
||||
editor.commands.clearContent();
|
||||
};
|
||||
|
||||
{/* Input area */}
|
||||
<div
|
||||
onClick={() => editor?.commands.focus()}
|
||||
style={{
|
||||
background: p.bg,
|
||||
border: `1.5px solid ${borderColor}`,
|
||||
borderRadius: replyingTo ? '0 0 14px 14px' : '14px',
|
||||
boxShadow,
|
||||
transition: 'border-color 160ms ease, box-shadow 160ms ease',
|
||||
}}
|
||||
>
|
||||
{/* Mention dropdown */}
|
||||
{mentionOpen && (
|
||||
<MentionDropdown
|
||||
items={mentionItems2}
|
||||
selectedIndex={mentionIdx}
|
||||
onSelect={selectMention}
|
||||
p={p}
|
||||
query={mentionQuery}
|
||||
/>
|
||||
)}
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: () => editor?.commands.focus(),
|
||||
clearContent: () => editor?.commands.clearContent(),
|
||||
getContent: () => editor?.getText() ?? '',
|
||||
insertMention: (type: string, id: string, label: string) => {
|
||||
if (!editor) return;
|
||||
const mentionStr = `@[${type}:${id}:${label}] `;
|
||||
editor.chain().focus().insertContent(mentionStr).run();
|
||||
},
|
||||
getAttachmentIds: () => {
|
||||
if (!editor) return [];
|
||||
const json = editor.getJSON();
|
||||
const ids: string[] = [];
|
||||
const walk = (node: Record<string, unknown>) => {
|
||||
if (node['type'] === 'file' && node['attrs']) {
|
||||
const attrs = node['attrs'] as Record<string, unknown>;
|
||||
if (attrs['id']) ids.push(String(attrs['id']));
|
||||
}
|
||||
const children = node['content'] as Record<string, unknown>[] | undefined;
|
||||
if (children) children.forEach(walk);
|
||||
};
|
||||
walk(json as Record<string, unknown>);
|
||||
return ids;
|
||||
},
|
||||
}));
|
||||
|
||||
{/* Editor */}
|
||||
<div
|
||||
className="ai-editor"
|
||||
style={{ padding: '12px 14px 10px' }}
|
||||
>
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
const hasContent = !!editor && !editor.isEmpty;
|
||||
|
||||
{/* Discord-style toolbar: icons left, send right */}
|
||||
<div className="flex items-center justify-between px-2 pb-2">
|
||||
{/* Left — emoji + attach */}
|
||||
<div className="flex items-center gap-0.5">
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setShowEmoji(v => !v); }}
|
||||
className="flex items-center justify-center w-8 h-8 rounded-lg transition-colors cursor-pointer"
|
||||
style={{ color: showEmoji ? p.iconHover : p.icon, background: showEmoji ? p.bgActive : 'transparent' }}
|
||||
title="Emoji"
|
||||
>
|
||||
<Smile size={18} />
|
||||
</button>
|
||||
{showEmoji && <EmojiPicker onClose={() => setShowEmoji(false)} onSelect={(emoji) => { editor?.chain().focus().insertContent(emoji).insertContent(' ').run(); setShowEmoji(false); }} p={p} />}
|
||||
// Dynamic styles
|
||||
const borderColor = focused ? p.borderFocus : p.border;
|
||||
const boxShadow = focused
|
||||
? (p === DARK ? `0 0 0 3px rgba(74,158,255,0.18)` : `0 0 0 3px rgba(28,125,237,0.12)`)
|
||||
: 'none';
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col mt-2 ml-3 mr-3 mb-1" ref={wrapRef}>
|
||||
{replyingTo && (
|
||||
<div
|
||||
className="flex items-center gap-3 px-4 py-2.5"
|
||||
style={{
|
||||
background: p.replyBg,
|
||||
borderRadius: '12px 12px 0 0',
|
||||
borderBottom: `1px solid ${p.border}`
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 min-w-0 flex items-center gap-2">
|
||||
<span className="text-[11px] font-semibold shrink-0"
|
||||
style={{color: p.borderFocus}}>Replying to</span>
|
||||
<span className="truncate text-sm font-medium"
|
||||
style={{color: p.text}}>{replyingTo.display_name}</span>
|
||||
<span className="truncate shrink-1 text-sm"
|
||||
style={{color: p.textMuted}}>— {replyingTo.content}</span>
|
||||
</div>
|
||||
{onCancelReply && (
|
||||
<button onClick={onCancelReply}
|
||||
className="flex items-center justify-center w-6 h-6 rounded-full cursor-pointer shrink-0 transition-colors"
|
||||
style={{color: p.icon}}>
|
||||
<X size={13}/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input area */}
|
||||
<div
|
||||
onClick={() => editor?.commands.focus()}
|
||||
style={{
|
||||
background: p.bg,
|
||||
border: `1.5px solid ${borderColor}`,
|
||||
borderRadius: replyingTo ? '0 0 14px 14px' : '14px',
|
||||
boxShadow,
|
||||
transition: 'border-color 160ms ease, box-shadow 160ms ease',
|
||||
}}
|
||||
>
|
||||
{/* Mention dropdown */}
|
||||
{mentionOpen && (
|
||||
<MentionDropdown
|
||||
items={mentionItems2}
|
||||
selectedIndex={mentionIdx}
|
||||
onSelect={selectMention}
|
||||
p={p}
|
||||
query={mentionQuery}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Editor */}
|
||||
<div
|
||||
className="ai-editor"
|
||||
style={{padding: '12px 14px 10px'}}
|
||||
>
|
||||
<EditorContent editor={editor}/>
|
||||
</div>
|
||||
|
||||
{/* Discord-style toolbar: icons left, send right */}
|
||||
<div className="flex items-center justify-between px-2 pb-2">
|
||||
{/* Left — emoji + attach */}
|
||||
<div className="flex items-center gap-0.5">
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowEmoji(v => !v);
|
||||
}}
|
||||
className="flex items-center justify-center w-8 h-8 rounded-lg transition-colors cursor-pointer"
|
||||
style={{
|
||||
color: showEmoji ? p.iconHover : p.icon,
|
||||
background: showEmoji ? p.bgActive : 'transparent'
|
||||
}}
|
||||
title="Emoji"
|
||||
>
|
||||
<Smile size={18}/>
|
||||
</button>
|
||||
{showEmoji && <EmojiPicker onClose={() => setShowEmoji(false)} onSelect={(emoji) => {
|
||||
editor?.chain().focus().insertContent(emoji).insertContent(' ').run();
|
||||
setShowEmoji(false);
|
||||
}} p={p}/>}
|
||||
</div>
|
||||
|
||||
<label
|
||||
className="flex items-center justify-center w-8 h-8 rounded-lg transition-colors cursor-pointer"
|
||||
style={{color: p.icon}}
|
||||
title="Attach file"
|
||||
>
|
||||
<Paperclip size={18}/>
|
||||
<input type="file" className="hidden" onChange={e => {
|
||||
e.stopPropagation();
|
||||
const f = e.target.files?.[0];
|
||||
if (f && onUploadFile) void doUpload(f);
|
||||
e.target.value = '';
|
||||
}}/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Right — hint + send */}
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="text-[11px]" style={{color: p.textSubtle}}>↵ send</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
send();
|
||||
}}
|
||||
disabled={!hasContent}
|
||||
className="flex items-center justify-center w-8 h-8 rounded-full transition-all duration-150 cursor-pointer"
|
||||
style={{
|
||||
background: hasContent ? p.sendBg : p.sendDisabled,
|
||||
color: hasContent ? p.sendIcon : p.icon,
|
||||
opacity: hasContent ? 1 : 0.55,
|
||||
transform: hasContent ? 'scale(1)' : 'scale(0.92)',
|
||||
}}
|
||||
title="Send"
|
||||
>
|
||||
<Send size={13}/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label
|
||||
className="flex items-center justify-center w-8 h-8 rounded-lg transition-colors cursor-pointer"
|
||||
style={{ color: p.icon }}
|
||||
title="Attach file"
|
||||
>
|
||||
<Paperclip size={18} />
|
||||
<input type="file" className="hidden" onChange={e => { e.stopPropagation(); const f = e.target.files?.[0]; if (f && onUploadFile) void doUpload(f); e.target.value = ''; }} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Right — hint + send */}
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="text-[11px]" style={{ color: p.textSubtle }}>↵ send</span>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); send(); }}
|
||||
disabled={!hasContent}
|
||||
className="flex items-center justify-center w-8 h-8 rounded-full transition-all duration-150 cursor-pointer"
|
||||
style={{
|
||||
background: hasContent ? p.sendBg : p.sendDisabled,
|
||||
color: hasContent ? p.sendIcon : p.icon,
|
||||
opacity: hasContent ? 1 : 0.55,
|
||||
transform: hasContent ? 'scale(1)' : 'scale(0.92)',
|
||||
}}
|
||||
title="Send"
|
||||
>
|
||||
<Send size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
<style>{`
|
||||
.ai-editor .ProseMirror {
|
||||
outline: none;
|
||||
white-space: pre-wrap;
|
||||
@ -495,8 +553,8 @@ export const IMEditor = forwardRef<IMEditorHandle, IMEditorProps>(function IMEdi
|
||||
}
|
||||
.ai-editor .ProseMirror::-webkit-scrollbar { display: none; }
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
IMEditor.displayName = 'IMEditor';
|
||||
|
||||
@ -63,6 +63,8 @@ export type MessageWithMeta = RoomMessageResponse & {
|
||||
/** True for messages sent by the current user that haven't been confirmed by the server */
|
||||
isOptimistic?: boolean;
|
||||
reactions?: ReactionGroup[];
|
||||
/** Attachment IDs for files uploaded with this message */
|
||||
attachment_ids?: string[];
|
||||
};
|
||||
|
||||
export type RoomWithCategory = RoomResponse & {
|
||||
@ -119,7 +121,7 @@ interface RoomContextValue {
|
||||
isTransitioningRoom: boolean;
|
||||
nextCursor: number | null;
|
||||
loadMore: (cursor?: number | null) => void;
|
||||
sendMessage: (content: string, contentType?: string, inReplyTo?: string) => Promise<void>;
|
||||
sendMessage: (content: string, contentType?: string, inReplyTo?: string, attachmentIds?: string[]) => Promise<void>;
|
||||
editMessage: (messageId: string, content: string) => Promise<void>;
|
||||
revokeMessage: (messageId: string) => Promise<void>;
|
||||
updateReadSeq: (seq: number) => Promise<void>;
|
||||
@ -837,7 +839,7 @@ export function RoomProvider({
|
||||
const sendingRef = useRef(false);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
async (content: string, contentType = 'text', inReplyTo?: string) => {
|
||||
async (content: string, contentType = 'text', inReplyTo?: string, attachmentIds?: string[]) => {
|
||||
const client = wsClientRef.current;
|
||||
if (!activeRoomId || !client) return;
|
||||
if (sendingRef.current) return;
|
||||
@ -861,6 +863,7 @@ export function RoomProvider({
|
||||
thread_id: inReplyTo,
|
||||
in_reply_to: inReplyTo,
|
||||
reactions: [],
|
||||
attachment_ids: attachmentIds,
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, optimisticMsg]);
|
||||
@ -869,6 +872,7 @@ export function RoomProvider({
|
||||
const confirmedMsg = await client.messageCreate(activeRoomId, content, {
|
||||
contentType,
|
||||
inReplyTo,
|
||||
attachmentIds,
|
||||
});
|
||||
// Replace optimistic message with server-confirmed one
|
||||
setMessages((prev) => {
|
||||
|
||||
646
src/index.css
646
src/index.css
@ -4,211 +4,342 @@
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--font-heading: var(--font-sans);
|
||||
--font-sans: 'Geist Variable', sans-serif;
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-background: var(--background);
|
||||
/* ══════════════════════════════════════════════════════════════════════════════
|
||||
DESIGN TOKEN LAYER
|
||||
Structure: Primitive → Semantic → Component
|
||||
Philosophy: Linear/Vercel dual-color (neutral monochrome + single indigo accent)
|
||||
══════════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ── Layer 1: Primitive tokens ─────────────────────────────────────────────── */
|
||||
/* These are never used directly in components — always reference via semantic */
|
||||
|
||||
/* Light primitives */
|
||||
@layer primitives {
|
||||
:root {
|
||||
/* Neutral scale (achromatic gray ramp) */
|
||||
--p-gray-50: oklch(0.995 0 0);
|
||||
--p-gray-100: oklch(0.985 0 0);
|
||||
--p-gray-200: oklch(0.967 0 0);
|
||||
--p-gray-300: oklch(0.91 0 0);
|
||||
--p-gray-400: oklch(0.70 0 0);
|
||||
--p-gray-500: oklch(0.55 0 0);
|
||||
--p-gray-600: oklch(0.40 0 0);
|
||||
--p-gray-700: oklch(0.30 0 0);
|
||||
--p-gray-800: oklch(0.20 0 0);
|
||||
--p-gray-900: oklch(0.135 0 0);
|
||||
--p-gray-950: oklch(0.11 0 0);
|
||||
|
||||
/* Brand accent (single hue: indigo) */
|
||||
--p-accent-50: oklch(0.95 0.05 265);
|
||||
--p-accent-100: oklch(0.88 0.08 265);
|
||||
--p-accent-200: oklch(0.78 0.11 265);
|
||||
--p-accent-300: oklch(0.65 0.14 265);
|
||||
--p-accent-400: oklch(0.55 0.17 265);
|
||||
--p-accent-500: oklch(0.42 0.19 265);
|
||||
--p-accent-600: oklch(0.35 0.20 265);
|
||||
--p-accent-700: oklch(0.30 0.21 265);
|
||||
--p-accent-800: oklch(0.22 0.17 265);
|
||||
--p-accent-900: oklch(0.14 0.12 265);
|
||||
|
||||
/* Status */
|
||||
--p-success: oklch(0.55 0.14 160); /* green */
|
||||
--p-warning: oklch(0.72 0.14 75); /* yellow */
|
||||
--p-error: oklch(0.55 0.22 25); /* red */
|
||||
|
||||
/* Surface luminance — base for background */
|
||||
--p-surface-light: oklch(0.995 0 0);
|
||||
--p-surface-dark: oklch(0.13 0 0);
|
||||
|
||||
/* Shadows */
|
||||
--p-shadow-sm: 0 1px 2px oklch(0 0 0 / 0.04), 0 1px 3px oklch(0 0 0 / 0.06);
|
||||
--p-shadow-md: 0 4px 6px oklch(0 0 0 / 0.04), 0 2px 4px oklch(0 0 0 / 0.06);
|
||||
--p-shadow-lg: 0 10px 15px oklch(0 0 0 / 0.05), 0 4px 6px oklch(0 0 0 / 0.06);
|
||||
--p-shadow-xl: 0 8px 30px oklch(0 0 0 / 0.10), 0 2px 8px oklch(0 0 0 / 0.06);
|
||||
--p-shadow-focus-light: 0 0 0 3px oklch(0.42 0.19 265 / 15%);
|
||||
--p-shadow-focus-dark: 0 0 0 3px oklch(0.60 0.17 265 / 25%);
|
||||
|
||||
/* Radius base */
|
||||
--p-radius-sm: 0.25rem;
|
||||
--p-radius-md: 0.375rem;
|
||||
--p-radius-lg: 0.5rem;
|
||||
--p-radius-xl: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Layer 2: Semantic tokens ─────────────────────────────────────────────── */
|
||||
/* Maps primitives to roles. Theme-switching happens HERE only. */
|
||||
|
||||
@layer semantic {
|
||||
:root {
|
||||
/* Background / foreground */
|
||||
--bg: var(--p-gray-50);
|
||||
--fg: var(--p-gray-900);
|
||||
--fg-muted: var(--p-gray-500);
|
||||
--fg-subtle: var(--p-gray-400);
|
||||
|
||||
/* Surface hierarchy */
|
||||
--surface-1: var(--p-gray-50); /* page bg */
|
||||
--surface-2: var(--p-gray-100); /* cards */
|
||||
--surface-3: var(--p-gray-200); /* hover, input bg */
|
||||
|
||||
/* Border */
|
||||
--border: var(--p-gray-300);
|
||||
--border-2: var(--p-gray-200); /* subtle dividers */
|
||||
|
||||
/* Brand accent */
|
||||
--accent: var(--p-accent-500);
|
||||
--accent-hover: var(--p-accent-400);
|
||||
--accent-subtle: oklch(0.42 0.19 265 / 10%);
|
||||
--accent-fg: var(--p-gray-50);
|
||||
|
||||
/* Destructive */
|
||||
--destructive: var(--p-error);
|
||||
--destructive-fg: var(--p-gray-50);
|
||||
|
||||
/* Status */
|
||||
--success: var(--p-success);
|
||||
--success-subtle: oklch(0.55 0.14 160 / 12%);
|
||||
--warning: var(--p-warning);
|
||||
--warning-subtle: oklch(0.72 0.14 75 / 12%);
|
||||
--error: var(--p-error);
|
||||
--error-subtle: oklch(0.55 0.22 25 / 12%);
|
||||
|
||||
/* Ring / focus */
|
||||
--ring: var(--accent);
|
||||
--focus: var(--p-shadow-focus-light);
|
||||
|
||||
/* Chart — monochrome ramp */
|
||||
--chart-1: var(--p-gray-600);
|
||||
--chart-2: var(--p-gray-500);
|
||||
--chart-3: var(--p-gray-400);
|
||||
--chart-4: var(--p-gray-300);
|
||||
--chart-5: var(--p-gray-200);
|
||||
|
||||
/* Radius */
|
||||
--radius: var(--p-radius-md);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-lg: calc(var(--radius) * 1.0);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
|
||||
/* ── Discord layout tokens ─────────────────────────────────────────────── */
|
||||
--color-discord-bg: var(--room-bg);
|
||||
--color-discord-sidebar: var(--room-sidebar);
|
||||
--color-discord-channel-hover: var(--room-channel-hover);
|
||||
/* Shadows */
|
||||
--shadow-sm: var(--p-shadow-sm);
|
||||
--shadow-md: var(--p-shadow-md);
|
||||
--shadow-lg: var(--p-shadow-lg);
|
||||
--shadow-xl: var(--p-shadow-xl);
|
||||
|
||||
/* Sidebar */
|
||||
--sidebar-bg: var(--p-gray-100);
|
||||
--sidebar-fg: var(--p-gray-900);
|
||||
--sidebar-border: var(--p-gray-300);
|
||||
--sidebar-accent: var(--p-gray-200);
|
||||
--sidebar-accent-fg: var(--p-gray-700);
|
||||
--sidebar-primary: var(--accent);
|
||||
--sidebar-primary-fg: var(--p-gray-50);
|
||||
|
||||
/* Room / chat */
|
||||
--room-bg: var(--p-gray-50);
|
||||
--room-sidebar: var(--p-gray-100);
|
||||
--room-sidebar-fg: var(--p-gray-900);
|
||||
--room-hover: var(--p-gray-200);
|
||||
--room-border: var(--p-gray-300);
|
||||
--room-channel-active: var(--accent-subtle);
|
||||
--room-text: var(--p-gray-900);
|
||||
--room-text-secondary: var(--p-gray-600);
|
||||
--room-text-muted: var(--p-gray-500);
|
||||
--room-text-subtle: var(--p-gray-400);
|
||||
--room-accent: var(--accent);
|
||||
--room-accent-hover: var(--accent-hover);
|
||||
--room-mention-bg: var(--accent);
|
||||
--room-mention-fg: var(--p-gray-50);
|
||||
--room-online: var(--p-success);
|
||||
--room-away: var(--p-warning);
|
||||
--room-offline: var(--p-gray-400);
|
||||
}
|
||||
|
||||
/* Dark theme — only semantic overrides, primitives stay the same */
|
||||
.dark {
|
||||
--bg: var(--p-gray-950);
|
||||
--fg: var(--p-gray-50);
|
||||
--fg-muted: var(--p-gray-400);
|
||||
--fg-subtle: var(--p-gray-600);
|
||||
|
||||
--surface-1: var(--p-gray-950);
|
||||
--surface-2: var(--p-gray-900);
|
||||
--surface-3: var(--p-gray-800);
|
||||
|
||||
--border: oklch(1 0 0 / 8%);
|
||||
--border-2: oklch(1 0 0 / 5%);
|
||||
|
||||
--accent: var(--p-accent-600);
|
||||
--accent-hover: var(--p-accent-500);
|
||||
--accent-subtle: oklch(0.60 0.17 265 / 14%);
|
||||
--accent-fg: var(--p-gray-950);
|
||||
|
||||
--destructive: oklch(0.65 0.18 25);
|
||||
--destructive-fg: var(--p-gray-950);
|
||||
|
||||
--success: var(--p-success);
|
||||
--success-subtle: oklch(0.65 0.13 160 / 15%);
|
||||
--warning: var(--p-warning);
|
||||
--warning-subtle: oklch(0.68 0.14 75 / 15%);
|
||||
--error: var(--p-error);
|
||||
--error-subtle: oklch(0.65 0.18 25 / 15%);
|
||||
|
||||
--ring: var(--accent);
|
||||
--focus: var(--p-shadow-focus-dark);
|
||||
|
||||
--chart-1: var(--p-gray-200);
|
||||
--chart-2: var(--p-gray-300);
|
||||
--chart-3: var(--p-gray-400);
|
||||
--chart-4: var(--p-gray-500);
|
||||
--chart-5: var(--p-gray-600);
|
||||
|
||||
--shadow-sm: 0 1px 2px oklch(0 0 0 / 0.30), 0 1px 3px oklch(0 0 0 / 0.40);
|
||||
--shadow-md: 0 4px 6px oklch(0 0 0 / 0.30), 0 2px 4px oklch(0 0 0 / 0.40);
|
||||
--shadow-lg: 0 10px 15px oklch(0 0 0 / 0.35), 0 4px 6px oklch(0 0 0 / 0.40);
|
||||
--shadow-xl: 0 8px 30px oklch(0 0 0 / 0.50), 0 2px 8px oklch(0 0 0 / 0.40);
|
||||
|
||||
--sidebar-bg: var(--p-gray-950);
|
||||
--sidebar-fg: var(--p-gray-50);
|
||||
--sidebar-border: oklch(1 0 0 / 8%);
|
||||
--sidebar-accent: var(--p-gray-800);
|
||||
--sidebar-accent-fg: var(--p-gray-200);
|
||||
--sidebar-primary: var(--accent);
|
||||
--sidebar-primary-fg: var(--p-gray-950);
|
||||
|
||||
--room-bg: var(--p-gray-950);
|
||||
--room-sidebar: var(--p-gray-900);
|
||||
--room-sidebar-fg: var(--p-gray-50);
|
||||
--room-hover: var(--p-gray-800);
|
||||
--room-border: oklch(1 0 0 / 8%);
|
||||
--room-channel-active: var(--accent-subtle);
|
||||
--room-text: var(--p-gray-50);
|
||||
--room-text-secondary: var(--p-gray-200);
|
||||
--room-text-muted: oklch(1 0 0 / 55%);
|
||||
--room-text-subtle: oklch(1 0 0 / 38%);
|
||||
--room-accent: var(--accent);
|
||||
--room-accent-hover: var(--accent-hover);
|
||||
--room-mention-bg: var(--accent);
|
||||
--room-mention-fg: var(--p-gray-950);
|
||||
--room-online: var(--p-success);
|
||||
--room-away: var(--p-warning);
|
||||
--room-offline: oklch(1 0 0 / 30%);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Layer 3: Tailwind @theme inline bridge ──────────────────────────────── */
|
||||
/* Maps semantic tokens → Tailwind utility classes */
|
||||
|
||||
@theme inline {
|
||||
--font-heading: var(--font-sans);
|
||||
--font-sans: 'Geist Variable', sans-serif;
|
||||
|
||||
--color-background: var(--bg);
|
||||
--color-foreground: var(--fg);
|
||||
--color-card: var(--surface-2);
|
||||
--color-card-foreground: var(--fg);
|
||||
--color-popover: var(--surface-2);
|
||||
--color-popover-foreground: var(--fg);
|
||||
--color-primary: var(--accent);
|
||||
--color-primary-foreground: var(--accent-fg);
|
||||
--color-secondary: var(--surface-3);
|
||||
--color-secondary-foreground: var(--fg);
|
||||
--color-muted: var(--surface-3);
|
||||
--color-muted-foreground: var(--fg-muted);
|
||||
--color-accent: var(--surface-3);
|
||||
--color-accent-foreground: var(--fg);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-fg);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--border);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
|
||||
--radius-sm: var(--radius-sm);
|
||||
--radius-md: var(--radius-md);
|
||||
--radius-lg: var(--radius-lg);
|
||||
--radius-xl: var(--radius-xl);
|
||||
--radius-2xl: var(--radius-2xl);
|
||||
--radius-3xl: var(--radius-3xl);
|
||||
--radius-4xl: var(--radius-4xl);
|
||||
|
||||
/* Sidebar */
|
||||
--color-sidebar: var(--sidebar-bg);
|
||||
--color-sidebar-foreground: var(--sidebar-fg);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-fg);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-fg);
|
||||
--color-sidebar-ring: var(--ring);
|
||||
|
||||
/* Room (discord-layout) — maps to semantic tokens */
|
||||
--color-discord-bg: var(--room-bg);
|
||||
--color-discord-sidebar: var(--room-sidebar);
|
||||
--color-discord-fg: var(--room-sidebar-fg);
|
||||
--color-discord-hover: var(--room-hover);
|
||||
--color-discord-border: var(--room-border);
|
||||
--color-discord-channel-active: var(--room-channel-active);
|
||||
--color-discord-mention-badge: var(--room-mention-badge);
|
||||
--color-discord-blurple: var(--room-accent);
|
||||
--color-discord-blurple-hover: var(--room-accent-hover);
|
||||
--color-discord-green: var(--room-online);
|
||||
--color-discord-red: oklch(0.63 0.21 25);
|
||||
--color-discord-yellow: oklch(0.75 0.17 80);
|
||||
--color-discord-online: var(--room-online);
|
||||
--color-discord-offline: var(--room-offline);
|
||||
--color-discord-idle: var(--room-away);
|
||||
--color-discord-mention-text: var(--room-mention-text);
|
||||
--color-discord-text: var(--room-text);
|
||||
--color-discord-text: var(--room-text);
|
||||
--color-discord-text-secondary: var(--room-text-secondary);
|
||||
--color-discord-text-muted: var(--room-text-muted);
|
||||
--color-discord-text-subtle: var(--room-text-subtle);
|
||||
--color-discord-border: var(--room-border);
|
||||
--color-discord-hover: var(--room-hover);
|
||||
--color-discord-placeholder: var(--room-text-muted);
|
||||
--color-discord-text-muted: var(--room-text-muted);
|
||||
--color-discord-text-subtle: var(--room-text-subtle);
|
||||
--color-discord-accent: var(--room-accent);
|
||||
--color-discord-accent-hover: var(--room-accent-hover);
|
||||
--color-discord-mention-bg: var(--room-mention-bg);
|
||||
--color-discord-mention-fg: var(--room-mention-fg);
|
||||
--color-discord-online: var(--room-online);
|
||||
--color-discord-away: var(--room-away);
|
||||
--color-discord-offline: var(--room-offline);
|
||||
--color-discord-success: var(--success);
|
||||
--color-discord-warning: var(--warning);
|
||||
--color-discord-error: var(--error);
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.488 0.243 264.376);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.488 0.243 264.376);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--radius: 0.5rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.488 0.243 264.376);
|
||||
|
||||
/* AI Studio room palette — light */
|
||||
--room-bg: oklch(0.995 0 0);
|
||||
--room-sidebar: oklch(0.99 0 0);
|
||||
--room-channel-hover: oklch(0.97 0 0);
|
||||
--room-channel-active: oklch(0.55 0.18 253 / 8%);
|
||||
--room-mention-badge: oklch(0.55 0.18 253);
|
||||
--room-accent: oklch(0.55 0.18 253);
|
||||
--room-accent-hover: oklch(0.52 0.19 253);
|
||||
--room-online: oklch(0.63 0.19 158);
|
||||
--room-offline: oklch(0.62 0 0 / 35%);
|
||||
--room-away: oklch(0.75 0.17 80);
|
||||
--room-text: oklch(0.145 0 0);
|
||||
--room-text-secondary: oklch(0.25 0 0);
|
||||
--room-text-muted: oklch(0.50 0 0);
|
||||
--room-text-subtle: oklch(0.68 0 0);
|
||||
--room-border: oklch(0.91 0 0);
|
||||
--room-hover: oklch(0.97 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.18 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.18 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.488 0.243 264.376);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.22 0 0);
|
||||
--muted-foreground: oklch(0.65 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 8%);
|
||||
--input: oklch(1 0 0 / 10%);
|
||||
--ring: oklch(0.488 0.243 264.376);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.13 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.22 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 8%);
|
||||
--sidebar-ring: oklch(0.488 0.243 264.376);
|
||||
|
||||
/* Discord dark theme */
|
||||
--discord-bg: oklch(0.145 0 0);
|
||||
--discord-sidebar: oklch(0.13 0 0);
|
||||
--discord-channel-hover: oklch(0.2 0 0);
|
||||
/* AI Studio room palette — dark */
|
||||
--room-bg: oklch(0.11 0 0);
|
||||
--room-sidebar: oklch(0.10 0 0);
|
||||
--room-channel-hover: oklch(0.16 0 0);
|
||||
--room-channel-active: oklch(0.58 0.18 253 / 12%);
|
||||
--room-mention-badge: oklch(0.58 0.18 253);
|
||||
--room-accent: oklch(0.58 0.18 253);
|
||||
--room-accent-hover: oklch(0.65 0.20 253);
|
||||
--room-online: oklch(0.65 0.17 158);
|
||||
--room-offline: oklch(0.50 0 0 / 35%);
|
||||
--room-away: oklch(0.72 0.16 80);
|
||||
--room-text: oklch(0.985 0 0);
|
||||
--room-text-secondary: oklch(0.985 0 0 / 80%);
|
||||
--room-text-muted: oklch(0.985 0 0 / 58%);
|
||||
--room-text-subtle: oklch(0.985 0 0 / 40%);
|
||||
--room-border: oklch(0 0 0 / 18%);
|
||||
--room-hover: oklch(0.16 0 0);
|
||||
}
|
||||
/* ── Layer 4: Base styles ──────────────────────────────────────────────────── */
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
@apply bg-background text-foreground antialiased;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Placeholder support for contenteditable MentionInput */
|
||||
[contenteditable][data-placeholder]:empty::before {
|
||||
content: attr(data-placeholder);
|
||||
color: var(--muted-foreground);
|
||||
color: var(--fg-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── Discord layout ──────────────────────────────────────────────────────── */
|
||||
/* ══════════════════════════════════════════════════════════════════════════════
|
||||
COMPONENT STYLES — all reference semantic tokens via CSS vars or Tailwind
|
||||
══════════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ── Discord layout ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.discord-layout {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--room-bg);
|
||||
color: var(--foreground);
|
||||
color: var(--room-text);
|
||||
}
|
||||
|
||||
/* Server sidebar (left icon strip) */
|
||||
.discord-server-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -232,8 +363,8 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
background: var(--room-channel-hover);
|
||||
color: var(--foreground);
|
||||
background: var(--room-hover);
|
||||
color: var(--room-sidebar-fg);
|
||||
transition: border-radius 200ms ease, background 200ms ease;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
@ -242,16 +373,11 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.discord-server-icon:hover {
|
||||
border-radius: 16px;
|
||||
background: var(--room-accent);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
.discord-server-icon:hover,
|
||||
.discord-server-icon.active {
|
||||
border-radius: 16px;
|
||||
background: var(--room-accent);
|
||||
color: var(--primary-foreground);
|
||||
color: var(--room-mention-fg);
|
||||
}
|
||||
|
||||
.discord-server-icon .home-icon {
|
||||
@ -259,11 +385,10 @@
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
/* Channel sidebar */
|
||||
.discord-channel-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 260px;
|
||||
width: 240px;
|
||||
background: var(--room-sidebar);
|
||||
border-right: 1px solid var(--room-border);
|
||||
flex-shrink: 0;
|
||||
@ -276,14 +401,13 @@
|
||||
height: 48px;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid var(--room-border);
|
||||
box-shadow: 0 1px 0 var(--room-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.discord-channel-header-title {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
color: var(--foreground);
|
||||
color: var(--room-sidebar-fg);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@ -294,20 +418,14 @@
|
||||
padding: 8px 8px 8px 0;
|
||||
}
|
||||
|
||||
.discord-channel-list::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.discord-channel-list::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.discord-channel-list::-webkit-scrollbar { width: 4px; }
|
||||
.discord-channel-list::-webkit-scrollbar-track { background: transparent; }
|
||||
.discord-channel-list::-webkit-scrollbar-thumb {
|
||||
background: var(--room-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.discord-channel-category {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.discord-channel-category { margin-bottom: 4px; }
|
||||
|
||||
.discord-channel-category-header {
|
||||
display: flex;
|
||||
@ -326,17 +444,9 @@
|
||||
transition: color 150ms;
|
||||
}
|
||||
|
||||
.discord-channel-category-header:hover {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.discord-channel-category-header svg {
|
||||
transition: transform 150ms;
|
||||
}
|
||||
|
||||
.discord-channel-category-header.collapsed svg {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
.discord-channel-category-header:hover { color: var(--room-sidebar-fg); }
|
||||
.discord-channel-category-header svg { transition: transform 150ms; }
|
||||
.discord-channel-category-header.collapsed svg { transform: rotate(-90deg); }
|
||||
|
||||
.discord-channel-item {
|
||||
display: flex;
|
||||
@ -360,7 +470,7 @@
|
||||
|
||||
.discord-channel-item.active {
|
||||
background: var(--room-channel-active);
|
||||
color: var(--foreground);
|
||||
color: var(--room-sidebar-fg);
|
||||
}
|
||||
|
||||
.discord-channel-item.active::before {
|
||||
@ -398,8 +508,8 @@
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: 9px;
|
||||
background: var(--room-mention-badge);
|
||||
color: var(--primary-foreground);
|
||||
background: var(--room-mention-bg);
|
||||
color: var(--room-mention-fg);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
@ -421,12 +531,9 @@
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
.discord-add-channel-btn:hover { color: var(--room-text); }
|
||||
|
||||
.discord-add-channel-btn:hover {
|
||||
color: var(--room-text);
|
||||
}
|
||||
|
||||
/* Member list sidebar */
|
||||
/* Member sidebar */
|
||||
.discord-member-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -470,15 +577,9 @@
|
||||
transition: background 100ms;
|
||||
user-select: none;
|
||||
}
|
||||
.discord-member-item:hover { background: var(--room-hover); }
|
||||
|
||||
.discord-member-item:hover {
|
||||
background: var(--room-hover);
|
||||
}
|
||||
|
||||
.discord-member-avatar-wrap {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.discord-member-avatar-wrap { position: relative; flex-shrink: 0; }
|
||||
|
||||
.discord-member-status-dot {
|
||||
position: absolute;
|
||||
@ -490,17 +591,12 @@
|
||||
border: 2px solid var(--room-sidebar);
|
||||
}
|
||||
|
||||
.discord-member-status-dot.online {
|
||||
background: var(--room-online);
|
||||
}
|
||||
.discord-member-status-dot.offline {
|
||||
background: var(--room-offline);
|
||||
}
|
||||
.discord-member-status-dot.idle {
|
||||
background: var(--room-away);
|
||||
}
|
||||
.discord-member-status-dot.online { background: var(--room-online); }
|
||||
.discord-member-status-dot.offline { background: var(--room-offline); }
|
||||
.discord-member-status-dot.idle { background: var(--room-away); }
|
||||
|
||||
/* ── Message list ──────────────────────────────────────────────────────────── */
|
||||
|
||||
/* ── Discord message bubbles ─────────────────────────────────────────────── */
|
||||
.discord-message-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
@ -508,12 +604,8 @@
|
||||
padding: 0 16px 8px;
|
||||
}
|
||||
|
||||
.discord-message-list::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
.discord-message-list::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.discord-message-list::-webkit-scrollbar { width: 8px; }
|
||||
.discord-message-list::-webkit-scrollbar-track { background: transparent; }
|
||||
.discord-message-list::-webkit-scrollbar-thumb {
|
||||
background: var(--room-border);
|
||||
border-radius: 4px;
|
||||
@ -521,10 +613,7 @@
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.discord-message-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.discord-message-group { display: flex; flex-direction: column; }
|
||||
|
||||
.discord-message-row {
|
||||
display: flex;
|
||||
@ -536,9 +625,7 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.discord-message-row:hover .discord-message-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
.discord-message-row:hover .discord-message-actions { opacity: 1; }
|
||||
|
||||
.discord-message-avatar {
|
||||
width: 40px;
|
||||
@ -549,15 +636,8 @@
|
||||
margin-top: -2px;
|
||||
}
|
||||
|
||||
.discord-message-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.discord-message-avatar-spacer {
|
||||
width: 40px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.discord-message-body { flex: 1; min-width: 0; }
|
||||
.discord-message-avatar-spacer { width: 40px; flex-shrink: 0; }
|
||||
|
||||
.discord-message-header {
|
||||
display: flex;
|
||||
@ -569,14 +649,11 @@
|
||||
.discord-message-author {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--foreground);
|
||||
color: var(--room-text);
|
||||
line-height: 1.2;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.discord-message-author:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.discord-message-author:hover { text-decoration: underline; }
|
||||
|
||||
.discord-message-time {
|
||||
font-size: 11px;
|
||||
@ -593,8 +670,8 @@
|
||||
}
|
||||
|
||||
.discord-message-content .mention {
|
||||
background: oklch(0.488 0.243 264.376 / 25%);
|
||||
color: var(--room-mention-text);
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent);
|
||||
padding: 0 4px;
|
||||
border-radius: 3px;
|
||||
font-weight: 500;
|
||||
@ -607,13 +684,13 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
background: var(--card);
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 2px;
|
||||
opacity: 0;
|
||||
transition: opacity 150ms;
|
||||
box-shadow: 0 1px 4px var(--room-border);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.discord-msg-action-btn {
|
||||
@ -629,7 +706,6 @@
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.discord-msg-action-btn:hover {
|
||||
background: var(--room-hover);
|
||||
color: var(--room-text);
|
||||
@ -668,10 +744,7 @@
|
||||
color: var(--room-text-subtle);
|
||||
}
|
||||
|
||||
.discord-typing-dots {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
}
|
||||
.discord-typing-dots { display: flex; gap: 3px; }
|
||||
|
||||
.discord-typing-dots span {
|
||||
width: 6px;
|
||||
@ -686,10 +759,10 @@
|
||||
|
||||
@keyframes typing-bounce {
|
||||
0%, 60%, 100% { transform: translateY(0); }
|
||||
30% { transform: translateY(-4px); }
|
||||
30% { transform: translateY(-4px); }
|
||||
}
|
||||
|
||||
/* Status pill in header */
|
||||
/* WebSocket status */
|
||||
.discord-ws-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@ -704,19 +777,19 @@
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.discord-ws-dot.connected { background: var(--room-online); }
|
||||
.discord-ws-dot.connecting {
|
||||
.discord-ws-dot.connected { background: var(--room-online); }
|
||||
.discord-ws-dot.connecting {
|
||||
background: var(--room-away);
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
.discord-ws-dot.disconnected { background: oklch(0.63 0.21 25); }
|
||||
.discord-ws-dot.disconnected { background: var(--error); }
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
/* Chat input area */
|
||||
/* Chat input */
|
||||
.discord-chat-input-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -754,11 +827,7 @@
|
||||
overflow-y: auto;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.discord-input-field::placeholder {
|
||||
color: var(--room-text-muted);
|
||||
}
|
||||
|
||||
.discord-input-field::placeholder { color: var(--room-text-muted); }
|
||||
.discord-input-field::-webkit-scrollbar { width: 0; }
|
||||
|
||||
.discord-send-btn {
|
||||
@ -769,17 +838,16 @@
|
||||
height: 36px;
|
||||
border-radius: 6px;
|
||||
background: var(--room-accent);
|
||||
color: var(--primary-foreground);
|
||||
color: var(--room-mention-fg);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: background 150ms;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.discord-send-btn:hover { background: var(--room-accent-hover); }
|
||||
.discord-send-btn:hover { background: var(--room-accent-hover); }
|
||||
.discord-send-btn:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
/* Reply preview in input */
|
||||
/* Reply preview */
|
||||
.discord-reply-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -793,11 +861,7 @@
|
||||
color: var(--room-text-muted);
|
||||
}
|
||||
|
||||
.discord-reply-preview-author {
|
||||
font-weight: 600;
|
||||
color: var(--room-text-secondary);
|
||||
}
|
||||
|
||||
.discord-reply-preview-author { font-weight: 600; color: var(--room-text-secondary); }
|
||||
.discord-reply-preview-text {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
@ -805,7 +869,7 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Streaming message cursor */
|
||||
/* Streaming cursor */
|
||||
.discord-streaming-cursor {
|
||||
display: inline-block;
|
||||
width: 2px;
|
||||
@ -818,14 +882,14 @@
|
||||
|
||||
@keyframes cursor-blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* Role color dot */
|
||||
/* Role dot */
|
||||
.discord-role-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@ -622,6 +622,7 @@ export class RoomWsClient {
|
||||
contentType?: string;
|
||||
threadId?: string;
|
||||
inReplyTo?: string;
|
||||
attachmentIds?: string[];
|
||||
},
|
||||
): Promise<RoomMessageResponse> {
|
||||
return this.request<RoomMessageResponse>('message.create', {
|
||||
@ -630,6 +631,7 @@ export class RoomWsClient {
|
||||
content_type: options?.contentType,
|
||||
thread_id: options?.threadId,
|
||||
in_reply_to: options?.inReplyTo,
|
||||
attachment_ids: options?.attachmentIds,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -91,6 +91,7 @@ export interface WsRequestParams {
|
||||
min_score?: number;
|
||||
query?: string;
|
||||
message_ids?: string[];
|
||||
attachment_ids?: string[];
|
||||
}
|
||||
|
||||
export interface WsResponse {
|
||||
|
||||
14
src/main.tsx
14
src/main.tsx
@ -6,6 +6,20 @@ import {UserProvider} from '@/contexts';
|
||||
import {ThemeProvider} from '@/contexts/theme-context';
|
||||
import './index.css';
|
||||
import App from './App.tsx';
|
||||
import {applyPaletteToDOM, loadActivePresetId} from '@/components/room/design-system';
|
||||
|
||||
// Restore custom palette on page load (before first render)
|
||||
const activePreset = loadActivePresetId();
|
||||
if (activePreset === 'custom') {
|
||||
const customPalette = localStorage.getItem('theme-custom-palette');
|
||||
if (customPalette) {
|
||||
try {
|
||||
applyPaletteToDOM(JSON.parse(customPalette));
|
||||
} catch {
|
||||
// ignore malformed stored palette
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user