feat(settings): add quick messages management with drag-and-drop sorting
Adds a new "Quick Messages" settings page below Experts for managing reusable title/content snippets, backed by SQLite via SeaORM and exposed through both Tauri commands and the Axum web router. The list supports drag-to-reorder using the same motion/react Reorder pattern as the agent list, with translations provided across all 10 supported locales.
This commit is contained in:
@@ -9,6 +9,7 @@ pub mod model_provider;
|
||||
#[cfg(feature = "tauri-runtime")]
|
||||
pub mod notification;
|
||||
pub mod project_boot;
|
||||
pub mod quick_messages;
|
||||
pub mod system_settings;
|
||||
pub mod terminal;
|
||||
pub mod version_control;
|
||||
|
||||
55
src-tauri/src/commands/quick_messages.rs
Normal file
55
src-tauri/src/commands/quick_messages.rs
Normal file
@@ -0,0 +1,55 @@
|
||||
#[cfg(feature = "tauri-runtime")]
|
||||
use crate::db::error::DbError;
|
||||
#[cfg(feature = "tauri-runtime")]
|
||||
use crate::db::service::quick_message_service;
|
||||
#[cfg(feature = "tauri-runtime")]
|
||||
use crate::db::AppDatabase;
|
||||
#[cfg(feature = "tauri-runtime")]
|
||||
use crate::models::QuickMessageInfo;
|
||||
|
||||
#[cfg(feature = "tauri-runtime")]
|
||||
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
|
||||
pub async fn quick_messages_list(
|
||||
db: tauri::State<'_, AppDatabase>,
|
||||
) -> Result<Vec<QuickMessageInfo>, DbError> {
|
||||
quick_message_service::list(&db.conn).await
|
||||
}
|
||||
|
||||
#[cfg(feature = "tauri-runtime")]
|
||||
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
|
||||
pub async fn quick_messages_create(
|
||||
db: tauri::State<'_, AppDatabase>,
|
||||
title: String,
|
||||
content: String,
|
||||
) -> Result<QuickMessageInfo, DbError> {
|
||||
quick_message_service::create(&db.conn, &title, &content).await
|
||||
}
|
||||
|
||||
#[cfg(feature = "tauri-runtime")]
|
||||
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
|
||||
pub async fn quick_messages_update(
|
||||
db: tauri::State<'_, AppDatabase>,
|
||||
id: i32,
|
||||
title: Option<String>,
|
||||
content: Option<String>,
|
||||
) -> Result<QuickMessageInfo, DbError> {
|
||||
quick_message_service::update(&db.conn, id, title, content).await
|
||||
}
|
||||
|
||||
#[cfg(feature = "tauri-runtime")]
|
||||
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
|
||||
pub async fn quick_messages_delete(
|
||||
db: tauri::State<'_, AppDatabase>,
|
||||
id: i32,
|
||||
) -> Result<(), DbError> {
|
||||
quick_message_service::delete(&db.conn, id).await
|
||||
}
|
||||
|
||||
#[cfg(feature = "tauri-runtime")]
|
||||
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
|
||||
pub async fn quick_messages_reorder(
|
||||
db: tauri::State<'_, AppDatabase>,
|
||||
ids: Vec<i32>,
|
||||
) -> Result<(), DbError> {
|
||||
quick_message_service::reorder(&db.conn, ids).await
|
||||
}
|
||||
@@ -9,3 +9,4 @@ pub mod folder_command;
|
||||
pub mod model_provider;
|
||||
pub mod opened_tab;
|
||||
pub mod prelude;
|
||||
pub mod quick_message;
|
||||
|
||||
@@ -10,3 +10,4 @@ pub use super::folder::Entity as Folder;
|
||||
pub use super::folder_command::Entity as FolderCommand;
|
||||
pub use super::model_provider::Entity as ModelProvider;
|
||||
pub use super::opened_tab::Entity as OpenedTab;
|
||||
pub use super::quick_message::Entity as QuickMessage;
|
||||
|
||||
19
src-tauri/src/db/entities/quick_message.rs
Normal file
19
src-tauri/src/db/entities/quick_message.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "quick_message")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub title: String,
|
||||
#[sea_orm(column_type = "Text")]
|
||||
pub content: String,
|
||||
pub sort_order: i32,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
70
src-tauri/src/db/migration/m20260424_000002_quick_message.rs
Normal file
70
src-tauri/src/db/migration/m20260424_000002_quick_message.rs
Normal file
@@ -0,0 +1,70 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(QuickMessage::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(QuickMessage::Id)
|
||||
.integer()
|
||||
.not_null()
|
||||
.auto_increment()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(QuickMessage::Title).string().not_null())
|
||||
.col(ColumnDef::new(QuickMessage::Content).text().not_null())
|
||||
.col(
|
||||
ColumnDef::new(QuickMessage::SortOrder)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(0),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(QuickMessage::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(QuickMessage::UpdatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null(),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_quick_message_sort_order")
|
||||
.table(QuickMessage::Table)
|
||||
.col(QuickMessage::SortOrder)
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(QuickMessage::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum QuickMessage {
|
||||
Table,
|
||||
Id,
|
||||
Title,
|
||||
Content,
|
||||
SortOrder,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
}
|
||||
@@ -13,6 +13,7 @@ mod m20260420_000001_opened_tabs;
|
||||
mod m20260422_000001_folder_sort_order;
|
||||
mod m20260423_000001_drop_folder_parent_branch;
|
||||
mod m20260424_000001_folder_color;
|
||||
mod m20260424_000002_quick_message;
|
||||
pub struct Migrator;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
@@ -32,6 +33,7 @@ impl MigratorTrait for Migrator {
|
||||
Box::new(m20260422_000001_folder_sort_order::Migration),
|
||||
Box::new(m20260423_000001_drop_folder_parent_branch::Migration),
|
||||
Box::new(m20260424_000001_folder_color::Migration),
|
||||
Box::new(m20260424_000002_quick_message::Migration),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,5 +7,6 @@ pub mod folder_command_service;
|
||||
pub mod folder_service;
|
||||
pub mod import_service;
|
||||
pub mod model_provider_service;
|
||||
pub mod quick_message_service;
|
||||
pub mod sender_context_service;
|
||||
pub mod tab_service;
|
||||
|
||||
114
src-tauri/src/db/service/quick_message_service.rs
Normal file
114
src-tauri/src/db/service/quick_message_service.rs
Normal file
@@ -0,0 +1,114 @@
|
||||
use chrono::Utc;
|
||||
use sea_orm::DatabaseConnection;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ActiveValue::NotSet, ConnectionTrait, DbBackend, EntityTrait,
|
||||
IntoActiveModel, QueryOrder, Set, Statement,
|
||||
};
|
||||
|
||||
use crate::db::entities::quick_message;
|
||||
use crate::db::error::DbError;
|
||||
use crate::models::QuickMessageInfo;
|
||||
|
||||
fn to_info(m: quick_message::Model) -> QuickMessageInfo {
|
||||
QuickMessageInfo {
|
||||
id: m.id,
|
||||
title: m.title,
|
||||
content: m.content,
|
||||
sort_order: m.sort_order,
|
||||
created_at: m.created_at,
|
||||
updated_at: m.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list(conn: &DatabaseConnection) -> Result<Vec<QuickMessageInfo>, DbError> {
|
||||
let rows = quick_message::Entity::find()
|
||||
.order_by_asc(quick_message::Column::SortOrder)
|
||||
.all(conn)
|
||||
.await?;
|
||||
|
||||
Ok(rows.into_iter().map(to_info).collect())
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
conn: &DatabaseConnection,
|
||||
title: &str,
|
||||
content: &str,
|
||||
) -> Result<QuickMessageInfo, DbError> {
|
||||
let now = Utc::now();
|
||||
|
||||
let max_order = quick_message::Entity::find()
|
||||
.order_by_desc(quick_message::Column::SortOrder)
|
||||
.one(conn)
|
||||
.await?
|
||||
.map(|m| m.sort_order)
|
||||
.unwrap_or(-1);
|
||||
|
||||
let active = quick_message::ActiveModel {
|
||||
id: NotSet,
|
||||
title: Set(title.to_string()),
|
||||
content: Set(content.to_string()),
|
||||
sort_order: Set(max_order + 1),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
};
|
||||
|
||||
let model = active.insert(conn).await?;
|
||||
Ok(to_info(model))
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
conn: &DatabaseConnection,
|
||||
id: i32,
|
||||
title: Option<String>,
|
||||
content: Option<String>,
|
||||
) -> Result<QuickMessageInfo, DbError> {
|
||||
let row = quick_message::Entity::find_by_id(id)
|
||||
.one(conn)
|
||||
.await?
|
||||
.ok_or_else(|| DbError::Migration(format!("QuickMessage {} not found", id)))?;
|
||||
|
||||
let mut active = row.into_active_model();
|
||||
if let Some(t) = title {
|
||||
active.title = Set(t);
|
||||
}
|
||||
if let Some(c) = content {
|
||||
active.content = Set(c);
|
||||
}
|
||||
active.updated_at = Set(Utc::now());
|
||||
|
||||
let model = active.update(conn).await?;
|
||||
Ok(to_info(model))
|
||||
}
|
||||
|
||||
pub async fn delete(conn: &DatabaseConnection, id: i32) -> Result<(), DbError> {
|
||||
quick_message::Entity::delete_by_id(id).exec(conn).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn reorder(conn: &DatabaseConnection, ids: Vec<i32>) -> Result<(), DbError> {
|
||||
if ids.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let now = Utc::now();
|
||||
let now_str = now.format("%Y-%m-%d %H:%M:%S %:z").to_string();
|
||||
let case_expr = ids
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, id)| format!("WHEN {} THEN {}", id, idx))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let id_list = ids
|
||||
.iter()
|
||||
.map(|id| id.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
|
||||
let sql = format!(
|
||||
"UPDATE quick_message SET sort_order = CASE id {case_expr} END, updated_at = '{now_str}' WHERE id IN ({id_list})"
|
||||
);
|
||||
conn.execute(Statement::from_string(DbBackend::Sqlite, sql))
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -24,7 +24,8 @@ mod tauri_app {
|
||||
use crate::commands::{
|
||||
acp as acp_commands, chat_channel as chat_channel_commands, conversations,
|
||||
experts as experts_commands, folder_commands, folders, mcp as mcp_commands,
|
||||
model_provider as model_provider_commands, notification, project_boot, system_settings,
|
||||
model_provider as model_provider_commands, notification, project_boot,
|
||||
quick_messages as quick_messages_commands, system_settings,
|
||||
terminal as terminal_commands, version_control, windows,
|
||||
workspace_state as workspace_state_commands,
|
||||
};
|
||||
@@ -354,6 +355,11 @@ mod tauri_app {
|
||||
folder_commands::delete_folder_command,
|
||||
folder_commands::reorder_folder_commands,
|
||||
folder_commands::bootstrap_folder_commands_from_package_json,
|
||||
quick_messages_commands::quick_messages_list,
|
||||
quick_messages_commands::quick_messages_create,
|
||||
quick_messages_commands::quick_messages_update,
|
||||
quick_messages_commands::quick_messages_delete,
|
||||
quick_messages_commands::quick_messages_reorder,
|
||||
terminal_commands::terminal_spawn,
|
||||
terminal_commands::terminal_write,
|
||||
terminal_commands::terminal_resize,
|
||||
|
||||
@@ -4,6 +4,7 @@ pub mod conversation;
|
||||
pub mod folder;
|
||||
pub mod message;
|
||||
pub mod model_provider;
|
||||
pub mod quick_message;
|
||||
pub mod system;
|
||||
|
||||
pub use agent::AgentType;
|
||||
@@ -15,6 +16,7 @@ pub use conversation::{
|
||||
SidebarData,
|
||||
};
|
||||
pub use folder::{FolderCommandInfo, FolderDetail, FolderHistoryEntry, OpenedTab};
|
||||
pub use quick_message::QuickMessageInfo;
|
||||
pub use message::{
|
||||
AgentExecutionStats, AgentToolCall, ContentBlock, MessageRole, MessageTurn, TurnRole,
|
||||
TurnUsage, UnifiedMessage,
|
||||
|
||||
12
src-tauri/src/models/quick_message.rs
Normal file
12
src-tauri/src/models/quick_message.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct QuickMessageInfo {
|
||||
pub id: i32,
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
pub sort_order: i32,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -10,6 +10,7 @@ pub mod git;
|
||||
pub mod mcp;
|
||||
pub mod model_provider;
|
||||
pub mod project_boot;
|
||||
pub mod quick_messages;
|
||||
pub mod system_settings;
|
||||
pub mod terminal;
|
||||
pub mod version_control;
|
||||
|
||||
86
src-tauri/src/web/handlers/quick_messages.rs
Normal file
86
src-tauri/src/web/handlers/quick_messages.rs
Normal file
@@ -0,0 +1,86 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{extract::Extension, Json};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::app_error::AppCommandError;
|
||||
use crate::app_state::AppState;
|
||||
use crate::db::service::quick_message_service;
|
||||
use crate::models::QuickMessageInfo;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateQuickMessageParams {
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateQuickMessageParams {
|
||||
pub id: i32,
|
||||
pub title: Option<String>,
|
||||
pub content: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeleteQuickMessageParams {
|
||||
pub id: i32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ReorderQuickMessagesParams {
|
||||
pub ids: Vec<i32>,
|
||||
}
|
||||
|
||||
pub async fn quick_messages_list(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
) -> Result<Json<Vec<QuickMessageInfo>>, AppCommandError> {
|
||||
let result = quick_message_service::list(&state.db.conn)
|
||||
.await
|
||||
.map_err(AppCommandError::from)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
pub async fn quick_messages_create(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
Json(params): Json<CreateQuickMessageParams>,
|
||||
) -> Result<Json<QuickMessageInfo>, AppCommandError> {
|
||||
let result = quick_message_service::create(&state.db.conn, ¶ms.title, ¶ms.content)
|
||||
.await
|
||||
.map_err(AppCommandError::from)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
pub async fn quick_messages_update(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
Json(params): Json<UpdateQuickMessageParams>,
|
||||
) -> Result<Json<QuickMessageInfo>, AppCommandError> {
|
||||
let result =
|
||||
quick_message_service::update(&state.db.conn, params.id, params.title, params.content)
|
||||
.await
|
||||
.map_err(AppCommandError::from)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
pub async fn quick_messages_delete(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
Json(params): Json<DeleteQuickMessageParams>,
|
||||
) -> Result<Json<()>, AppCommandError> {
|
||||
quick_message_service::delete(&state.db.conn, params.id)
|
||||
.await
|
||||
.map_err(AppCommandError::from)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
pub async fn quick_messages_reorder(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
Json(params): Json<ReorderQuickMessagesParams>,
|
||||
) -> Result<Json<()>, AppCommandError> {
|
||||
quick_message_service::reorder(&state.db.conn, params.ids)
|
||||
.await
|
||||
.map_err(AppCommandError::from)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
@@ -631,6 +631,27 @@ pub fn build_router(state: Arc<AppState>, token: String, static_dir: std::path::
|
||||
"/delete_model_provider",
|
||||
post(handlers::model_provider::delete_model_provider),
|
||||
)
|
||||
// ─── Quick Messages ───
|
||||
.route(
|
||||
"/quick_messages_list",
|
||||
post(handlers::quick_messages::quick_messages_list),
|
||||
)
|
||||
.route(
|
||||
"/quick_messages_create",
|
||||
post(handlers::quick_messages::quick_messages_create),
|
||||
)
|
||||
.route(
|
||||
"/quick_messages_update",
|
||||
post(handlers::quick_messages::quick_messages_update),
|
||||
)
|
||||
.route(
|
||||
"/quick_messages_delete",
|
||||
post(handlers::quick_messages::quick_messages_delete),
|
||||
)
|
||||
.route(
|
||||
"/quick_messages_reorder",
|
||||
post(handlers::quick_messages::quick_messages_reorder),
|
||||
)
|
||||
// ─── Terminal ───
|
||||
.route("/terminal_spawn", post(handlers::terminal::terminal_spawn))
|
||||
.route("/terminal_write", post(handlers::terminal::terminal_write))
|
||||
|
||||
5
src/app/settings/quick-messages/page.tsx
Normal file
5
src/app/settings/quick-messages/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { QuickMessagesSettings } from "@/components/settings/quick-messages-settings"
|
||||
|
||||
export default function SettingsQuickMessagesPage() {
|
||||
return <QuickMessagesSettings />
|
||||
}
|
||||
583
src/components/settings/quick-messages-settings.tsx
Normal file
583
src/components/settings/quick-messages-settings.tsx
Normal file
@@ -0,0 +1,583 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type PointerEvent,
|
||||
type ReactNode,
|
||||
} from "react"
|
||||
import { GripVertical, Loader2, Plus, Save, Trash2 } from "lucide-react"
|
||||
import { Reorder, useDragControls } from "motion/react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "@/components/ui/resizable"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
quickMessagesCreate,
|
||||
quickMessagesDelete,
|
||||
quickMessagesList,
|
||||
quickMessagesReorder,
|
||||
quickMessagesUpdate,
|
||||
} from "@/lib/api"
|
||||
import type { QuickMessage } from "@/lib/types"
|
||||
|
||||
const LEFT_MIN_WIDTH = 280
|
||||
const RIGHT_MIN_WIDTH = 420
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value))
|
||||
}
|
||||
|
||||
function toPercent(pixels: number, totalPixels: number): number {
|
||||
if (totalPixels <= 0) return 0
|
||||
return (pixels / totalPixels) * 100
|
||||
}
|
||||
|
||||
interface QuickMessageReorderItemProps {
|
||||
message: QuickMessage
|
||||
selected: boolean
|
||||
reordering: boolean
|
||||
onSelect: (id: number) => void
|
||||
onDragStart: () => void
|
||||
onDragEnd: () => void
|
||||
children: (
|
||||
startDrag: (event: PointerEvent<HTMLButtonElement>) => void
|
||||
) => ReactNode
|
||||
}
|
||||
|
||||
function QuickMessageReorderItem({
|
||||
message,
|
||||
selected,
|
||||
reordering,
|
||||
onSelect,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
children,
|
||||
}: QuickMessageReorderItemProps) {
|
||||
const dragControls = useDragControls()
|
||||
|
||||
const startDrag = useCallback(
|
||||
(event: PointerEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
dragControls.start(event)
|
||||
},
|
||||
[dragControls]
|
||||
)
|
||||
|
||||
return (
|
||||
<Reorder.Item
|
||||
as="section"
|
||||
value={message}
|
||||
data-quick-message-id={message.id}
|
||||
drag={reordering ? false : "y"}
|
||||
dragListener={false}
|
||||
dragControls={dragControls}
|
||||
dragMomentum={false}
|
||||
layout="position"
|
||||
className={cn(
|
||||
"rounded-lg border bg-card p-2.5 transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40",
|
||||
selected && "border-primary/60 bg-primary/5"
|
||||
)}
|
||||
tabIndex={0}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
onClick={() => onSelect(message.id)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.target !== event.currentTarget) return
|
||||
if (event.key !== "Enter" && event.key !== " ") return
|
||||
event.preventDefault()
|
||||
onSelect(message.id)
|
||||
}}
|
||||
>
|
||||
{children(startDrag)}
|
||||
</Reorder.Item>
|
||||
)
|
||||
}
|
||||
|
||||
export function QuickMessagesSettings() {
|
||||
const t = useTranslations("QuickMessagesSettings")
|
||||
|
||||
const [messages, setMessages] = useState<QuickMessage[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
|
||||
const [draftTitle, setDraftTitle] = useState("")
|
||||
const [draftContent, setDraftContent] = useState("")
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<number | null>(null)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
const [reordering, setReordering] = useState(false)
|
||||
const pendingOrderRef = useRef<number[] | null>(null)
|
||||
|
||||
const panelContainerRef = useRef<HTMLDivElement | null>(null)
|
||||
const [panelContainerWidth, setPanelContainerWidth] = useState(0)
|
||||
const titleInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setLoadError(null)
|
||||
try {
|
||||
const list = await quickMessagesList()
|
||||
setMessages(list)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
setLoadError(message)
|
||||
setMessages([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
refresh().catch((err) => {
|
||||
console.error("[QuickMessagesSettings] initial refresh failed:", err)
|
||||
})
|
||||
}, [refresh])
|
||||
|
||||
useEffect(() => {
|
||||
const container = panelContainerRef.current
|
||||
if (!container) return
|
||||
const updateWidth = (next: number) => {
|
||||
setPanelContainerWidth((prev) =>
|
||||
Math.abs(prev - next) < 1 ? prev : next
|
||||
)
|
||||
}
|
||||
updateWidth(container.getBoundingClientRect().width)
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
updateWidth(
|
||||
entries[0]?.contentRect.width ?? container.getBoundingClientRect().width
|
||||
)
|
||||
})
|
||||
observer.observe(container)
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const selectedMessage = useMemo(
|
||||
() => messages.find((m) => m.id === selectedId) ?? null,
|
||||
[messages, selectedId]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedMessage) {
|
||||
setDraftTitle(selectedMessage.title)
|
||||
setDraftContent(selectedMessage.content)
|
||||
} else {
|
||||
setDraftTitle("")
|
||||
setDraftContent("")
|
||||
}
|
||||
}, [selectedMessage])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedId === null && messages.length > 0) {
|
||||
setSelectedId(messages[0].id)
|
||||
}
|
||||
}, [selectedId, messages])
|
||||
|
||||
const filteredMessages = useMemo(() => {
|
||||
const q = searchQuery.trim().toLowerCase()
|
||||
if (!q) return messages
|
||||
return messages.filter(
|
||||
(m) =>
|
||||
m.title.toLowerCase().includes(q) || m.content.toLowerCase().includes(q)
|
||||
)
|
||||
}, [messages, searchQuery])
|
||||
|
||||
const isDirty = useMemo(() => {
|
||||
if (!selectedMessage) return false
|
||||
return (
|
||||
draftTitle !== selectedMessage.title ||
|
||||
draftContent !== selectedMessage.content
|
||||
)
|
||||
}, [selectedMessage, draftTitle, draftContent])
|
||||
|
||||
const persistReorder = useCallback(
|
||||
async (ids: number[]) => {
|
||||
if (ids.length === 0) return
|
||||
setReordering(true)
|
||||
try {
|
||||
await quickMessagesReorder(ids)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
toast.error(t("toasts.saveOrderFailed"), { description: message })
|
||||
await refresh()
|
||||
} finally {
|
||||
setReordering(false)
|
||||
}
|
||||
},
|
||||
[refresh, t]
|
||||
)
|
||||
|
||||
const handleReorder = useCallback((next: QuickMessage[]) => {
|
||||
const reordered = next.map((m, index) => ({ ...m, sort_order: index }))
|
||||
setMessages(reordered)
|
||||
pendingOrderRef.current = reordered.map((m) => m.id)
|
||||
}, [])
|
||||
|
||||
const handleCreate = useCallback(async () => {
|
||||
setCreating(true)
|
||||
try {
|
||||
const created = await quickMessagesCreate({ title: "", content: "" })
|
||||
setMessages((prev) => [...prev, created])
|
||||
setSelectedId(created.id)
|
||||
toast.success(t("toasts.created"))
|
||||
requestAnimationFrame(() => {
|
||||
titleInputRef.current?.focus()
|
||||
})
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
toast.error(t("toasts.createFailed"), { description: message })
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}, [t])
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!selectedMessage) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const updated = await quickMessagesUpdate({
|
||||
id: selectedMessage.id,
|
||||
title: draftTitle,
|
||||
content: draftContent,
|
||||
})
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => (m.id === updated.id ? updated : m))
|
||||
)
|
||||
toast.success(t("toasts.saved"))
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
toast.error(t("toasts.saveFailed"), { description: message })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [selectedMessage, draftTitle, draftContent, t])
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (deleteTargetId === null) return
|
||||
const target = deleteTargetId
|
||||
setDeleting(true)
|
||||
try {
|
||||
await quickMessagesDelete(target)
|
||||
setMessages((prev) => {
|
||||
const next = prev.filter((m) => m.id !== target)
|
||||
if (selectedId === target) {
|
||||
setSelectedId(next[0]?.id ?? null)
|
||||
}
|
||||
return next
|
||||
})
|
||||
toast.success(t("toasts.deleted"))
|
||||
setDeleteTargetId(null)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
toast.error(t("toasts.deleteFailed"), { description: message })
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}, [deleteTargetId, selectedId, t])
|
||||
|
||||
const safeContainerWidth =
|
||||
panelContainerWidth > 0 ? panelContainerWidth : 1200
|
||||
const leftMinSize = clamp(
|
||||
toPercent(LEFT_MIN_WIDTH, safeContainerWidth),
|
||||
5,
|
||||
95
|
||||
)
|
||||
const rightMinSize = clamp(
|
||||
toPercent(RIGHT_MIN_WIDTH, safeContainerWidth),
|
||||
5,
|
||||
95
|
||||
)
|
||||
const leftMaxSize = Math.max(leftMinSize, 100 - rightMinSize)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
{t("loading")}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const deleteTargetMessage =
|
||||
deleteTargetId !== null
|
||||
? (messages.find((m) => m.id === deleteTargetId) ?? null)
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col p-3 md:p-4">
|
||||
<div className="flex items-center justify-between gap-3 pb-4">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">{t("title")}</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t("description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loadError && (
|
||||
<div className="mb-3 rounded-md border border-red-500/30 bg-red-500/5 px-3 py-2 text-xs text-red-400">
|
||||
{loadError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={panelContainerRef} className="flex-1 min-h-0 min-w-0">
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
className="h-full min-h-0 min-w-0"
|
||||
>
|
||||
<ResizablePanel
|
||||
defaultSize={34}
|
||||
minSize={leftMinSize}
|
||||
maxSize={leftMaxSize}
|
||||
>
|
||||
<div className="min-h-0 h-full min-w-0 rounded-lg border bg-card flex flex-col overflow-hidden lg:rounded-r-none">
|
||||
<div className="border-b p-3 space-y-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
placeholder={t("searchPlaceholder")}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
handleCreate().catch((err) => {
|
||||
console.error(
|
||||
"[QuickMessagesSettings] create failed:",
|
||||
err
|
||||
)
|
||||
})
|
||||
}}
|
||||
disabled={creating}
|
||||
>
|
||||
{creating ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("actions.new")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredMessages.length === 0 ? (
|
||||
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground px-4 text-center">
|
||||
{messages.length === 0
|
||||
? t("emptyList")
|
||||
: t("searchPlaceholder")}
|
||||
</div>
|
||||
) : (
|
||||
<Reorder.Group
|
||||
as="div"
|
||||
axis="y"
|
||||
values={messages}
|
||||
onReorder={handleReorder}
|
||||
className="flex-1 min-h-0 overflow-y-auto space-y-2 p-2"
|
||||
>
|
||||
{filteredMessages.map((m) => (
|
||||
<QuickMessageReorderItem
|
||||
key={m.id}
|
||||
message={m}
|
||||
selected={selectedId === m.id}
|
||||
reordering={reordering}
|
||||
onSelect={(id) => setSelectedId(id)}
|
||||
onDragStart={() => {
|
||||
/* no-op: list re-render handles dragging state */
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
const order = pendingOrderRef.current
|
||||
pendingOrderRef.current = null
|
||||
if (order && !reordering) {
|
||||
persistReorder(order).catch((err) => {
|
||||
console.error(
|
||||
"[QuickMessagesSettings] reorder failed:",
|
||||
err
|
||||
)
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
{(startDrag) => (
|
||||
<div className="flex items-center gap-2 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground cursor-grab active:cursor-grabbing rounded p-0.5 hover:bg-muted"
|
||||
title={t("actions.dragSort")}
|
||||
aria-label={t("actions.dragSortMessage", {
|
||||
name: m.title || t("untitled"),
|
||||
})}
|
||||
onPointerDown={startDrag}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
disabled={reordering}
|
||||
>
|
||||
<GripVertical className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{m.title || (
|
||||
<span className="italic text-muted-foreground">
|
||||
{t("untitled")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{m.content && (
|
||||
<div className="text-[11px] text-muted-foreground truncate mt-0.5">
|
||||
{m.content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</QuickMessageReorderItem>
|
||||
))}
|
||||
</Reorder.Group>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel defaultSize={66} minSize={rightMinSize}>
|
||||
<div className="h-full flex-1 min-h-0 min-w-0 rounded-lg border bg-card overflow-hidden lg:rounded-l-none lg:border-l-0">
|
||||
{selectedMessage ? (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="quick-message-title" className="text-xs">
|
||||
{t("fields.title")}
|
||||
</Label>
|
||||
<Input
|
||||
id="quick-message-title"
|
||||
ref={titleInputRef}
|
||||
value={draftTitle}
|
||||
onChange={(event) => setDraftTitle(event.target.value)}
|
||||
placeholder={t("fields.titlePlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="quick-message-content"
|
||||
className="text-xs"
|
||||
>
|
||||
{t("fields.content")}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="quick-message-content"
|
||||
value={draftContent}
|
||||
onChange={(event) =>
|
||||
setDraftContent(event.target.value)
|
||||
}
|
||||
placeholder={t("fields.contentPlaceholder")}
|
||||
className="min-h-[260px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t px-4 py-3 flex items-center justify-between gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setDeleteTargetId(selectedMessage.id)}
|
||||
className="text-red-500 hover:text-red-500"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{t("actions.delete")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
handleSave().catch((err) => {
|
||||
console.error(
|
||||
"[QuickMessagesSettings] save failed:",
|
||||
err
|
||||
)
|
||||
})
|
||||
}}
|
||||
disabled={saving || !isDirty}
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("actions.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center text-xs text-muted-foreground">
|
||||
{t("emptySelection")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
|
||||
<AlertDialog
|
||||
open={deleteTargetId !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDeleteTargetId(null)
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("confirmDelete.title")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("confirmDelete.message", {
|
||||
name: deleteTargetMessage?.title || t("untitled"),
|
||||
})}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleting}>
|
||||
{t("confirmDelete.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
handleDelete().catch((err) => {
|
||||
console.error("[QuickMessagesSettings] delete failed:", err)
|
||||
})
|
||||
}}
|
||||
disabled={deleting}
|
||||
>
|
||||
{deleting ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : null}
|
||||
{t("confirmDelete.confirm")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Globe,
|
||||
Keyboard,
|
||||
Menu,
|
||||
MessageSquareText,
|
||||
SendHorizontal,
|
||||
Palette,
|
||||
PlugZap,
|
||||
@@ -41,6 +42,7 @@ interface SettingsNavItem {
|
||||
| "mcp"
|
||||
| "skills"
|
||||
| "experts"
|
||||
| "quick_messages"
|
||||
| "shortcuts"
|
||||
| "version_control"
|
||||
| "chat_channels"
|
||||
@@ -70,6 +72,11 @@ const SETTINGS_NAV_ITEMS: SettingsNavItem[] = [
|
||||
labelKey: "experts",
|
||||
icon: Sparkles,
|
||||
},
|
||||
{
|
||||
href: "/settings/quick-messages",
|
||||
labelKey: "quick_messages",
|
||||
icon: MessageSquareText,
|
||||
},
|
||||
{
|
||||
href: "/settings/agents",
|
||||
labelKey: "agents",
|
||||
|
||||
@@ -47,7 +47,8 @@
|
||||
"chat_channels": "قنوات المحادثة",
|
||||
"web_service": "خدمة الويب",
|
||||
"model_providers": "مزودو النماذج",
|
||||
"experts": "الخبراء"
|
||||
"experts": "الخبراء",
|
||||
"quick_messages": "رسائل سريعة"
|
||||
}
|
||||
},
|
||||
"AppearanceSettings": {
|
||||
@@ -2108,5 +2109,43 @@
|
||||
"disableFailed": "فشل تعطيل الخبير",
|
||||
"openFolderFailed": "فشل فتح المجلد"
|
||||
}
|
||||
},
|
||||
"QuickMessagesSettings": {
|
||||
"title": "رسائل سريعة",
|
||||
"description": "إدارة مقتطفات الرسائل القابلة لإعادة الاستخدام. اسحب لإعادة الترتيب.",
|
||||
"loading": "جارٍ تحميل الرسائل السريعة…",
|
||||
"emptyList": "لا توجد رسائل سريعة بعد. انقر على \"جديد\" لإنشاء واحدة.",
|
||||
"emptySelection": "اختر رسالة سريعة للتعديل.",
|
||||
"searchPlaceholder": "ابحث حسب العنوان أو المحتوى",
|
||||
"untitled": "بدون عنوان",
|
||||
"actions": {
|
||||
"new": "جديد",
|
||||
"save": "حفظ",
|
||||
"delete": "حذف",
|
||||
"dragSort": "اسحب لإعادة الترتيب",
|
||||
"dragSortMessage": "اسحب لإعادة ترتيب الرسالة السريعة: {name}"
|
||||
},
|
||||
"fields": {
|
||||
"title": "العنوان",
|
||||
"titlePlaceholder": "أعطِ هذه الرسالة عنوانًا قصيرًا",
|
||||
"content": "المحتوى",
|
||||
"contentPlaceholder": "اكتب محتوى الرسالة هنا"
|
||||
},
|
||||
"confirmDelete": {
|
||||
"title": "حذف الرسالة السريعة؟",
|
||||
"message": "سيؤدي ذلك إلى حذف \"{name}\" نهائيًا. هل أنت متأكد؟",
|
||||
"cancel": "إلغاء",
|
||||
"confirm": "حذف"
|
||||
},
|
||||
"toasts": {
|
||||
"loadFailed": "فشل تحميل الرسائل السريعة",
|
||||
"createFailed": "فشل إنشاء الرسالة السريعة",
|
||||
"saveFailed": "فشل حفظ الرسالة السريعة",
|
||||
"deleteFailed": "فشل حذف الرسالة السريعة",
|
||||
"saveOrderFailed": "فشل حفظ الترتيب",
|
||||
"created": "تم إنشاء الرسالة السريعة",
|
||||
"saved": "تم حفظ الرسالة السريعة",
|
||||
"deleted": "تم حذف الرسالة السريعة"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,8 @@
|
||||
"chat_channels": "Chat-Kanäle",
|
||||
"web_service": "Webdienst",
|
||||
"model_providers": "Modellanbieter",
|
||||
"experts": "Experten"
|
||||
"experts": "Experten",
|
||||
"quick_messages": "Schnellnachrichten"
|
||||
}
|
||||
},
|
||||
"AppearanceSettings": {
|
||||
@@ -2108,5 +2109,43 @@
|
||||
"disableFailed": "Deaktivieren des Experten fehlgeschlagen",
|
||||
"openFolderFailed": "Ordner konnte nicht geöffnet werden"
|
||||
}
|
||||
},
|
||||
"QuickMessagesSettings": {
|
||||
"title": "Schnellnachrichten",
|
||||
"description": "Verwalte wiederverwendbare Nachrichtenbausteine. Ziehe zum Neuordnen.",
|
||||
"loading": "Schnellnachrichten werden geladen…",
|
||||
"emptyList": "Noch keine Schnellnachrichten. Klicke auf \"Neu\", um eine zu erstellen.",
|
||||
"emptySelection": "Wähle eine Schnellnachricht zum Bearbeiten aus.",
|
||||
"searchPlaceholder": "Nach Titel oder Inhalt suchen",
|
||||
"untitled": "Ohne Titel",
|
||||
"actions": {
|
||||
"new": "Neu",
|
||||
"save": "Speichern",
|
||||
"delete": "Löschen",
|
||||
"dragSort": "Zum Neuordnen ziehen",
|
||||
"dragSortMessage": "Schnellnachricht neu ordnen: {name}"
|
||||
},
|
||||
"fields": {
|
||||
"title": "Titel",
|
||||
"titlePlaceholder": "Gib dieser Nachricht einen kurzen Titel",
|
||||
"content": "Inhalt",
|
||||
"contentPlaceholder": "Gib hier den Nachrichteninhalt ein"
|
||||
},
|
||||
"confirmDelete": {
|
||||
"title": "Schnellnachricht löschen?",
|
||||
"message": "Dadurch wird \"{name}\" dauerhaft gelöscht. Bist du sicher?",
|
||||
"cancel": "Abbrechen",
|
||||
"confirm": "Löschen"
|
||||
},
|
||||
"toasts": {
|
||||
"loadFailed": "Laden der Schnellnachrichten fehlgeschlagen",
|
||||
"createFailed": "Erstellen der Schnellnachricht fehlgeschlagen",
|
||||
"saveFailed": "Speichern der Schnellnachricht fehlgeschlagen",
|
||||
"deleteFailed": "Löschen der Schnellnachricht fehlgeschlagen",
|
||||
"saveOrderFailed": "Reihenfolge konnte nicht gespeichert werden",
|
||||
"created": "Schnellnachricht erstellt",
|
||||
"saved": "Schnellnachricht gespeichert",
|
||||
"deleted": "Schnellnachricht gelöscht"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,8 @@
|
||||
"chat_channels": "Chat Channels",
|
||||
"web_service": "Web Service",
|
||||
"model_providers": "Model Providers",
|
||||
"experts": "Experts"
|
||||
"experts": "Experts",
|
||||
"quick_messages": "Quick Messages"
|
||||
}
|
||||
},
|
||||
"AppearanceSettings": {
|
||||
@@ -2108,5 +2109,43 @@
|
||||
"disableFailed": "Failed to disable expert",
|
||||
"openFolderFailed": "Failed to open folder"
|
||||
}
|
||||
},
|
||||
"QuickMessagesSettings": {
|
||||
"title": "Quick Messages",
|
||||
"description": "Manage reusable message snippets. Drag to reorder.",
|
||||
"loading": "Loading quick messages…",
|
||||
"emptyList": "No quick messages yet. Click \"New\" to create one.",
|
||||
"emptySelection": "Select a quick message to edit.",
|
||||
"searchPlaceholder": "Search by title or content",
|
||||
"untitled": "Untitled",
|
||||
"actions": {
|
||||
"new": "New",
|
||||
"save": "Save",
|
||||
"delete": "Delete",
|
||||
"dragSort": "Drag to reorder",
|
||||
"dragSortMessage": "Drag to reorder quick message: {name}"
|
||||
},
|
||||
"fields": {
|
||||
"title": "Title",
|
||||
"titlePlaceholder": "Give this message a short title",
|
||||
"content": "Content",
|
||||
"contentPlaceholder": "Write the message content here"
|
||||
},
|
||||
"confirmDelete": {
|
||||
"title": "Delete quick message?",
|
||||
"message": "This will permanently delete \"{name}\". Are you sure?",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Delete"
|
||||
},
|
||||
"toasts": {
|
||||
"loadFailed": "Failed to load quick messages",
|
||||
"createFailed": "Failed to create quick message",
|
||||
"saveFailed": "Failed to save quick message",
|
||||
"deleteFailed": "Failed to delete quick message",
|
||||
"saveOrderFailed": "Failed to save order",
|
||||
"created": "Quick message created",
|
||||
"saved": "Quick message saved",
|
||||
"deleted": "Quick message deleted"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,8 @@
|
||||
"chat_channels": "Canales de chat",
|
||||
"web_service": "Servicio Web",
|
||||
"model_providers": "Proveedores de Modelos",
|
||||
"experts": "Expertos"
|
||||
"experts": "Expertos",
|
||||
"quick_messages": "Mensajes rápidos"
|
||||
}
|
||||
},
|
||||
"AppearanceSettings": {
|
||||
@@ -2108,5 +2109,43 @@
|
||||
"disableFailed": "Error al deshabilitar el experto",
|
||||
"openFolderFailed": "Error al abrir la carpeta"
|
||||
}
|
||||
},
|
||||
"QuickMessagesSettings": {
|
||||
"title": "Mensajes rápidos",
|
||||
"description": "Gestiona fragmentos de mensajes reutilizables. Arrastra para reordenar.",
|
||||
"loading": "Cargando mensajes rápidos…",
|
||||
"emptyList": "Aún no hay mensajes rápidos. Haz clic en \"Nuevo\" para crear uno.",
|
||||
"emptySelection": "Selecciona un mensaje rápido para editar.",
|
||||
"searchPlaceholder": "Buscar por título o contenido",
|
||||
"untitled": "Sin título",
|
||||
"actions": {
|
||||
"new": "Nuevo",
|
||||
"save": "Guardar",
|
||||
"delete": "Eliminar",
|
||||
"dragSort": "Arrastra para reordenar",
|
||||
"dragSortMessage": "Arrastra para reordenar mensaje rápido: {name}"
|
||||
},
|
||||
"fields": {
|
||||
"title": "Título",
|
||||
"titlePlaceholder": "Asigna un título corto a este mensaje",
|
||||
"content": "Contenido",
|
||||
"contentPlaceholder": "Escribe aquí el contenido del mensaje"
|
||||
},
|
||||
"confirmDelete": {
|
||||
"title": "¿Eliminar mensaje rápido?",
|
||||
"message": "Esto eliminará permanentemente \"{name}\". ¿Estás seguro?",
|
||||
"cancel": "Cancelar",
|
||||
"confirm": "Eliminar"
|
||||
},
|
||||
"toasts": {
|
||||
"loadFailed": "Error al cargar mensajes rápidos",
|
||||
"createFailed": "Error al crear el mensaje rápido",
|
||||
"saveFailed": "Error al guardar el mensaje rápido",
|
||||
"deleteFailed": "Error al eliminar el mensaje rápido",
|
||||
"saveOrderFailed": "Error al guardar el orden",
|
||||
"created": "Mensaje rápido creado",
|
||||
"saved": "Mensaje rápido guardado",
|
||||
"deleted": "Mensaje rápido eliminado"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,8 @@
|
||||
"chat_channels": "Canaux de chat",
|
||||
"web_service": "Service Web",
|
||||
"model_providers": "Fournisseurs de Modèles",
|
||||
"experts": "Experts"
|
||||
"experts": "Experts",
|
||||
"quick_messages": "Messages rapides"
|
||||
}
|
||||
},
|
||||
"AppearanceSettings": {
|
||||
@@ -2108,5 +2109,43 @@
|
||||
"disableFailed": "Échec de la désactivation de l'expert",
|
||||
"openFolderFailed": "Échec de l'ouverture du dossier"
|
||||
}
|
||||
},
|
||||
"QuickMessagesSettings": {
|
||||
"title": "Messages rapides",
|
||||
"description": "Gérez des extraits de messages réutilisables. Glissez pour réorganiser.",
|
||||
"loading": "Chargement des messages rapides…",
|
||||
"emptyList": "Aucun message rapide. Cliquez sur « Nouveau » pour en créer un.",
|
||||
"emptySelection": "Sélectionnez un message rapide à modifier.",
|
||||
"searchPlaceholder": "Rechercher par titre ou contenu",
|
||||
"untitled": "Sans titre",
|
||||
"actions": {
|
||||
"new": "Nouveau",
|
||||
"save": "Enregistrer",
|
||||
"delete": "Supprimer",
|
||||
"dragSort": "Glisser pour réorganiser",
|
||||
"dragSortMessage": "Réorganiser le message rapide : {name}"
|
||||
},
|
||||
"fields": {
|
||||
"title": "Titre",
|
||||
"titlePlaceholder": "Donnez un titre court à ce message",
|
||||
"content": "Contenu",
|
||||
"contentPlaceholder": "Saisissez ici le contenu du message"
|
||||
},
|
||||
"confirmDelete": {
|
||||
"title": "Supprimer le message rapide ?",
|
||||
"message": "Cela supprimera définitivement « {name} ». Êtes-vous sûr ?",
|
||||
"cancel": "Annuler",
|
||||
"confirm": "Supprimer"
|
||||
},
|
||||
"toasts": {
|
||||
"loadFailed": "Échec du chargement des messages rapides",
|
||||
"createFailed": "Échec de la création du message rapide",
|
||||
"saveFailed": "Échec de l'enregistrement du message rapide",
|
||||
"deleteFailed": "Échec de la suppression du message rapide",
|
||||
"saveOrderFailed": "Échec de l'enregistrement de l'ordre",
|
||||
"created": "Message rapide créé",
|
||||
"saved": "Message rapide enregistré",
|
||||
"deleted": "Message rapide supprimé"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,8 @@
|
||||
"chat_channels": "チャットチャンネル",
|
||||
"web_service": "Webサービス",
|
||||
"model_providers": "モデルプロバイダー",
|
||||
"experts": "エキスパート"
|
||||
"experts": "エキスパート",
|
||||
"quick_messages": "クイックメッセージ"
|
||||
}
|
||||
},
|
||||
"AppearanceSettings": {
|
||||
@@ -2108,5 +2109,43 @@
|
||||
"disableFailed": "エキスパートの無効化に失敗しました",
|
||||
"openFolderFailed": "フォルダを開けませんでした"
|
||||
}
|
||||
},
|
||||
"QuickMessagesSettings": {
|
||||
"title": "クイックメッセージ",
|
||||
"description": "再利用可能なメッセージスニペットを管理します。ドラッグして並べ替えできます。",
|
||||
"loading": "クイックメッセージを読み込み中…",
|
||||
"emptyList": "クイックメッセージはまだありません。「新規」をクリックして作成してください。",
|
||||
"emptySelection": "編集するクイックメッセージを選択してください。",
|
||||
"searchPlaceholder": "タイトルまたは内容で検索",
|
||||
"untitled": "無題",
|
||||
"actions": {
|
||||
"new": "新規",
|
||||
"save": "保存",
|
||||
"delete": "削除",
|
||||
"dragSort": "ドラッグして並べ替え",
|
||||
"dragSortMessage": "クイックメッセージを並べ替え: {name}"
|
||||
},
|
||||
"fields": {
|
||||
"title": "タイトル",
|
||||
"titlePlaceholder": "このメッセージに短いタイトルを付けてください",
|
||||
"content": "内容",
|
||||
"contentPlaceholder": "ここにメッセージ内容を入力してください"
|
||||
},
|
||||
"confirmDelete": {
|
||||
"title": "クイックメッセージを削除しますか?",
|
||||
"message": "「{name}」を完全に削除します。よろしいですか?",
|
||||
"cancel": "キャンセル",
|
||||
"confirm": "削除"
|
||||
},
|
||||
"toasts": {
|
||||
"loadFailed": "クイックメッセージの読み込みに失敗しました",
|
||||
"createFailed": "クイックメッセージの作成に失敗しました",
|
||||
"saveFailed": "クイックメッセージの保存に失敗しました",
|
||||
"deleteFailed": "クイックメッセージの削除に失敗しました",
|
||||
"saveOrderFailed": "順序の保存に失敗しました",
|
||||
"created": "クイックメッセージを作成しました",
|
||||
"saved": "クイックメッセージを保存しました",
|
||||
"deleted": "クイックメッセージを削除しました"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,8 @@
|
||||
"chat_channels": "채팅 채널",
|
||||
"web_service": "웹 서비스",
|
||||
"model_providers": "모델 제공업체",
|
||||
"experts": "전문가"
|
||||
"experts": "전문가",
|
||||
"quick_messages": "빠른 메시지"
|
||||
}
|
||||
},
|
||||
"AppearanceSettings": {
|
||||
@@ -2108,5 +2109,43 @@
|
||||
"disableFailed": "전문가 비활성화에 실패했습니다",
|
||||
"openFolderFailed": "폴더를 열지 못했습니다"
|
||||
}
|
||||
},
|
||||
"QuickMessagesSettings": {
|
||||
"title": "빠른 메시지",
|
||||
"description": "재사용 가능한 메시지 스니펫을 관리합니다. 드래그하여 순서를 변경하세요.",
|
||||
"loading": "빠른 메시지 불러오는 중…",
|
||||
"emptyList": "빠른 메시지가 없습니다. \"새로 만들기\"를 클릭하여 추가하세요.",
|
||||
"emptySelection": "편집할 빠른 메시지를 선택하세요.",
|
||||
"searchPlaceholder": "제목 또는 내용으로 검색",
|
||||
"untitled": "제목 없음",
|
||||
"actions": {
|
||||
"new": "새로 만들기",
|
||||
"save": "저장",
|
||||
"delete": "삭제",
|
||||
"dragSort": "드래그하여 순서 변경",
|
||||
"dragSortMessage": "빠른 메시지 순서 변경: {name}"
|
||||
},
|
||||
"fields": {
|
||||
"title": "제목",
|
||||
"titlePlaceholder": "이 메시지에 짧은 제목을 지정하세요",
|
||||
"content": "내용",
|
||||
"contentPlaceholder": "여기에 메시지 내용을 입력하세요"
|
||||
},
|
||||
"confirmDelete": {
|
||||
"title": "빠른 메시지를 삭제하시겠습니까?",
|
||||
"message": "\"{name}\" 이(가) 영구적으로 삭제됩니다. 계속하시겠습니까?",
|
||||
"cancel": "취소",
|
||||
"confirm": "삭제"
|
||||
},
|
||||
"toasts": {
|
||||
"loadFailed": "빠른 메시지를 불러오지 못했습니다",
|
||||
"createFailed": "빠른 메시지를 만들지 못했습니다",
|
||||
"saveFailed": "빠른 메시지를 저장하지 못했습니다",
|
||||
"deleteFailed": "빠른 메시지를 삭제하지 못했습니다",
|
||||
"saveOrderFailed": "순서를 저장하지 못했습니다",
|
||||
"created": "빠른 메시지가 생성되었습니다",
|
||||
"saved": "빠른 메시지가 저장되었습니다",
|
||||
"deleted": "빠른 메시지가 삭제되었습니다"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,8 @@
|
||||
"chat_channels": "Canais de chat",
|
||||
"web_service": "Serviço Web",
|
||||
"model_providers": "Provedores de Modelos",
|
||||
"experts": "Especialistas"
|
||||
"experts": "Especialistas",
|
||||
"quick_messages": "Mensagens rápidas"
|
||||
}
|
||||
},
|
||||
"AppearanceSettings": {
|
||||
@@ -2108,5 +2109,43 @@
|
||||
"disableFailed": "Falha ao desativar o especialista",
|
||||
"openFolderFailed": "Falha ao abrir a pasta"
|
||||
}
|
||||
},
|
||||
"QuickMessagesSettings": {
|
||||
"title": "Mensagens rápidas",
|
||||
"description": "Gerencie trechos de mensagens reutilizáveis. Arraste para reordenar.",
|
||||
"loading": "Carregando mensagens rápidas…",
|
||||
"emptyList": "Ainda não há mensagens rápidas. Clique em \"Nova\" para criar uma.",
|
||||
"emptySelection": "Selecione uma mensagem rápida para editar.",
|
||||
"searchPlaceholder": "Pesquisar por título ou conteúdo",
|
||||
"untitled": "Sem título",
|
||||
"actions": {
|
||||
"new": "Nova",
|
||||
"save": "Salvar",
|
||||
"delete": "Excluir",
|
||||
"dragSort": "Arraste para reordenar",
|
||||
"dragSortMessage": "Arrastar para reordenar mensagem rápida: {name}"
|
||||
},
|
||||
"fields": {
|
||||
"title": "Título",
|
||||
"titlePlaceholder": "Dê um título curto a esta mensagem",
|
||||
"content": "Conteúdo",
|
||||
"contentPlaceholder": "Digite aqui o conteúdo da mensagem"
|
||||
},
|
||||
"confirmDelete": {
|
||||
"title": "Excluir mensagem rápida?",
|
||||
"message": "Isso excluirá permanentemente \"{name}\". Tem certeza?",
|
||||
"cancel": "Cancelar",
|
||||
"confirm": "Excluir"
|
||||
},
|
||||
"toasts": {
|
||||
"loadFailed": "Falha ao carregar mensagens rápidas",
|
||||
"createFailed": "Falha ao criar mensagem rápida",
|
||||
"saveFailed": "Falha ao salvar mensagem rápida",
|
||||
"deleteFailed": "Falha ao excluir mensagem rápida",
|
||||
"saveOrderFailed": "Falha ao salvar a ordem",
|
||||
"created": "Mensagem rápida criada",
|
||||
"saved": "Mensagem rápida salva",
|
||||
"deleted": "Mensagem rápida excluída"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,8 @@
|
||||
"chat_channels": "消息渠道",
|
||||
"web_service": "Web 服务",
|
||||
"model_providers": "模型供应商",
|
||||
"experts": "专家"
|
||||
"experts": "专家",
|
||||
"quick_messages": "快捷消息"
|
||||
}
|
||||
},
|
||||
"AppearanceSettings": {
|
||||
@@ -2108,5 +2109,43 @@
|
||||
"disableFailed": "禁用专家失败",
|
||||
"openFolderFailed": "打开目录失败"
|
||||
}
|
||||
},
|
||||
"QuickMessagesSettings": {
|
||||
"title": "快捷消息",
|
||||
"description": "管理可复用的消息片段。拖拽以调整顺序。",
|
||||
"loading": "加载快捷消息…",
|
||||
"emptyList": "暂无快捷消息。点击“新建”创建一条。",
|
||||
"emptySelection": "选择一条快捷消息进行编辑。",
|
||||
"searchPlaceholder": "按标题或内容搜索",
|
||||
"untitled": "未命名",
|
||||
"actions": {
|
||||
"new": "新建",
|
||||
"save": "保存",
|
||||
"delete": "删除",
|
||||
"dragSort": "拖拽排序",
|
||||
"dragSortMessage": "拖拽排序快捷消息:{name}"
|
||||
},
|
||||
"fields": {
|
||||
"title": "标题",
|
||||
"titlePlaceholder": "为此消息取一个简短的标题",
|
||||
"content": "内容",
|
||||
"contentPlaceholder": "在此输入消息内容"
|
||||
},
|
||||
"confirmDelete": {
|
||||
"title": "删除快捷消息?",
|
||||
"message": "将永久删除“{name}”。确定吗?",
|
||||
"cancel": "取消",
|
||||
"confirm": "删除"
|
||||
},
|
||||
"toasts": {
|
||||
"loadFailed": "加载快捷消息失败",
|
||||
"createFailed": "创建快捷消息失败",
|
||||
"saveFailed": "保存快捷消息失败",
|
||||
"deleteFailed": "删除快捷消息失败",
|
||||
"saveOrderFailed": "保存顺序失败",
|
||||
"created": "已创建快捷消息",
|
||||
"saved": "已保存快捷消息",
|
||||
"deleted": "已删除快捷消息"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,8 @@
|
||||
"chat_channels": "訊息頻道",
|
||||
"web_service": "Web 服務",
|
||||
"model_providers": "模型供應商",
|
||||
"experts": "專家"
|
||||
"experts": "專家",
|
||||
"quick_messages": "快捷訊息"
|
||||
}
|
||||
},
|
||||
"AppearanceSettings": {
|
||||
@@ -2108,5 +2109,43 @@
|
||||
"disableFailed": "停用專家失敗",
|
||||
"openFolderFailed": "開啟目錄失敗"
|
||||
}
|
||||
},
|
||||
"QuickMessagesSettings": {
|
||||
"title": "快捷訊息",
|
||||
"description": "管理可重用的訊息片段。拖曳以調整順序。",
|
||||
"loading": "載入快捷訊息…",
|
||||
"emptyList": "尚無快捷訊息。點擊「新增」建立一條。",
|
||||
"emptySelection": "選擇一條快捷訊息進行編輯。",
|
||||
"searchPlaceholder": "依標題或內容搜尋",
|
||||
"untitled": "未命名",
|
||||
"actions": {
|
||||
"new": "新增",
|
||||
"save": "儲存",
|
||||
"delete": "刪除",
|
||||
"dragSort": "拖曳排序",
|
||||
"dragSortMessage": "拖曳排序快捷訊息:{name}"
|
||||
},
|
||||
"fields": {
|
||||
"title": "標題",
|
||||
"titlePlaceholder": "為此訊息取一個簡短的標題",
|
||||
"content": "內容",
|
||||
"contentPlaceholder": "在此輸入訊息內容"
|
||||
},
|
||||
"confirmDelete": {
|
||||
"title": "刪除快捷訊息?",
|
||||
"message": "將永久刪除「{name}」。確定嗎?",
|
||||
"cancel": "取消",
|
||||
"confirm": "刪除"
|
||||
},
|
||||
"toasts": {
|
||||
"loadFailed": "載入快捷訊息失敗",
|
||||
"createFailed": "建立快捷訊息失敗",
|
||||
"saveFailed": "儲存快捷訊息失敗",
|
||||
"deleteFailed": "刪除快捷訊息失敗",
|
||||
"saveOrderFailed": "儲存順序失敗",
|
||||
"created": "已建立快捷訊息",
|
||||
"saved": "已儲存快捷訊息",
|
||||
"deleted": "已刪除快捷訊息"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ import type {
|
||||
ChatChannelMessageLog,
|
||||
ModelProviderInfo,
|
||||
PluginCheckSummary,
|
||||
QuickMessage,
|
||||
} from "./types"
|
||||
|
||||
export async function listConversations(params?: {
|
||||
@@ -1258,6 +1259,42 @@ export async function bootstrapFolderCommandsFromPackageJson(
|
||||
})
|
||||
}
|
||||
|
||||
// Quick message management
|
||||
|
||||
export async function quickMessagesList(): Promise<QuickMessage[]> {
|
||||
return getTransport().call("quick_messages_list")
|
||||
}
|
||||
|
||||
export async function quickMessagesCreate(params: {
|
||||
title: string
|
||||
content: string
|
||||
}): Promise<QuickMessage> {
|
||||
return getTransport().call("quick_messages_create", {
|
||||
title: params.title,
|
||||
content: params.content,
|
||||
})
|
||||
}
|
||||
|
||||
export async function quickMessagesUpdate(params: {
|
||||
id: number
|
||||
title?: string
|
||||
content?: string
|
||||
}): Promise<QuickMessage> {
|
||||
return getTransport().call("quick_messages_update", {
|
||||
id: params.id,
|
||||
title: params.title ?? null,
|
||||
content: params.content ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
export async function quickMessagesDelete(id: number): Promise<void> {
|
||||
return getTransport().call("quick_messages_delete", { id })
|
||||
}
|
||||
|
||||
export async function quickMessagesReorder(ids: number[]): Promise<void> {
|
||||
return getTransport().call("quick_messages_reorder", { ids })
|
||||
}
|
||||
|
||||
// Directory browser (for web/server mode)
|
||||
|
||||
export async function getHomeDirectory(): Promise<string> {
|
||||
|
||||
@@ -769,6 +769,15 @@ export interface FolderCommand {
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface QuickMessage {
|
||||
id: number
|
||||
title: string
|
||||
content: string
|
||||
sort_order: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface GitStatusEntry {
|
||||
status: string
|
||||
file: string
|
||||
|
||||
Reference in New Issue
Block a user