mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
release: v0.69.0 - upgrade aster-rust to v0.13.0, skill content dialog
This commit is contained in:
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "proxycast",
|
||||
"private": true,
|
||||
"version": "0.67.0",
|
||||
"version": "0.69.0",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
Generated
+24
-23
@@ -211,12 +211,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||
|
||||
[[package]]
|
||||
name = "aster"
|
||||
version = "0.11.0"
|
||||
source = "git+https://github.com/astercloud/aster-rust?tag=v0.11.0#c4a9c40b48bcb0c77375b10ccb1313366fb4e7ba"
|
||||
name = "aster-core"
|
||||
version = "0.13.0"
|
||||
source = "git+https://github.com/astercloud/aster-rust?tag=v0.13.0#4422f761c3701d2e3af1b0e6db5d4394f80d9c54"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"anyhow",
|
||||
"aster-models",
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
"aws-config",
|
||||
@@ -304,8 +305,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aster-models"
|
||||
version = "0.12.0"
|
||||
source = "git+https://github.com/astercloud/aster-rust?tag=v0.12.0#f6c1ad342eb58accb61524985fa78ea7f4b8be2b"
|
||||
version = "0.13.0"
|
||||
source = "git+https://github.com/astercloud/aster-rust?tag=v0.13.0#4422f761c3701d2e3af1b0e6db5d4394f80d9c54"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -6684,11 +6685,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast"
|
||||
version = "0.68.0"
|
||||
version = "0.69.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
"aster",
|
||||
"aster-core",
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
"axum 0.7.9",
|
||||
@@ -6782,9 +6783,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-agent"
|
||||
version = "0.68.0"
|
||||
version = "0.69.0"
|
||||
dependencies = [
|
||||
"aster",
|
||||
"aster-core",
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"dirs 5.0.1",
|
||||
@@ -6805,7 +6806,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-config"
|
||||
version = "0.68.0"
|
||||
version = "0.69.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"parking_lot",
|
||||
@@ -6821,7 +6822,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-core"
|
||||
version = "0.68.0"
|
||||
version = "0.69.0"
|
||||
dependencies = [
|
||||
"aster-models",
|
||||
"async-trait",
|
||||
@@ -6861,7 +6862,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-credential"
|
||||
version = "0.68.0"
|
||||
version = "0.69.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"base64 0.22.1",
|
||||
@@ -6896,7 +6897,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-infra"
|
||||
version = "0.68.0"
|
||||
version = "0.69.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dashmap 5.5.3",
|
||||
@@ -6916,7 +6917,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-mcp"
|
||||
version = "0.68.0"
|
||||
version = "0.69.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"glob",
|
||||
@@ -6947,7 +6948,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-processor"
|
||||
version = "0.68.0"
|
||||
version = "0.69.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"parking_lot",
|
||||
@@ -6966,7 +6967,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-providers"
|
||||
version = "0.68.0"
|
||||
version = "0.69.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
@@ -7018,7 +7019,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-server"
|
||||
version = "0.68.0"
|
||||
version = "0.69.0"
|
||||
dependencies = [
|
||||
"async-stream",
|
||||
"axum 0.7.9",
|
||||
@@ -7060,7 +7061,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-server-utils"
|
||||
version = "0.68.0"
|
||||
version = "0.69.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"futures",
|
||||
@@ -7075,10 +7076,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-services"
|
||||
version = "0.68.0"
|
||||
version = "0.69.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"aster",
|
||||
"aster-core",
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
@@ -7116,7 +7117,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-skills"
|
||||
version = "0.68.0"
|
||||
version = "0.69.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dirs 5.0.1",
|
||||
@@ -7132,7 +7133,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-terminal"
|
||||
version = "0.68.0"
|
||||
version = "0.69.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@@ -7159,7 +7160,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-websocket"
|
||||
version = "0.68.0"
|
||||
version = "0.69.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
|
||||
@@ -3,7 +3,7 @@ members = ["crates/*"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.68.0"
|
||||
version = "0.69.0"
|
||||
edition = "2021"
|
||||
authors = ["you"]
|
||||
repository = "https://github.com/aiclientproxy/proxycast"
|
||||
@@ -118,11 +118,11 @@ enigo = "0.3"
|
||||
# Aster Agent Framework
|
||||
# 开发时使用本地 aster-rust,CI/CD 使用远程 GitHub 仓库
|
||||
# 本地开发: path = "../../../astercloud/aster-rust/crates/aster" (相对 src-tauri/)
|
||||
# CI/CD: git = "https://github.com/astercloud/aster-rust", tag = "v0.11.0"
|
||||
# aster = { path = "../../../astercloud/aster-rust/crates/aster" }
|
||||
aster = { git = "https://github.com/astercloud/aster-rust", tag = "v0.11.0" }
|
||||
# CI/CD: git = "https://github.com/astercloud/aster-rust", tag = "v0.13.0"
|
||||
# aster = { package = "aster-core", path = "../../../astercloud/aster-rust/crates/aster" }
|
||||
aster = { package = "aster-core", git = "https://github.com/astercloud/aster-rust", tag = "v0.13.0" }
|
||||
# 本地开发: aster-models = { path = "../../../astercloud/aster-rust/crates/aster-models" }
|
||||
aster-models = { git = "https://github.com/astercloud/aster-rust", tag = "v0.12.0" }
|
||||
aster-models = { git = "https://github.com/astercloud/aster-rust", tag = "v0.13.0" }
|
||||
|
||||
# MCP (Model Context Protocol)
|
||||
rmcp = { version = "0.12.0", features = ["client", "transport-io", "transport-child-process"] }
|
||||
@@ -185,7 +185,7 @@ version = "2.4"
|
||||
|
||||
[package]
|
||||
name = "proxycast"
|
||||
version = "0.68.0"
|
||||
version = "0.69.0"
|
||||
description = "AI API Proxy Desktop App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -756,6 +756,7 @@ pub fn run() {
|
||||
commands::skill_cmd::add_skill_repo,
|
||||
commands::skill_cmd::remove_skill_repo,
|
||||
commands::skill_cmd::get_installed_proxycast_skills,
|
||||
commands::skill_cmd::get_local_skill_content,
|
||||
// Skill Execution commands
|
||||
commands::skill_exec_cmd::execute_skill,
|
||||
commands::skill_exec_cmd::list_executable_skills,
|
||||
|
||||
@@ -5,7 +5,7 @@ use crate::models::app_type::AppType;
|
||||
use crate::models::skill_model::{Skill, SkillRepo, SkillState};
|
||||
use chrono::Utc;
|
||||
use proxycast_services::skill_service::SkillService;
|
||||
use std::path::Path;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
|
||||
@@ -42,6 +42,75 @@ pub fn scan_installed_skills(skills_dir: &Path) -> Vec<String> {
|
||||
skills
|
||||
}
|
||||
|
||||
fn get_skills_dir(app_type: &AppType) -> Result<PathBuf, String> {
|
||||
let home = dirs::home_dir().ok_or_else(|| "Failed to get home directory".to_string())?;
|
||||
|
||||
let skills_dir = match app_type {
|
||||
AppType::Claude => home.join(".claude").join("skills"),
|
||||
AppType::Codex => home.join(".codex").join("skills"),
|
||||
AppType::Gemini => home.join(".gemini").join("skills"),
|
||||
AppType::ProxyCast => home.join(".proxycast").join("skills"),
|
||||
};
|
||||
|
||||
Ok(skills_dir)
|
||||
}
|
||||
|
||||
fn validate_skill_directory(directory: &str) -> Result<(), String> {
|
||||
if directory.trim().is_empty() {
|
||||
return Err("Skill directory is required".to_string());
|
||||
}
|
||||
|
||||
if directory.contains("..") || directory.contains('/') || directory.contains('\\') {
|
||||
return Err("Invalid skill directory".to_string());
|
||||
}
|
||||
|
||||
let mut components = Path::new(directory).components();
|
||||
let first = components
|
||||
.next()
|
||||
.ok_or_else(|| "Skill directory is required".to_string())?;
|
||||
|
||||
if components.next().is_some() {
|
||||
return Err("Invalid skill directory".to_string());
|
||||
}
|
||||
|
||||
match first {
|
||||
Component::Normal(_) => Ok(()),
|
||||
_ => Err("Invalid skill directory".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_local_skill_content(skills_dir: &Path, directory: &str) -> Result<String, String> {
|
||||
validate_skill_directory(directory)?;
|
||||
|
||||
if !skills_dir.exists() {
|
||||
return Err("Skills directory not found".to_string());
|
||||
}
|
||||
|
||||
let canonical_skills_dir = skills_dir
|
||||
.canonicalize()
|
||||
.map_err(|e| format!("Failed to resolve skills directory: {e}"))?;
|
||||
|
||||
let skill_dir = skills_dir.join(directory);
|
||||
if !skill_dir.exists() {
|
||||
return Err(format!("Skill not found: {directory}"));
|
||||
}
|
||||
|
||||
let canonical_skill_dir = skill_dir
|
||||
.canonicalize()
|
||||
.map_err(|e| format!("Failed to resolve skill directory: {e}"))?;
|
||||
|
||||
if !canonical_skill_dir.starts_with(&canonical_skills_dir) {
|
||||
return Err("Invalid skill directory path".to_string());
|
||||
}
|
||||
|
||||
let skill_md_path = canonical_skill_dir.join("SKILL.md");
|
||||
if !skill_md_path.is_file() {
|
||||
return Err(format!("SKILL.md not found for skill: {directory}"));
|
||||
}
|
||||
|
||||
std::fs::read_to_string(&skill_md_path).map_err(|e| format!("Failed to read SKILL.md: {e}"))
|
||||
}
|
||||
|
||||
/// 获取已安装的 ProxyCast Skills 目录列表
|
||||
///
|
||||
/// 扫描 ~/.proxycast/skills/ 目录,返回包含 SKILL.md 的子目录名列表。
|
||||
@@ -57,6 +126,24 @@ pub async fn get_installed_proxycast_skills() -> Result<Vec<String>, String> {
|
||||
Ok(scan_installed_skills(&skills_dir))
|
||||
}
|
||||
|
||||
/// 获取本地已安装 Skill 的 SKILL.md 内容
|
||||
///
|
||||
/// 仅支持读取本地 Skills 目录下的文件,包含目录合法性与路径穿越防护。
|
||||
///
|
||||
/// # Arguments
|
||||
/// - `app`: 应用类型(proxycast/claude/codex/gemini)
|
||||
/// - `directory`: Skill 目录名
|
||||
///
|
||||
/// # Returns
|
||||
/// - `Ok(String)`: SKILL.md 的文本内容
|
||||
/// - `Err(String)`: 错误信息
|
||||
#[tauri::command]
|
||||
pub fn get_local_skill_content(app: String, directory: String) -> Result<String, String> {
|
||||
let app_type: AppType = app.parse().map_err(|e: String| e)?;
|
||||
let skills_dir = get_skills_dir(&app_type)?;
|
||||
read_local_skill_content(&skills_dir, &directory)
|
||||
}
|
||||
|
||||
pub struct SkillServiceState(pub Arc<SkillService>);
|
||||
|
||||
fn get_skill_key(app_type: &AppType, directory: &str) -> String {
|
||||
@@ -374,4 +461,58 @@ mod tests {
|
||||
assert_eq!(discovered.len(), 1);
|
||||
assert!(discovered.contains(&"valid-skill".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_local_skill_content_success() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let skills_dir = temp_dir.path().join("skills");
|
||||
let skill_dir = skills_dir.join("demo-skill");
|
||||
std::fs::create_dir_all(&skill_dir).unwrap();
|
||||
std::fs::write(skill_dir.join("SKILL.md"), "# Demo Skill\ncontent").unwrap();
|
||||
|
||||
let content = read_local_skill_content(&skills_dir, "demo-skill").unwrap();
|
||||
assert!(content.contains("# Demo Skill"));
|
||||
assert!(content.contains("content"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_local_skill_content_rejects_traversal() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let skills_dir = temp_dir.path().join("skills");
|
||||
std::fs::create_dir_all(&skills_dir).unwrap();
|
||||
|
||||
let err = read_local_skill_content(&skills_dir, "../outside").unwrap_err();
|
||||
assert!(err.contains("Invalid skill directory"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_local_skill_content_missing_skill_md() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let skills_dir = temp_dir.path().join("skills");
|
||||
let skill_dir = skills_dir.join("no-skill-md");
|
||||
std::fs::create_dir_all(&skill_dir).unwrap();
|
||||
|
||||
let err = read_local_skill_content(&skills_dir, "no-skill-md").unwrap_err();
|
||||
assert!(err.contains("SKILL.md not found"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn test_read_local_skill_content_rejects_symlink_escape() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let skills_dir = temp_dir.path().join("skills");
|
||||
std::fs::create_dir_all(&skills_dir).unwrap();
|
||||
|
||||
let outside_dir = temp_dir.path().join("outside-skill");
|
||||
std::fs::create_dir_all(&outside_dir).unwrap();
|
||||
std::fs::write(outside_dir.join("SKILL.md"), "# Outside").unwrap();
|
||||
|
||||
let symlink_dir = skills_dir.join("escape-skill");
|
||||
symlink(&outside_dir, &symlink_dir).unwrap();
|
||||
|
||||
let err = read_local_skill_content(&skills_dir, "escape-skill").unwrap_err();
|
||||
assert!(err.contains("Invalid skill directory path"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "ProxyCast",
|
||||
"version": "0.67.0",
|
||||
"version": "0.69.0",
|
||||
"identifier": "com.proxycast.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "ProxyCast",
|
||||
"version": "0.67.0",
|
||||
"version": "0.69.0",
|
||||
"identifier": "com.proxycast.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
@@ -7,10 +7,14 @@
|
||||
* **Validates: Requirements 5.1, 5.2**
|
||||
*/
|
||||
|
||||
import { describe, expect } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { test } from "@fast-check/vitest";
|
||||
import * as fc from "fast-check";
|
||||
import { getSkillSource, type SkillSource } from "./SkillCard";
|
||||
import {
|
||||
canViewLocalSkillContent,
|
||||
getSkillSource,
|
||||
type SkillSource,
|
||||
} from "./SkillCard";
|
||||
import type { Skill } from "@/lib/api/skills";
|
||||
|
||||
/**
|
||||
@@ -130,3 +134,32 @@ describe("getSkillSource", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canViewLocalSkillContent", () => {
|
||||
it("本地且已安装 skill 应可查看内容", () => {
|
||||
const skill = createSkill({
|
||||
installed: true,
|
||||
repoOwner: undefined,
|
||||
repoName: undefined,
|
||||
});
|
||||
expect(canViewLocalSkillContent(skill)).toBe(true);
|
||||
});
|
||||
|
||||
it("本地但未安装 skill 不可查看内容", () => {
|
||||
const skill = createSkill({
|
||||
installed: false,
|
||||
repoOwner: undefined,
|
||||
repoName: undefined,
|
||||
});
|
||||
expect(canViewLocalSkillContent(skill)).toBe(false);
|
||||
});
|
||||
|
||||
it("非本地已安装 skill 不可查看内容", () => {
|
||||
const skill = createSkill({
|
||||
installed: true,
|
||||
repoOwner: "proxycast",
|
||||
repoName: "skills",
|
||||
});
|
||||
expect(canViewLocalSkillContent(skill)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,13 +6,21 @@
|
||||
* - 显示 Skill 基本信息(名称、描述、来源)
|
||||
* - 安装/卸载操作按钮
|
||||
* - 执行按钮(仅已安装的 Skill 显示)
|
||||
* - 查看内容按钮(仅本地且已安装的 Skill 显示)
|
||||
* - GitHub 链接按钮
|
||||
*
|
||||
* @module components/skills
|
||||
* @requirements 6.1, 6.3
|
||||
*/
|
||||
|
||||
import { Download, Trash2, ExternalLink, Loader2, Play } from "lucide-react";
|
||||
import {
|
||||
Download,
|
||||
Trash2,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
Play,
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
import type { Skill } from "@/lib/api/skills";
|
||||
|
||||
/**
|
||||
@@ -45,6 +53,19 @@ export function getSkillSource(skill: Skill): SkillSource {
|
||||
return "community";
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否可查看本地 Skill 内容
|
||||
*
|
||||
* 仅本地且已安装的 Skill 支持查看 SKILL.md。
|
||||
*
|
||||
* @param skill - Skill 对象
|
||||
* @returns 是否显示查看内容入口
|
||||
*/
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function canViewLocalSkillContent(skill: Skill): boolean {
|
||||
return skill.installed && getSkillSource(skill) === "local";
|
||||
}
|
||||
|
||||
/**
|
||||
* 来源标签配置
|
||||
*/
|
||||
@@ -90,6 +111,7 @@ interface SkillCardProps {
|
||||
onInstall: (directory: string) => void;
|
||||
onUninstall: (directory: string) => void;
|
||||
onExecute?: (skill: Skill) => void;
|
||||
onViewContent?: (skill: Skill) => void;
|
||||
installing: boolean;
|
||||
}
|
||||
|
||||
@@ -99,6 +121,7 @@ interface SkillCardProps {
|
||||
* 展示单个 Skill 的信息和操作按钮,包括:
|
||||
* - 安装/卸载按钮
|
||||
* - 执行按钮(仅已安装的 Skill 显示)
|
||||
* - 查看内容按钮(仅本地且已安装的 Skill 显示)
|
||||
* - GitHub 链接按钮
|
||||
*
|
||||
* @param props - 组件属性
|
||||
@@ -111,6 +134,7 @@ export function SkillCard({
|
||||
onInstall,
|
||||
onUninstall,
|
||||
onExecute,
|
||||
onViewContent,
|
||||
installing,
|
||||
}: SkillCardProps) {
|
||||
const handleAction = () => {
|
||||
@@ -138,7 +162,14 @@ export function SkillCard({
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewContent = () => {
|
||||
if (onViewContent && canViewLocalSkillContent(skill)) {
|
||||
onViewContent(skill);
|
||||
}
|
||||
};
|
||||
|
||||
const source = getSkillSource(skill);
|
||||
const showViewContent = Boolean(onViewContent && canViewLocalSkillContent(skill));
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4 hover:shadow-md transition-shadow">
|
||||
@@ -210,6 +241,19 @@ export function SkillCard({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 查看内容按钮 - 仅本地且已安装的 Skill 显示 */}
|
||||
{showViewContent && (
|
||||
<button
|
||||
onClick={handleViewContent}
|
||||
disabled={installing}
|
||||
className="flex items-center justify-center gap-2 rounded-lg border border-amber-500 px-3 py-2 text-sm font-medium text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-950/30 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="查看 SKILL.md"
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
查看内容
|
||||
</button>
|
||||
)}
|
||||
|
||||
{skill.readmeUrl && (
|
||||
<button
|
||||
onClick={openGithub}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { act, type ComponentProps } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { SkillContentDialog } from "./SkillContentDialog";
|
||||
|
||||
interface RenderResult {
|
||||
container: HTMLDivElement;
|
||||
root: Root;
|
||||
}
|
||||
|
||||
const mountedRoots: RenderResult[] = [];
|
||||
|
||||
function renderDialog(
|
||||
overrides: Partial<ComponentProps<typeof SkillContentDialog>> = {},
|
||||
): RenderResult {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<SkillContentDialog
|
||||
skillName="test-skill"
|
||||
open={true}
|
||||
onOpenChange={() => {}}
|
||||
content=""
|
||||
loading={false}
|
||||
error={null}
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const rendered = { container, root };
|
||||
mountedRoots.push(rendered);
|
||||
return rendered;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
}
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (mountedRoots.length > 0) {
|
||||
const mounted = mountedRoots.pop();
|
||||
if (!mounted) {
|
||||
break;
|
||||
}
|
||||
act(() => {
|
||||
mounted.root.unmount();
|
||||
});
|
||||
mounted.container.remove();
|
||||
}
|
||||
});
|
||||
|
||||
describe("SkillContentDialog", () => {
|
||||
it("加载中时应显示加载提示", () => {
|
||||
renderDialog({ loading: true });
|
||||
expect(document.body.textContent).toContain("正在读取 SKILL.md...");
|
||||
});
|
||||
|
||||
it("出错时应显示错误信息", () => {
|
||||
renderDialog({ error: "读取失败: 文件不存在" });
|
||||
expect(document.body.textContent).toContain("读取失败: 文件不存在");
|
||||
});
|
||||
|
||||
it("有内容时应渲染 markdown 文本", () => {
|
||||
renderDialog({ content: "# 标题\n正文内容" });
|
||||
expect(document.body.textContent).toContain("标题");
|
||||
expect(document.body.textContent).toContain("正文内容");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* @file SkillContentDialog.tsx
|
||||
* @description Skill 内容查看弹窗,展示本地 SKILL.md 的 Markdown 预览
|
||||
*
|
||||
* 功能:
|
||||
* - 展示 Skill 名称和说明
|
||||
* - 加载中状态
|
||||
* - 错误状态
|
||||
* - Markdown 内容预览
|
||||
*
|
||||
* @module components/skills
|
||||
*/
|
||||
|
||||
import { Loader2, AlertCircle, FileText } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { MarkdownPreview } from "@/components/preview/MarkdownPreview";
|
||||
import "@/components/preview/preview.css";
|
||||
|
||||
export interface SkillContentDialogProps {
|
||||
/** Skill 名称 */
|
||||
skillName: string;
|
||||
/** 是否打开 */
|
||||
open: boolean;
|
||||
/** 打开状态变化 */
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** SKILL.md 内容 */
|
||||
content: string;
|
||||
/** 是否正在加载 */
|
||||
loading: boolean;
|
||||
/** 错误信息 */
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function SkillContentDialog({
|
||||
skillName,
|
||||
open,
|
||||
onOpenChange,
|
||||
content,
|
||||
loading,
|
||||
error,
|
||||
}: SkillContentDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent maxWidth="max-w-5xl" className="h-[80vh] p-0 overflow-hidden">
|
||||
<DialogHeader className="border-b px-6 py-4">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5 text-muted-foreground" />
|
||||
{skillName}
|
||||
</DialogTitle>
|
||||
<DialogDescription>查看本地 SKILL.md 内容</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="h-[calc(80vh-88px)] overflow-auto [--terminal-bg:#ffffff] [--terminal-fg:#111827] [--terminal-border:#e5e7eb] [--terminal-muted:#6b7280] [--terminal-accent:#2563eb] [--terminal-tab-bg:#f8fafc]">
|
||||
{loading && (
|
||||
<div className="flex h-full items-center justify-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
正在读取 SKILL.md...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && error && (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 px-6 text-center text-red-600">
|
||||
<AlertCircle className="h-8 w-8" />
|
||||
<p className="text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
{content.trim() ? (
|
||||
<MarkdownPreview content={content} />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center px-6 text-sm text-muted-foreground">
|
||||
SKILL.md 内容为空
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useState, forwardRef, useImperativeHandle } from "react";
|
||||
import { useState, forwardRef, useImperativeHandle, useRef } from "react";
|
||||
import { RefreshCw, Search, Settings } from "lucide-react";
|
||||
import { useSkills } from "@/hooks/useSkills";
|
||||
import { SkillCard } from "./SkillCard";
|
||||
import { RepoManagerPanel } from "./RepoManagerPanel";
|
||||
import { SkillExecutionDialog } from "./SkillExecutionDialog";
|
||||
import { SkillContentDialog } from "./SkillContentDialog";
|
||||
import { HelpTip } from "@/components/HelpTip";
|
||||
import type { AppType, Skill } from "@/lib/api/skills";
|
||||
import { skillsApi, type AppType, type Skill } from "@/lib/api/skills";
|
||||
|
||||
interface SkillsPageProps {
|
||||
initialApp?: AppType;
|
||||
@@ -32,6 +33,14 @@ export const SkillsPage = forwardRef<SkillsPageRef, SkillsPageProps>(
|
||||
const [executionDialogOpen, setExecutionDialogOpen] = useState(false);
|
||||
const [selectedSkillForExecution, setSelectedSkillForExecution] =
|
||||
useState<Skill | null>(null);
|
||||
// 内容查看对话框状态
|
||||
const [contentDialogOpen, setContentDialogOpen] = useState(false);
|
||||
const [selectedSkillForContent, setSelectedSkillForContent] =
|
||||
useState<Skill | null>(null);
|
||||
const [skillContent, setSkillContent] = useState("");
|
||||
const [contentLoading, setContentLoading] = useState(false);
|
||||
const [contentError, setContentError] = useState<string | null>(null);
|
||||
const contentRequestIdRef = useRef(0);
|
||||
|
||||
const {
|
||||
skills,
|
||||
@@ -102,6 +111,53 @@ export const SkillsPage = forwardRef<SkillsPageRef, SkillsPageProps>(
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理查看内容按钮点击
|
||||
* 读取本地 SKILL.md 并打开预览弹窗
|
||||
*/
|
||||
const handleViewContent = async (skill: Skill) => {
|
||||
const requestId = ++contentRequestIdRef.current;
|
||||
|
||||
setSelectedSkillForContent(skill);
|
||||
setContentDialogOpen(true);
|
||||
setSkillContent("");
|
||||
setContentError(null);
|
||||
setContentLoading(true);
|
||||
|
||||
try {
|
||||
const content = await skillsApi.getLocalSkillContent(skill.directory, app);
|
||||
if (requestId !== contentRequestIdRef.current) {
|
||||
return;
|
||||
}
|
||||
setSkillContent(content);
|
||||
} catch (e) {
|
||||
if (requestId !== contentRequestIdRef.current) {
|
||||
return;
|
||||
}
|
||||
setContentError(
|
||||
`读取失败: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
} finally {
|
||||
if (requestId === contentRequestIdRef.current) {
|
||||
setContentLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理内容查看对话框关闭
|
||||
*/
|
||||
const handleContentDialogClose = (open: boolean) => {
|
||||
setContentDialogOpen(open);
|
||||
if (!open) {
|
||||
contentRequestIdRef.current += 1;
|
||||
setSelectedSkillForContent(null);
|
||||
setSkillContent("");
|
||||
setContentError(null);
|
||||
setContentLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredSkills = skills.filter((skill) => {
|
||||
const matchesSearch =
|
||||
skill.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
@@ -252,6 +308,7 @@ export const SkillsPage = forwardRef<SkillsPageRef, SkillsPageProps>(
|
||||
onInstall={handleInstall}
|
||||
onUninstall={handleUninstall}
|
||||
onExecute={handleExecute}
|
||||
onViewContent={handleViewContent}
|
||||
installing={installingSkills.has(skill.directory)}
|
||||
/>
|
||||
))}
|
||||
@@ -277,6 +334,18 @@ export const SkillsPage = forwardRef<SkillsPageRef, SkillsPageProps>(
|
||||
onOpenChange={handleExecutionDialogClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Skill 内容查看对话框 */}
|
||||
{selectedSkillForContent && (
|
||||
<SkillContentDialog
|
||||
skillName={selectedSkillForContent.name}
|
||||
open={contentDialogOpen}
|
||||
onOpenChange={handleContentDialogClose}
|
||||
content={skillContent}
|
||||
loading={contentLoading}
|
||||
error={contentError}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -11,3 +11,4 @@ export {
|
||||
SkillExecutionDialog,
|
||||
type SkillExecutionDialogProps,
|
||||
} from "./SkillExecutionDialog";
|
||||
export { SkillContentDialog, type SkillContentDialogProps } from "./SkillContentDialog";
|
||||
|
||||
@@ -63,4 +63,18 @@ export const skillsApi = {
|
||||
async getInstalledProxyCastSkills(): Promise<string[]> {
|
||||
return safeInvoke("get_installed_proxycast_skills");
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取本地已安装 Skill 的 SKILL.md 内容
|
||||
*
|
||||
* @param directory Skill 目录名
|
||||
* @param app 应用类型
|
||||
* @returns SKILL.md 的文本内容
|
||||
*/
|
||||
async getLocalSkillContent(
|
||||
directory: string,
|
||||
app: AppType = "proxycast",
|
||||
): Promise<string> {
|
||||
return safeInvoke("get_local_skill_content", { app, directory });
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user