feat: 新增技能管理API,支持获取技能列表和切换技能状态
This commit is contained in:
parent
d7ff969560
commit
38b9f661ee
@ -5,6 +5,34 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use super::GatewayState;
|
use super::GatewayState;
|
||||||
use crate::config::{Config, get_default_config_path};
|
use crate::config::{Config, get_default_config_path};
|
||||||
|
use crate::skills::SkillWithStatus;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct SkillToggleRequest {
|
||||||
|
pub name: String,
|
||||||
|
pub scope: String,
|
||||||
|
pub enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct SkillToggleResponse {
|
||||||
|
success: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
changed: Option<bool>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
available: Option<bool>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
disabled_in_scopes: Option<Vec<String>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct SkillListResponse {
|
||||||
|
skills_system_enabled: bool,
|
||||||
|
total: usize,
|
||||||
|
skills: Vec<SkillWithStatus>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct HealthResponse {
|
pub struct HealthResponse {
|
||||||
@ -182,3 +210,87 @@ pub async fn mcp_status(
|
|||||||
};
|
};
|
||||||
Json(status)
|
Json(status)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// GET /api/skills — Return all discovered skills with their disabled status
|
||||||
|
pub async fn skills_list(
|
||||||
|
State(state): State<Arc<GatewayState>>,
|
||||||
|
) -> Json<SkillListResponse> {
|
||||||
|
let skills_enabled = state.config.read().await.skills.enabled;
|
||||||
|
|
||||||
|
if !skills_enabled {
|
||||||
|
return Json(SkillListResponse {
|
||||||
|
skills_system_enabled: false,
|
||||||
|
total: 0,
|
||||||
|
skills: vec![],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let skills = state.skills.list_skills_with_status();
|
||||||
|
let total = skills.len();
|
||||||
|
|
||||||
|
Json(SkillListResponse {
|
||||||
|
skills_system_enabled: true,
|
||||||
|
total,
|
||||||
|
skills,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/skills/toggle — Enable or disable a specific skill
|
||||||
|
pub async fn skills_toggle(
|
||||||
|
State(state): State<Arc<GatewayState>>,
|
||||||
|
Json(req): Json<SkillToggleRequest>,
|
||||||
|
) -> (StatusCode, Json<SkillToggleResponse>) {
|
||||||
|
let scope = match crate::skills::SkillScope::parse(&req.scope) {
|
||||||
|
Some(s) => s,
|
||||||
|
None => {
|
||||||
|
return (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(SkillToggleResponse {
|
||||||
|
success: false,
|
||||||
|
changed: None,
|
||||||
|
available: None,
|
||||||
|
disabled_in_scopes: None,
|
||||||
|
error: Some(format!("invalid scope: {}", req.scope)),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = if req.enabled {
|
||||||
|
state.skills.enable_skill(scope, &req.name, true)
|
||||||
|
} else {
|
||||||
|
state.skills.disable_skill(scope, &req.name, true)
|
||||||
|
};
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(change) => (
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(SkillToggleResponse {
|
||||||
|
success: true,
|
||||||
|
changed: Some(change.changed),
|
||||||
|
available: Some(change.available),
|
||||||
|
disabled_in_scopes: Some(
|
||||||
|
change.disabled_in_scopes.iter().map(|s| s.as_str().to_string()).collect(),
|
||||||
|
),
|
||||||
|
error: None,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Err(msg) => {
|
||||||
|
let status = if msg.contains("not found") {
|
||||||
|
StatusCode::NOT_FOUND
|
||||||
|
} else {
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
};
|
||||||
|
(
|
||||||
|
status,
|
||||||
|
Json(SkillToggleResponse {
|
||||||
|
success: false,
|
||||||
|
changed: None,
|
||||||
|
available: None,
|
||||||
|
disabled_in_scopes: None,
|
||||||
|
error: Some(msg),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -63,6 +63,7 @@ pub struct GatewayState {
|
|||||||
pub cancel_manager: CancelManager,
|
pub cancel_manager: CancelManager,
|
||||||
pub restart_tx: watch::Sender<bool>,
|
pub restart_tx: watch::Sender<bool>,
|
||||||
pub mcp_manager: Option<Arc<crate::mcp::client::McpClientManager>>,
|
pub mcp_manager: Option<Arc<crate::mcp::client::McpClientManager>>,
|
||||||
|
pub skills: Arc<SkillRuntime>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GatewayState {
|
impl GatewayState {
|
||||||
@ -93,7 +94,7 @@ impl GatewayState {
|
|||||||
config.time.timezone.clone(),
|
config.time.timezone.clone(),
|
||||||
provider_config,
|
provider_config,
|
||||||
provider_configs,
|
provider_configs,
|
||||||
skills,
|
skills.clone(),
|
||||||
Arc::new(BusSessionMessageSender::new(bus.clone())),
|
Arc::new(BusSessionMessageSender::new(bus.clone())),
|
||||||
std::collections::HashSet::new(),
|
std::collections::HashSet::new(),
|
||||||
config.tools.task.clone(),
|
config.tools.task.clone(),
|
||||||
@ -121,6 +122,7 @@ impl GatewayState {
|
|||||||
cancel_manager,
|
cancel_manager,
|
||||||
restart_tx,
|
restart_tx,
|
||||||
mcp_manager,
|
mcp_manager,
|
||||||
|
skills,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -230,6 +232,8 @@ pub async fn run(
|
|||||||
.route("/api/config", routing::get(http::get_config).put(http::save_config))
|
.route("/api/config", routing::get(http::get_config).put(http::save_config))
|
||||||
.route("/api/restart", routing::post(http::restart))
|
.route("/api/restart", routing::post(http::restart))
|
||||||
.route("/api/mcp/status", routing::get(http::mcp_status))
|
.route("/api/mcp/status", routing::get(http::mcp_status))
|
||||||
|
.route("/api/skills", routing::get(http::skills_list))
|
||||||
|
.route("/api/skills/toggle", routing::post(http::skills_toggle))
|
||||||
.route("/ws", routing::get(ws::ws_handler))
|
.route("/ws", routing::get(ws::ws_handler))
|
||||||
.fallback(static_handler)
|
.fallback(static_handler)
|
||||||
.with_state(state.clone())
|
.with_state(state.clone())
|
||||||
@ -240,6 +244,8 @@ pub async fn run(
|
|||||||
.route("/api/config", routing::get(http::get_config).put(http::save_config))
|
.route("/api/config", routing::get(http::get_config).put(http::save_config))
|
||||||
.route("/api/restart", routing::post(http::restart))
|
.route("/api/restart", routing::post(http::restart))
|
||||||
.route("/api/mcp/status", routing::get(http::mcp_status))
|
.route("/api/mcp/status", routing::get(http::mcp_status))
|
||||||
|
.route("/api/skills", routing::get(http::skills_list))
|
||||||
|
.route("/api/skills/toggle", routing::post(http::skills_toggle))
|
||||||
.route("/ws", routing::get(ws::ws_handler))
|
.route("/ws", routing::get(ws::ws_handler))
|
||||||
.fallback_service(ServeDir::new(&static_dir))
|
.fallback_service(ServeDir::new(&static_dir))
|
||||||
.with_state(state.clone())
|
.with_state(state.clone())
|
||||||
|
|||||||
@ -25,6 +25,17 @@ pub struct Skill {
|
|||||||
pub path: PathBuf,
|
pub path: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A skill entry with its disabled status across scopes.
|
||||||
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct SkillWithStatus {
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub source: String,
|
||||||
|
pub path: String,
|
||||||
|
/// Which scopes have this skill disabled. Empty means enabled.
|
||||||
|
pub disabled_in_scopes: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum SkillSource {
|
pub enum SkillSource {
|
||||||
User,
|
User,
|
||||||
@ -163,6 +174,24 @@ impl SkillRuntime {
|
|||||||
.clone()
|
.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// List all discovered skills including disabled ones, with their disabled scopes.
|
||||||
|
pub fn list_skills_with_status(&self) -> Vec<SkillWithStatus> {
|
||||||
|
let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
|
||||||
|
let catalog = SkillCatalog::discover_without_state(&self.config, &cwd);
|
||||||
|
let disable_state = load_skill_disable_state(&cwd);
|
||||||
|
|
||||||
|
catalog.skills.iter().map(|skill| {
|
||||||
|
let disabled_scopes = disable_state.disabled_scopes_for(&skill.name);
|
||||||
|
SkillWithStatus {
|
||||||
|
name: skill.name.clone(),
|
||||||
|
description: skill.description.clone(),
|
||||||
|
source: skill.source.as_str().to_string(),
|
||||||
|
path: skill.path.display().to_string(),
|
||||||
|
disabled_in_scopes: disabled_scopes.iter().map(|s| s.as_str().to_string()).collect(),
|
||||||
|
}
|
||||||
|
}).collect()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get_skill(&self, name: &str) -> Option<Skill> {
|
pub fn get_skill(&self, name: &str) -> Option<Skill> {
|
||||||
self.catalog
|
self.catalog
|
||||||
.read()
|
.read()
|
||||||
@ -1435,4 +1464,65 @@ mod tests {
|
|||||||
let payload = catalog.activation_event_payload("demo-user-openclaw").unwrap();
|
let payload = catalog.activation_event_payload("demo-user-openclaw").unwrap();
|
||||||
assert_eq!(payload["source"], "user_openclaw");
|
assert_eq!(payload["source"], "user_openclaw");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_list_skills_with_status_includes_disabled() {
|
||||||
|
let _lock = acquire_test_lock();
|
||||||
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
|
let home_dir = temp_dir.path().join("home");
|
||||||
|
let project_dir = temp_dir.path().join("project");
|
||||||
|
fs::create_dir_all(&home_dir).unwrap();
|
||||||
|
fs::create_dir_all(&project_dir).unwrap();
|
||||||
|
let _home = HomeDirGuard::enter(&home_dir);
|
||||||
|
let _guard = CurrentDirGuard::enter(&project_dir);
|
||||||
|
|
||||||
|
let skill_dir = project_dir.join(".picobot").join("skills").join("demo");
|
||||||
|
fs::create_dir_all(&skill_dir).unwrap();
|
||||||
|
fs::write(
|
||||||
|
skill_dir.join("SKILL.md"),
|
||||||
|
"---\ndescription: A demo skill\n---\nBody here",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
save_skill_state_file(
|
||||||
|
&project_dir.join(".picobot").join("skill-state.json"),
|
||||||
|
&SkillStateFile {
|
||||||
|
disabled_skills: vec!["demo".to_string()],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let runtime = SkillRuntime::from_config(SkillsConfig {
|
||||||
|
enabled: true,
|
||||||
|
sources: vec!["project".to_string()],
|
||||||
|
max_index_chars: 4000,
|
||||||
|
max_listed_skills: 32,
|
||||||
|
});
|
||||||
|
|
||||||
|
// list_skills_with_status should include the disabled skill with its disabled scope
|
||||||
|
let skills = runtime.list_skills_with_status();
|
||||||
|
assert_eq!(
|
||||||
|
skills.len(),
|
||||||
|
1,
|
||||||
|
"list_skills_with_status should include disabled skills"
|
||||||
|
);
|
||||||
|
assert_eq!(skills[0].name, "demo");
|
||||||
|
assert_eq!(skills[0].description, "A demo skill");
|
||||||
|
assert_eq!(skills[0].source, "project");
|
||||||
|
assert_eq!(skills[0].disabled_in_scopes, vec!["project".to_string()]);
|
||||||
|
|
||||||
|
// list_skills (normal) should filter out disabled skills
|
||||||
|
let active = runtime.list_skills();
|
||||||
|
assert_eq!(
|
||||||
|
active.len(),
|
||||||
|
0,
|
||||||
|
"list_skills should filter out disabled skills"
|
||||||
|
);
|
||||||
|
|
||||||
|
// After enabling, list_skills_with_status should report no disabled scopes
|
||||||
|
runtime.enable_skill(SkillScope::Project, "demo", true).unwrap();
|
||||||
|
let skills_after = runtime.list_skills_with_status();
|
||||||
|
assert_eq!(skills_after.len(), 1);
|
||||||
|
assert!(skills_after[0].disabled_in_scopes.is_empty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -32,6 +32,20 @@ interface McpServerConfig {
|
|||||||
description?: string
|
description?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SkillItem {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
source: string
|
||||||
|
path: string
|
||||||
|
disabled_in_scopes: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SkillListResponse {
|
||||||
|
skills_system_enabled: boolean
|
||||||
|
total: number
|
||||||
|
skills: SkillItem[]
|
||||||
|
}
|
||||||
|
|
||||||
interface McpServerStatus {
|
interface McpServerStatus {
|
||||||
key: string
|
key: string
|
||||||
name: string
|
name: string
|
||||||
@ -306,6 +320,8 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
|
|||||||
const [showRestartDialog, setShowRestartDialog] = useState(false)
|
const [showRestartDialog, setShowRestartDialog] = useState(false)
|
||||||
const [restarting, setRestarting] = useState(false)
|
const [restarting, setRestarting] = useState(false)
|
||||||
const [mcpStatus, setMcpStatus] = useState<McpStatusResponse | null>(null)
|
const [mcpStatus, setMcpStatus] = useState<McpStatusResponse | null>(null)
|
||||||
|
const [skillList, setSkillList] = useState<SkillListResponse | null>(null)
|
||||||
|
const [skillListLoading, setSkillListLoading] = useState(false)
|
||||||
|
|
||||||
const fetchMcpStatus = useCallback(async () => {
|
const fetchMcpStatus = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@ -314,6 +330,24 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
|
|||||||
} catch { /* ignore fetch errors */ }
|
} catch { /* ignore fetch errors */ }
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const fetchSkillList = useCallback(async () => {
|
||||||
|
setSkillListLoading(true)
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/skills')
|
||||||
|
if (resp.ok) setSkillList(await resp.json())
|
||||||
|
} catch { /* ignore fetch errors */ }
|
||||||
|
finally { setSkillListLoading(false) }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const toggleSkill = useCallback(async (name: string, scope: string, enabled: boolean) => {
|
||||||
|
const resp = await fetch('/api/skills/toggle', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name, scope, enabled }),
|
||||||
|
})
|
||||||
|
return resp
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
if (dirty && !confirm('有未保存的更改,确定要关闭吗?')) return
|
if (dirty && !confirm('有未保存的更改,确定要关闭吗?')) return
|
||||||
onClose()
|
onClose()
|
||||||
@ -332,6 +366,11 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
|
|||||||
if (activeTab === 'mcp') fetchMcpStatus()
|
if (activeTab === 'mcp') fetchMcpStatus()
|
||||||
}, [activeTab, fetchMcpStatus])
|
}, [activeTab, fetchMcpStatus])
|
||||||
|
|
||||||
|
// Fetch skill list when skills tab is selected
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'skills') fetchSkillList()
|
||||||
|
}, [activeTab, fetchSkillList])
|
||||||
|
|
||||||
// ESC to close
|
// ESC to close
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') handleClose() }
|
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') handleClose() }
|
||||||
@ -621,9 +660,85 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
|
|||||||
examplePaths={['D:\\my-skills', '/home/user/shared-skills']}
|
examplePaths={['D:\\my-skills', '/home/user/shared-skills']}
|
||||||
/>
|
/>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
{renderDiscoveredSkills()}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const renderDiscoveredSkills = () => {
|
||||||
|
if (!skillList || !skillList.skills_system_enabled) return null
|
||||||
|
|
||||||
|
const skills = skillList.skills
|
||||||
|
|
||||||
|
const handleToggle = async (name: string, currentlyEnabled: boolean) => {
|
||||||
|
// Optimistic update
|
||||||
|
const prevSkillList = skillList
|
||||||
|
setSkillList({
|
||||||
|
...skillList,
|
||||||
|
skills: skills.map(s =>
|
||||||
|
s.name === name
|
||||||
|
? { ...s, disabled_in_scopes: currentlyEnabled ? ['project'] : [] }
|
||||||
|
: s
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await toggleSkill(name, 'project', !currentlyEnabled)
|
||||||
|
const data = await resp.json()
|
||||||
|
if (!resp.ok || !data.success) {
|
||||||
|
// Rollback
|
||||||
|
setSkillList(prevSkillList)
|
||||||
|
setToast(data.error || '切换技能状态失败')
|
||||||
|
setTimeout(() => setToast(''), 3000)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Update with server response
|
||||||
|
setSkillList({
|
||||||
|
...prevSkillList,
|
||||||
|
skills: prevSkillList.skills.map(s =>
|
||||||
|
s.name === name
|
||||||
|
? { ...s, disabled_in_scopes: data.disabled_in_scopes || [] }
|
||||||
|
: s
|
||||||
|
),
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// Rollback
|
||||||
|
setSkillList(prevSkillList)
|
||||||
|
setToast('网络错误,切换技能状态失败')
|
||||||
|
setTimeout(() => setToast(''), 3000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionCard title="已发现技能">
|
||||||
|
{skillListLoading && skills.length === 0 ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-[var(--text-muted)]">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" /> 加载中...
|
||||||
|
</div>
|
||||||
|
) : skills.length === 0 ? (
|
||||||
|
<p className="text-sm text-[var(--text-muted)]">未发现任何技能,请检查来源目录配置</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{skills.map(skill => {
|
||||||
|
const isEnabled = skill.disabled_in_scopes.length === 0
|
||||||
|
return (
|
||||||
|
<div key={skill.name} className="flex items-center gap-3 py-2 px-2 rounded-lg hover:bg-[var(--bg-hover)] transition-colors">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-mono text-[var(--text-primary)]">{skill.name}</span>
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--bg-tertiary)] text-[var(--text-muted)] uppercase tracking-wider">{skill.source}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-[var(--text-muted)] truncate mt-0.5">{skill.description}</p>
|
||||||
|
</div>
|
||||||
|
<Toggle checked={isEnabled} onChange={() => handleToggle(skill.name, isEnabled)} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const TASK_KNOWN_TOOLS: KnownSource[] = [
|
const TASK_KNOWN_TOOLS: KnownSource[] = [
|
||||||
{ key: 'read', label: 'Read', description: '读取文件' },
|
{ key: 'read', label: 'Read', description: '读取文件' },
|
||||||
{ key: 'edit', label: 'Edit', description: '编辑文件' },
|
{ key: 'edit', label: 'Edit', description: '编辑文件' },
|
||||||
@ -883,7 +998,7 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm animate-[fadeIn_0.15s_ease-out]" onClick={handleClose}>
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm animate-[fadeIn_0.15s_ease-out]">
|
||||||
<div
|
<div
|
||||||
className="relative flex flex-col w-[92vw] max-w-4xl h-[85vh] rounded-2xl border border-[var(--border-color)] bg-[var(--bg-primary)] shadow-2xl overflow-hidden animate-[scaleIn_0.2s_ease-out]"
|
className="relative flex flex-col w-[92vw] max-w-4xl h-[85vh] rounded-2xl border border-[var(--border-color)] bg-[var(--bg-primary)] shadow-2xl overflow-hidden animate-[scaleIn_0.2s_ease-out]"
|
||||||
onClick={e => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user