Compare commits

..

4 Commits

Author SHA1 Message Date
YoVinchen de0758eba4 Merge branch 'refs/heads/main' into fix/skill-duplicate-install-detection 2026-01-25 13:39:14 +08:00
YoVinchen 22552f7a7f fix(skills): prevent duplicate skill installation from different repos
- Add directory conflict detection before installation
- Fix installed status check to match repo owner and name
- Add i18n translations for conflict error messages
2026-01-25 06:10:24 +08:00
Jason 1c6689a0bc chore: update Cargo.lock version to 3.10.2 2026-01-24 23:49:06 +08:00
Jason 9404341f14 feat(ui): replace update badge dot with ArrowUpCircle icon
- Replace blue dot indicator with lucide ArrowUpCircle icon
- Change color scheme from blue to green for better visibility
- Enlarge button (h-8 w-8) and icon (h-5 w-5) for better UX
- Move UpdateBadge from title area to after settings icon
2026-01-24 23:32:05 +08:00
9 changed files with 83 additions and 25 deletions
+1 -1
View File
@@ -701,7 +701,7 @@ dependencies = [
[[package]]
name = "cc-switch"
version = "3.10.1"
version = "3.10.2"
dependencies = [
"anyhow",
"async-stream",
+47 -1
View File
@@ -252,6 +252,50 @@ impl SkillService {
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| skill.directory.clone());
// 检查数据库中是否已有同名 directory 的 skill(来自其他仓库)
let existing_skills = db.get_all_installed_skills()?;
for existing in existing_skills.values() {
if existing.directory.eq_ignore_ascii_case(&install_name) {
// 检查是否来自同一仓库
let same_repo = existing.repo_owner.as_deref() == Some(&skill.repo_owner)
&& existing.repo_name.as_deref() == Some(&skill.repo_name);
if same_repo {
// 同一仓库的同名 skill,返回现有记录(可能需要更新启用状态)
let mut updated = existing.clone();
updated.apps.set_enabled_for(current_app, true);
db.save_skill(&updated)?;
Self::sync_to_app_dir(&updated.directory, current_app)?;
log::info!(
"Skill {} 已存在,更新 {:?} 启用状态",
updated.name,
current_app
);
return Ok(updated);
} else {
// 不同仓库的同名 skill,报错
return Err(anyhow!(format_skill_error(
"SKILL_DIRECTORY_CONFLICT",
&[
("directory", &install_name),
(
"existing_repo",
&format!(
"{}/{}",
existing.repo_owner.as_deref().unwrap_or("unknown"),
existing.repo_name.as_deref().unwrap_or("unknown")
)
),
(
"new_repo",
&format!("{}/{}", skill.repo_owner, skill.repo_name)
),
],
Some("uninstallFirst"),
)));
}
}
}
let dest = ssot_dir.join(&install_name);
// 如果已存在则跳过下载
@@ -933,10 +977,12 @@ impl SkillService {
Ok(meta)
}
/// 去重技能列表
/// 去重技能列表(基于完整 key,不同仓库的同名 skill 分开显示)
fn deduplicate_discoverable_skills(skills: &mut Vec<DiscoverableSkill>) {
let mut seen = HashMap::new();
skills.retain(|skill| {
// 使用完整 keyowner/repo:directory)作为唯一标识
// 这样不同仓库的同名 skill 会分开显示
let unique_key = skill.key.to_lowercase();
if let std::collections::hash_map::Entry::Vacant(e) = seen.entry(unique_key) {
e.insert(true);
+6 -7
View File
@@ -750,13 +750,6 @@ function App() {
>
CC Switch
</a>
<UpdateBadge
onClick={() => {
setSettingsDefaultTab("about");
setCurrentView("settings");
}}
className="absolute -top-4 -right-4"
/>
</div>
<Button
variant="ghost"
@@ -770,6 +763,12 @@ function App() {
>
<Settings className="w-4 h-4" />
</Button>
<UpdateBadge
onClick={() => {
setSettingsDefaultTab("about");
setCurrentView("settings");
}}
/>
{isCurrentAppTakeoverActive && (
<Button
variant="ghost"
+4 -8
View File
@@ -1,6 +1,7 @@
import { useUpdate } from "@/contexts/UpdateContext";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { ArrowUpCircle } from "lucide-react";
interface UpdateBadgeProps {
className?: string;
@@ -30,17 +31,12 @@ export function UpdateBadge({ className = "", onClick }: UpdateBadgeProps) {
aria-label={title}
onClick={onClick}
className={`
relative h-6 w-6 rounded-full
${isActive ? "text-blue-600 dark:text-blue-300 hover:bg-blue-50 dark:hover:bg-blue-500/10" : "text-muted-foreground hover:bg-muted/60"}
relative h-8 w-8 rounded-full
${isActive ? "text-green-600 dark:text-green-400 hover:bg-green-50 dark:hover:bg-green-500/10" : "text-muted-foreground hover:bg-muted/60"}
${className}
`}
>
<span
className={`
absolute inset-0 m-auto h-2 w-2 rounded-full ring-1 ring-background
${isActive ? "bg-blue-500 dark:bg-blue-400" : "bg-blue-300/70 dark:bg-blue-300/60"}
`}
/>
<ArrowUpCircle className="h-5 w-5" />
</Button>
);
}
+14 -5
View File
@@ -65,10 +65,17 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
const addRepoMutation = useAddSkillRepo();
const removeRepoMutation = useRemoveSkillRepo();
// 已安装的 directory 集合
const installedDirs = useMemo(() => {
// 已安装的 skill key 集合(使用 directory + repoOwner + repoName 组合判断)
const installedKeys = useMemo(() => {
if (!installedSkills) return new Set<string>();
return new Set(installedSkills.map((s) => s.directory.toLowerCase()));
return new Set(
installedSkills.map((s) => {
// 构建唯一 keydirectory + repoOwner + repoName
const owner = s.repoOwner?.toLowerCase() || "";
const name = s.repoName?.toLowerCase() || "";
return `${s.directory.toLowerCase()}:${owner}:${name}`;
}),
);
}, [installedSkills]);
type DiscoverableSkillItem = DiscoverableSkill & { installed: boolean };
@@ -80,12 +87,14 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
const installName =
d.directory.split("/").pop()?.toLowerCase() ||
d.directory.toLowerCase();
// 使用 directory + repoOwner + repoName 组合判断是否已安装
const key = `${installName}:${d.repoOwner.toLowerCase()}:${d.repoName.toLowerCase()}`;
return {
...d,
installed: installedDirs.has(installName),
installed: installedKeys.has(key),
};
});
}, [discoverableSkills, installedDirs]);
}, [discoverableSkills, installedKeys]);
const loading = loadingDiscoverable || fetchingDiscoverable;
+3 -1
View File
@@ -972,6 +972,7 @@
"downloadTimeoutHint": "Please check network connection or retry later",
"skillPathNotFound": "Skill path '{{path}}' not found in repository {{owner}}/{{name}}",
"skillDirNotFound": "Skill directory not found: {{path}}",
"directoryConflict": "Skill directory '{{directory}}' is already occupied by {{existing_repo}}, cannot install from {{new_repo}}",
"emptyArchive": "Downloaded archive is empty",
"downloadFailed": "Download failed: HTTP {{status}}",
"allBranchesFailed": "All branches failed, tried: {{branches}}",
@@ -990,7 +991,8 @@
"retryLater": "Please retry later",
"checkRepoUrl": "Please check repository URL and branch name",
"checkDiskSpace": "Please check disk space",
"checkPermission": "Please check directory permissions"
"checkPermission": "Please check directory permissions",
"uninstallFirst": "Please uninstall the existing skill with the same name first"
}
},
"repo": {
+3 -1
View File
@@ -972,6 +972,7 @@
"downloadTimeoutHint": "ネットワークを確認するか、時間をおいて再試行してください",
"skillPathNotFound": "リポジトリ {{owner}}/{{name}} にスキルパス '{{path}}' がありません",
"skillDirNotFound": "スキルディレクトリが見つかりません: {{path}}",
"directoryConflict": "スキルディレクトリ '{{directory}}' は既に {{existing_repo}} で使用されています。{{new_repo}} からインストールできません",
"emptyArchive": "ダウンロードしたアーカイブが空です",
"downloadFailed": "ダウンロードに失敗しました: HTTP {{status}}",
"allBranchesFailed": "すべてのブランチで失敗しました。試行: {{branches}}",
@@ -990,7 +991,8 @@
"retryLater": "時間をおいて再試行してください",
"checkRepoUrl": "リポジトリ URL とブランチ名を確認してください",
"checkDiskSpace": "ディスク容量を確認してください",
"checkPermission": "ディレクトリの権限を確認してください"
"checkPermission": "ディレクトリの権限を確認してください",
"uninstallFirst": "同名のスキルを先にアンインストールしてください"
}
},
"repo": {
+3 -1
View File
@@ -972,6 +972,7 @@
"downloadTimeoutHint": "请检查网络连接或稍后重试",
"skillPathNotFound": "仓库 {{owner}}/{{name}} 中未找到技能路径 '{{path}}'",
"skillDirNotFound": "技能目录不存在:{{path}}",
"directoryConflict": "技能目录 '{{directory}}' 已被 {{existing_repo}} 占用,无法从 {{new_repo}} 安装",
"emptyArchive": "下载的压缩包为空",
"downloadFailed": "下载失败:HTTP {{status}}",
"allBranchesFailed": "所有分支下载失败,尝试了:{{branches}}",
@@ -990,7 +991,8 @@
"retryLater": "请稍后重试",
"checkRepoUrl": "请检查仓库地址和分支名称",
"checkDiskSpace": "请检查磁盘空间",
"checkPermission": "请检查目录权限"
"checkPermission": "请检查目录权限",
"uninstallFirst": "请先卸载已安装的同名技能"
}
},
"repo": {
+2
View File
@@ -35,6 +35,7 @@ function getErrorI18nKey(code: string): string {
DOWNLOAD_TIMEOUT: "skills.error.downloadTimeout",
DOWNLOAD_FAILED: "skills.error.downloadFailed",
SKILL_DIR_NOT_FOUND: "skills.error.skillDirNotFound",
SKILL_DIRECTORY_CONFLICT: "skills.error.directoryConflict",
EMPTY_ARCHIVE: "skills.error.emptyArchive",
GET_HOME_DIR_FAILED: "skills.error.getHomeDirFailed",
};
@@ -52,6 +53,7 @@ function getSuggestionI18nKey(suggestion: string): string {
retryLater: "skills.error.suggestion.retryLater",
checkRepoUrl: "skills.error.suggestion.checkRepoUrl",
checkPermission: "skills.error.suggestion.checkPermission",
uninstallFirst: "skills.error.suggestion.uninstallFirst",
http403: "skills.error.http403",
http404: "skills.error.http404",
http429: "skills.error.http429",