diff --git a/Cargo.lock b/Cargo.lock index d09e87f..ad22ab1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10642,6 +10642,7 @@ version = "11.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4994acea2522cd2b3b85c1d9529a55991e3ad5e25cdcd3de9d505972c4379424" dependencies = [ + "chrono", "serde_json", "thiserror 2.0.17", "ts-rs-macros 11.1.0", diff --git a/kagent-api-server/CHANGELOG.md b/kagent-api-server/CHANGELOG.md index 84222bf..a8cb36e 100644 --- a/kagent-api-server/CHANGELOG.md +++ b/kagent-api-server/CHANGELOG.md @@ -1,5 +1,13 @@ # CHANGELOG +## 2026-04-16 +- skills API 重构为技能仓库管理接口: + - `GET /api/skills*` 响应改为返回 `repositoryName/gitUrl/syncStatus/commitId/lastSyncedAt/syncError`。 + - `POST /api/skills` 改为接收 Git 仓库地址,并在创建后自动执行首次同步。 + - `PATCH /api/skills/{skill_id}` 改为更新仓库名称、描述与 Git 地址;Git 地址变更时重置同步状态。 + - 新增 `POST /api/skills/{skill_id}/sync` 主动同步接口。 +- skills 相关活动日志名称提取逻辑同步更新为 `repositoryName`。 + ## 2026-02-24 - `/api/projects/item` 项目标识解析增强: - 新增对请求头 `x-kagent-project-id` 的支持,允许通过请求头传递项目上下文。 diff --git a/kagent-api-server/src/middleware/activity.rs b/kagent-api-server/src/middleware/activity.rs index 86d5145..c96ae91 100644 --- a/kagent-api-server/src/middleware/activity.rs +++ b/kagent-api-server/src/middleware/activity.rs @@ -291,7 +291,7 @@ async fn lookup_resource_snapshot( .ok()??; Some(ResourceSnapshot { project_id: Some(kagent_core::projects::external_project_id(row.project_id)), - resource_name: Some(row.skill_name), + resource_name: Some(row.repository_name), }) } "mcp_server" => { @@ -359,7 +359,7 @@ fn extract_resource_name_from_body( let key = match resource_type { "project" => "name", "credential" => "name", - "skill" => "skillName", + "skill" => "repositoryName", "mcp_server" => "name", "code_source" => "name", "model_provider" => "providerName", diff --git a/kagent-api-server/src/routes/skills.rs b/kagent-api-server/src/routes/skills.rs index db4b3da..34a0927 100644 --- a/kagent-api-server/src/routes/skills.rs +++ b/kagent-api-server/src/routes/skills.rs @@ -2,7 +2,7 @@ use axum::Json; use axum::Router; use axum::extract::{Path, State}; use axum::http::StatusCode; -use axum::routing::get; +use axum::routing::{get, post}; use crate::error::AppError; use crate::middleware::project_access::ProjectMember; @@ -17,18 +17,21 @@ pub(crate) fn router() -> Router { ApiRoute::SkillsItem.path(), get(get_skill).patch(patch_skill).delete(delete_skill), ) + .route("/api/skills/{skill_id}/sync", post(sync_skill)) } -// ===== DTO ===== - #[derive(serde::Serialize)] #[serde(rename_all = "camelCase")] struct SkillDto { id: String, project_id: String, - skill_name: String, + repository_name: String, description: String, - resource_id: Option, + git_url: Option, + sync_status: kagent_db::entities::skills::SkillSyncStatus, + commit_id: Option, + last_synced_at: Option, + sync_error: Option, created_at: i64, updated_at: i64, } @@ -38,20 +41,19 @@ impl From for SkillDto { Self { id: kagent_core::skills::external_skill_id(m.id), project_id: kagent_core::projects::external_project_id(m.project_id), - skill_name: m.skill_name, + repository_name: m.repository_name, description: m.description, - resource_id: m - .resource_id - .map(kagent_core::resources::external_resource_id), + git_url: m.git_url, + sync_status: m.sync_status, + commit_id: m.commit_id, + last_synced_at: m.last_synced_at.map(|value| value.timestamp_millis()), + sync_error: m.sync_error, created_at: m.created_at.timestamp_millis(), updated_at: m.updated_at.timestamp_millis(), } } } -// ===== handlers ===== - -/// 列出项目下的技能 async fn list_skills( ProjectMember { project_id, .. }: ProjectMember, State(state): State, @@ -68,46 +70,24 @@ async fn list_skills( #[derive(serde::Deserialize)] #[serde(rename_all = "camelCase")] struct CreateSkillBody { - skill_name: String, + repository_name: String, + #[serde(default)] description: String, - resource_id: Option, + git_url: String, } -/// 创建技能 async fn create_skill( ProjectMember { project_id, .. }: ProjectMember, State(state): State, Json(body): Json, ) -> Result, AppError> { - let resource_id = match body.resource_id.as_deref() { - Some(value) if value.trim().is_empty() => None, - Some(value) => Some( - kagent_core::resources::parse_resource_id(value.trim()) - .map_err(|e| AppError::bad_request(format!("invalid resource id: {e}")))?, - ), - None => None, - }; - - if let Some(resource_id) = resource_id { - let row = kagent_core::resources::get_resource_by_id(state.db.conn(), resource_id) - .await - .map_err(|e| AppError { - status: StatusCode::INTERNAL_SERVER_ERROR, - message: format!("check resource failed: {e:#}"), - })? - .ok_or_else(|| AppError::not_found("resource not found"))?; - if row.project_id != project_id.uuid() { - return Err(AppError::not_found("resource not found")); - } - } - let result = kagent_core::skills::create_skill( state.db.conn(), project_id, kagent_core::skills::CreateSkillInput { - skill_name: body.skill_name, + repository_name: body.repository_name, description: body.description, - resource_id, + git_url: body.git_url, }, ) .await; @@ -121,7 +101,7 @@ async fn create_skill( || msg.contains("UNIQUE constraint failed") { return Err(AppError::conflict( - "skill name already exists in this project", + "repository name already exists in this project", )); } Err(AppError { @@ -132,7 +112,6 @@ async fn create_skill( } } -/// 获取技能详情 async fn get_skill( State(state): State, axum::Extension(user): axum::Extension, @@ -148,7 +127,6 @@ async fn get_skill( })? .ok_or_else(|| AppError::not_found("skill not found"))?; - // 通过资源所属的 project_id 验证权限 let project_id = kagent_db::ids::ProjectId::new(row.project_id); require_project_member(state.db.conn(), project_id, user.id).await?; @@ -158,12 +136,11 @@ async fn get_skill( #[derive(serde::Deserialize)] #[serde(rename_all = "camelCase")] struct PatchSkillBody { - skill_name: Option, + repository_name: Option, description: Option, - resource_id: Option>, + git_url: Option, } -/// 更新技能 async fn patch_skill( State(state): State, axum::Extension(user): axum::Extension, @@ -172,17 +149,7 @@ async fn patch_skill( ) -> Result, AppError> { let skill_id = kagent_core::skills::parse_skill_id(&skill_id) .map_err(|e| AppError::bad_request(format!("invalid skill id: {e}")))?; - let resource_id = match body.resource_id { - Some(Some(value)) if value.trim().is_empty() => Some(None), - Some(Some(value)) => Some(Some( - kagent_core::resources::parse_resource_id(value.trim()) - .map_err(|e| AppError::bad_request(format!("invalid resource id: {e}")))?, - )), - Some(None) => Some(None), - None => None, - }; - // 先加载现有数据,验证权限 let existing = kagent_core::skills::get_skill_by_id(state.db.conn(), skill_id) .await .map_err(|e| AppError { @@ -191,30 +158,16 @@ async fn patch_skill( })? .ok_or_else(|| AppError::not_found("skill not found"))?; - // 通过资源所属的 project_id 验证权限 let project_id = kagent_db::ids::ProjectId::new(existing.project_id); require_project_member(state.db.conn(), project_id, user.id).await?; - if let Some(Some(resource_id)) = resource_id { - let row = kagent_core::resources::get_resource_by_id(state.db.conn(), resource_id) - .await - .map_err(|e| AppError { - status: StatusCode::INTERNAL_SERVER_ERROR, - message: format!("check resource failed: {e:#}"), - })? - .ok_or_else(|| AppError::not_found("resource not found"))?; - if row.project_id != project_id.uuid() { - return Err(AppError::not_found("resource not found")); - } - } - let result = kagent_core::skills::update_skill( state.db.conn(), skill_id, kagent_core::skills::UpdateSkillInput { - skill_name: body.skill_name, + repository_name: body.repository_name, description: body.description, - resource_id, + git_url: body.git_url, }, ) .await; @@ -229,7 +182,7 @@ async fn patch_skill( || msg.contains("UNIQUE constraint failed") { return Err(AppError::conflict( - "skill name already exists in this project", + "repository name already exists in this project", )); } Err(AppError { @@ -240,7 +193,6 @@ async fn patch_skill( } } -/// 删除技能 async fn delete_skill( State(state): State, axum::Extension(user): axum::Extension, @@ -249,7 +201,6 @@ async fn delete_skill( let skill_id = kagent_core::skills::parse_skill_id(&skill_id) .map_err(|e| AppError::bad_request(format!("invalid skill id: {e}")))?; - // 先获取资源验证权限 let existing = kagent_core::skills::get_skill_by_id(state.db.conn(), skill_id) .await .map_err(|e| AppError { @@ -258,7 +209,6 @@ async fn delete_skill( })? .ok_or_else(|| AppError::not_found("skill not found"))?; - // 通过资源所属的 project_id 验证权限 let project_id = kagent_db::ids::ProjectId::new(existing.project_id); require_project_member(state.db.conn(), project_id, user.id).await?; @@ -274,3 +224,33 @@ async fn delete_skill( Err(AppError::not_found("skill not found")) } } + +async fn sync_skill( + State(state): State, + axum::Extension(user): axum::Extension, + Path(skill_id): Path, +) -> Result, AppError> { + let skill_id = kagent_core::skills::parse_skill_id(&skill_id) + .map_err(|e| AppError::bad_request(format!("invalid skill id: {e}")))?; + + let existing = kagent_core::skills::get_skill_by_id(state.db.conn(), skill_id) + .await + .map_err(|e| AppError { + status: StatusCode::INTERNAL_SERVER_ERROR, + message: format!("get skill failed: {e:#}"), + })? + .ok_or_else(|| AppError::not_found("skill not found"))?; + + let project_id = kagent_db::ids::ProjectId::new(existing.project_id); + require_project_member(state.db.conn(), project_id, user.id).await?; + + let row = kagent_core::skills::sync_skill_repository(state.db.conn(), skill_id) + .await + .map_err(|e| AppError { + status: StatusCode::INTERNAL_SERVER_ERROR, + message: format!("sync skill failed: {e:#}"), + })? + .ok_or_else(|| AppError::not_found("skill not found"))?; + + Ok(Json(SkillDto::from(row))) +} diff --git a/kagent-core/CHANGELOG.md b/kagent-core/CHANGELOG.md index ecd79b5..6c945b4 100644 --- a/kagent-core/CHANGELOG.md +++ b/kagent-core/CHANGELOG.md @@ -1,5 +1,12 @@ # CHANGELOG +## 2026-04-16 +- skills 模块重构为技能仓库管理: + - `CreateSkillInput` / `UpdateSkillInput` 改为接收 `repository_name + git_url`。 + - 新增本地仓库目录解析与 Git 同步流程,仓库统一落在 `${KAGENT_HOME:-~/.kagent}/skills/`。 + - 创建技能时会自动触发首次同步,并持久化 `sync_status/commit_id/last_synced_at/sync_error`。 + - 新增手动同步入口 `sync_skill_repository`,支持复用已有本地 checkout 执行更新。 + ## 2026-02-12 - workspaces 生成 codex 配置快照时默认注入 kagent MCP server: - 默认添加 `mcp_servers.kagent`(stdio 命令:`kagent mcp`)。 diff --git a/kagent-core/src/skills.rs b/kagent-core/src/skills.rs index 8024636..9d34bdd 100644 --- a/kagent-core/src/skills.rs +++ b/kagent-core/src/skills.rs @@ -3,7 +3,8 @@ use anyhow::Context; use chrono::{DateTime, Utc}; use kagent_db::entities::skills; -use kagent_db::ids::{ProjectId, ResourceId, SkillId}; +use kagent_db::ids::{ProjectId, SkillId}; +use kagent_git::GitService; use sea_orm::ActiveModelTrait; use sea_orm::ColumnTrait; use sea_orm::DatabaseConnection; @@ -12,20 +13,34 @@ use sea_orm::Order; use sea_orm::QueryFilter; use sea_orm::QueryOrder; use sea_orm::Set; +use std::path::{Path, PathBuf}; use uuid::Uuid; #[derive(Debug, Clone)] pub struct CreateSkillInput { - pub skill_name: String, + pub repository_name: String, pub description: String, - pub resource_id: Option, + pub git_url: String, } #[derive(Debug, Clone, Default)] pub struct UpdateSkillInput { - pub skill_name: Option, + pub repository_name: Option, pub description: Option, - pub resource_id: Option>, + pub git_url: Option, +} + +impl UpdateSkillInput { + pub fn git_url_changed(&self, existing_git_url: &str) -> bool { + self.git_url + .as_deref() + .map(normalize_git_url) + .transpose() + .ok() + .flatten() + .flatten() + .is_some_and(|git_url| git_url != existing_git_url.trim()) + } } pub fn external_skill_id(uuid: Uuid) -> String { @@ -65,24 +80,30 @@ pub async fn create_skill( project_id: ProjectId, input: CreateSkillInput, ) -> anyhow::Result { - let skill_name = input.skill_name.trim().to_string(); - anyhow::ensure!(!skill_name.is_empty(), "skill name is empty"); - let description = input.description.trim().to_string(); - anyhow::ensure!(!description.is_empty(), "skill description is empty"); + let repository_name = normalize_repository_name(&input.repository_name)?; + let git_url = normalize_git_url(&input.git_url)? + .ok_or_else(|| anyhow::anyhow!("git url is empty"))?; + let description = normalize_description(&input.description); let now: DateTime = Utc::now(); let am = skills::ActiveModel { project_id: Set(project_id.uuid()), - skill_name: Set(skill_name), + repository_name: Set(repository_name), description: Set(description), - resource_id: Set(input.resource_id.map(|id| id.uuid())), + git_url: Set(Some(git_url)), + sync_status: Set(skills::SkillSyncStatus::Pending), + commit_id: Set(None), + last_synced_at: Set(None), + sync_error: Set(None), created_at: Set(now.into()), updated_at: Set(now.into()), ..Default::default() }; let row = am.insert(conn).await.context("create skill")?; - Ok(row) + sync_skill_repository(conn, SkillId::new(row.id)) + .await? + .ok_or_else(|| anyhow::anyhow!("skill disappeared after create")) } pub async fn update_skill( @@ -99,18 +120,18 @@ pub async fn update_skill( }; let mut am: skills::ActiveModel = existing.into(); - if let Some(skill_name) = input.skill_name { - let trimmed = skill_name.trim().to_string(); - anyhow::ensure!(!trimmed.is_empty(), "skill name is empty"); - am.skill_name = Set(trimmed); + if let Some(repository_name) = input.repository_name { + am.repository_name = Set(normalize_repository_name(&repository_name)?); } if let Some(description) = input.description { - let trimmed = description.trim().to_string(); - anyhow::ensure!(!trimmed.is_empty(), "skill description is empty"); - am.description = Set(trimmed); + am.description = Set(normalize_description(&description)); } - if let Some(resource_id) = input.resource_id { - am.resource_id = Set(resource_id.map(|id| id.uuid())); + if let Some(git_url) = input.git_url { + am.git_url = Set(normalize_git_url(&git_url)?); + am.sync_status = Set(skills::SkillSyncStatus::Pending); + am.commit_id = Set(None); + am.last_synced_at = Set(None); + am.sync_error = Set(None); } let now: DateTime = Utc::now(); @@ -127,3 +148,140 @@ pub async fn delete_skill(conn: &DatabaseConnection, skill_id: SkillId) -> anyho .context("delete skill")?; Ok(result.rows_affected > 0) } + +pub async fn sync_skill_repository( + conn: &DatabaseConnection, + skill_id: SkillId, +) -> anyhow::Result> { + let Some(existing) = get_skill_by_id(conn, skill_id).await? else { + return Ok(None); + }; + + let now: DateTime = Utc::now(); + let mut syncing: skills::ActiveModel = existing.clone().into(); + syncing.sync_status = Set(skills::SkillSyncStatus::Syncing); + syncing.sync_error = Set(None); + syncing.updated_at = Set(now.into()); + let syncing = syncing.update(conn).await.context("mark skill syncing")?; + + let sync_result = run_skill_repository_sync(SkillId::new(syncing.id), syncing.git_url.clone()).await; + let finished_at: DateTime = Utc::now(); + let mut updated: skills::ActiveModel = syncing.into(); + updated.updated_at = Set(finished_at.into()); + + match sync_result { + Ok(commit_id) => { + updated.sync_status = Set(skills::SkillSyncStatus::Ready); + updated.commit_id = Set(Some(commit_id)); + updated.last_synced_at = Set(Some(finished_at.into())); + updated.sync_error = Set(None); + } + Err(error) => { + updated.sync_status = Set(skills::SkillSyncStatus::Failed); + updated.sync_error = Set(Some(error.to_string())); + } + } + + let row = updated.update(conn).await.context("persist skill sync result")?; + Ok(Some(row)) +} + +fn normalize_repository_name(repository_name: &str) -> anyhow::Result { + let repository_name = repository_name.trim().to_string(); + anyhow::ensure!(!repository_name.is_empty(), "repository name is empty"); + Ok(repository_name) +} + +fn normalize_description(description: &str) -> String { + description.trim().to_string() +} + +fn normalize_git_url(git_url: &str) -> anyhow::Result> { + let git_url = git_url.trim().to_string(); + if git_url.is_empty() { + return Ok(None); + } + anyhow::ensure!( + git_url.starts_with("http://") || git_url.starts_with("https://") || git_url.starts_with("git@"), + "git url must start with http://, https://, or git@" + ); + Ok(Some(git_url)) +} + +fn skill_repository_checkout_dir(kagent_home: &Path, skill_id: SkillId) -> PathBuf { + kagent_home.join("skills").join(skill_id.uuid().to_string()) +} + +async fn run_skill_repository_sync(skill_id: SkillId, git_url: Option) -> anyhow::Result { + let git_url = git_url + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| anyhow::anyhow!("git url is empty"))?; + let checkout_dir = skill_repository_checkout_dir(&kagent_config::default_kagent_home(), skill_id); + + tokio::task::spawn_blocking(move || sync_skill_repository_blocking(&git_url, &checkout_dir)) + .await + .context("join skill repository sync task")? +} + +fn sync_skill_repository_blocking(git_url: &str, checkout_dir: &Path) -> anyhow::Result { + let git_service = GitService::new(); + if checkout_dir.exists() { + match git_service.open_repo(checkout_dir) { + Ok(_) => { + return git_service + .sync_repository_to_default_branch_head(checkout_dir) + .map(|head| head.oid) + .map_err(anyhow::Error::from); + } + Err(_) => remove_managed_checkout_dir(checkout_dir)?, + } + } + + GitService::clone_repository(git_url, checkout_dir, None).context("clone skill repository")?; + git_service + .get_head_info(checkout_dir) + .map(|head| head.oid) + .map_err(anyhow::Error::from) +} + +fn remove_managed_checkout_dir(checkout_dir: &Path) -> anyhow::Result<()> { + if checkout_dir.is_dir() { + std::fs::remove_dir_all(checkout_dir) + .with_context(|| format!("remove invalid checkout dir {}", checkout_dir.display()))?; + } else if checkout_dir.exists() { + std::fs::remove_file(checkout_dir) + .with_context(|| format!("remove invalid checkout file {}", checkout_dir.display()))?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use uuid::Uuid; + + #[test] + fn skill_repository_checkout_dir_uses_kagent_home_and_skill_uuid() { + let skill_id = + SkillId::new(Uuid::parse_str("8ec8573a-b0c7-44df-a62b-0b4d3254c652").unwrap()); + let root = std::path::Path::new("/tmp/example-home"); + + let path = skill_repository_checkout_dir(root, skill_id); + + assert_eq!( + path, + std::path::PathBuf::from("/tmp/example-home/skills/8ec8573a-b0c7-44df-a62b-0b4d3254c652") + ); + } + + #[test] + fn update_input_reports_git_url_change_when_new_url_differs() { + let input = UpdateSkillInput { + git_url: Some(" https://example.com/org/repo.git ".to_string()), + ..Default::default() + }; + + assert!(input.git_url_changed("https://example.com/old/repo.git")); + assert!(!input.git_url_changed("https://example.com/org/repo.git")); + } +} diff --git a/kagent-db-migration/CHANGELOG.md b/kagent-db-migration/CHANGELOG.md index b647e8a..e1e4acd 100644 --- a/kagent-db-migration/CHANGELOG.md +++ b/kagent-db-migration/CHANGELOG.md @@ -1,5 +1,12 @@ # CHANGELOG +## 2026-04-16 +- 新增迁移 `m20260416_000001_rework_skills_as_git_repositories`: + - 将 `skills.skill_name` 重命名为 `repository_name`。 + - 删除 `resource_id` 字段及其索引。 + - 新增 `git_url`、`sync_status`、`commit_id`、`last_synced_at`、`sync_error` 字段。 + - 新增 `sync_status` CHECK 约束,以及 `(project_id, repository_name)` 唯一索引。 + ## 2026-02-11 - 新增迁移 `m20260211_000001_add_kanban_cards_new_branch_name`: - 为 `kanban_cards` 表新增可空字段 `new_branch_name`。 diff --git a/kagent-db-migration/src/lib.rs b/kagent-db-migration/src/lib.rs index 348c08c..01fb7ba 100644 --- a/kagent-db-migration/src/lib.rs +++ b/kagent-db-migration/src/lib.rs @@ -54,6 +54,7 @@ mod m20260210_000006_add_kanban_cards_project_seq; mod m20260210_000007_add_kanban_cards_duration_days; mod m20260211_000001_add_kanban_cards_new_branch_name; mod m20260211_000002_add_kanban_cards_code_source_id; +mod m20260416_000001_rework_skills_as_git_repositories; pub struct Migrator; @@ -113,6 +114,7 @@ impl MigratorTrait for Migrator { Box::new(m20260210_000007_add_kanban_cards_duration_days::Migration), Box::new(m20260211_000001_add_kanban_cards_new_branch_name::Migration), Box::new(m20260211_000002_add_kanban_cards_code_source_id::Migration), + Box::new(m20260416_000001_rework_skills_as_git_repositories::Migration), ] } } diff --git a/kagent-db-migration/src/m20260416_000001_rework_skills_as_git_repositories.rs b/kagent-db-migration/src/m20260416_000001_rework_skills_as_git_repositories.rs new file mode 100644 index 0000000..2c026b5 --- /dev/null +++ b/kagent-db-migration/src/m20260416_000001_rework_skills_as_git_repositories.rs @@ -0,0 +1,141 @@ +use sea_orm_migration::prelude::*; + +/// 将 skills 从资源绑定模型调整为 Git 仓库管理模型。 +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .get_connection() + .execute_unprepared( + r#" +DROP INDEX IF EXISTS idx_skills_project_id_name_unique; +DROP INDEX IF EXISTS idx_skills_resource_id; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name = 'skills' AND column_name = 'skill_name' + ) AND NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name = 'skills' AND column_name = 'repository_name' + ) THEN + ALTER TABLE skills RENAME COLUMN skill_name TO repository_name; + END IF; +END $$; + +ALTER TABLE skills + DROP COLUMN IF EXISTS resource_id, + ADD COLUMN IF NOT EXISTS git_url text, + ADD COLUMN IF NOT EXISTS sync_status text NOT NULL DEFAULT 'pending', + ADD COLUMN IF NOT EXISTS commit_id text, + ADD COLUMN IF NOT EXISTS last_synced_at timestamptz, + ADD COLUMN IF NOT EXISTS sync_error text; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'chk_skills_sync_status' + ) THEN + ALTER TABLE skills + ADD CONSTRAINT chk_skills_sync_status + CHECK (sync_status IN ('pending', 'syncing', 'ready', 'failed')); + END IF; +END $$; +"#, + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_skills_project_id_repository_name_unique") + .table(Skills::Table) + .col(Skills::ProjectId) + .col(Skills::RepositoryName) + .unique() + .if_not_exists() + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .get_connection() + .execute_unprepared( + r#" +DROP INDEX IF EXISTS idx_skills_project_id_repository_name_unique; + +ALTER TABLE skills + DROP CONSTRAINT IF EXISTS chk_skills_sync_status, + DROP COLUMN IF EXISTS sync_error, + DROP COLUMN IF EXISTS last_synced_at, + DROP COLUMN IF EXISTS commit_id, + DROP COLUMN IF EXISTS sync_status, + DROP COLUMN IF EXISTS git_url, + ADD COLUMN IF NOT EXISTS resource_id uuid; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name = 'skills' AND column_name = 'repository_name' + ) AND NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name = 'skills' AND column_name = 'skill_name' + ) THEN + ALTER TABLE skills RENAME COLUMN repository_name TO skill_name; + END IF; +END $$; +"#, + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_skills_project_id_name_unique") + .table(Skills::Table) + .col(Skills::ProjectId) + .col(Skills::SkillName) + .unique() + .if_not_exists() + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_skills_resource_id") + .table(Skills::Table) + .col(Skills::ResourceId) + .if_not_exists() + .to_owned(), + ) + .await?; + + Ok(()) + } +} + +#[derive(DeriveIden)] +enum Skills { + Table, + ProjectId, + SkillName, + RepositoryName, + ResourceId, +} diff --git a/kagent-db/CHANGELOG.md b/kagent-db/CHANGELOG.md index 5bf9773..251b5b8 100644 --- a/kagent-db/CHANGELOG.md +++ b/kagent-db/CHANGELOG.md @@ -1,5 +1,12 @@ # CHANGELOG +## 2026-04-16 +- `skills` 实体从资源绑定模型调整为 Git 仓库模型: + - `skill_name` 更名为 `repository_name`。 + - 移除 `resource_id` 字段。 + - 新增 `git_url`、`sync_status`、`commit_id`、`last_synced_at`、`sync_error` 字段。 + - 新增 `SkillSyncStatus` 枚举,支持 `pending/syncing/ready/failed`。 + ## 2026-02-11 - `kanban_cards` 实体新增 `new_branch_name` 字段(可空),用于持久化卡片建议分支名。 - `kanban_cards` 实体新增 `code_source_id` 字段(可空),用于持久化卡片关联代码源。 diff --git a/kagent-db/src/entities/skills.rs b/kagent-db/src/entities/skills.rs index 8f9b1d0..188271c 100644 --- a/kagent-db/src/entities/skills.rs +++ b/kagent-db/src/entities/skills.rs @@ -1,15 +1,41 @@ use sea_orm::entity::prelude::*; -/// skills 表:项目级技能包。 +#[derive( + Clone, Debug, PartialEq, Eq, EnumIter, DeriveActiveEnum, serde::Serialize, serde::Deserialize, +)] +#[sea_orm(rs_type = "String", db_type = "Text")] +#[serde(rename_all = "snake_case")] +pub enum SkillSyncStatus { + #[sea_orm(string_value = "pending")] + Pending, + #[sea_orm(string_value = "syncing")] + Syncing, + #[sea_orm(string_value = "ready")] + Ready, + #[sea_orm(string_value = "failed")] + Failed, +} + +impl Default for SkillSyncStatus { + fn default() -> Self { + Self::Pending + } +} + +/// skills 表:项目级技能仓库。 #[derive(Clone, Debug, PartialEq, DeriveEntityModel)] #[sea_orm(table_name = "skills")] pub struct Model { #[sea_orm(primary_key)] pub id: Uuid, pub project_id: Uuid, - pub skill_name: String, + pub repository_name: String, pub description: String, - pub resource_id: Option, + pub git_url: Option, + pub sync_status: SkillSyncStatus, + pub commit_id: Option, + pub last_synced_at: Option, + pub sync_error: Option, pub created_at: DateTimeWithTimeZone, pub updated_at: DateTimeWithTimeZone, } diff --git a/kagent-git/Cargo.toml b/kagent-git/Cargo.toml index d95ce7e..797f418 100644 --- a/kagent-git/Cargo.toml +++ b/kagent-git/Cargo.toml @@ -5,18 +5,18 @@ version.workspace = true license.workspace = true [dependencies] -chrono = { workspace = true } +chrono = { workspace = true, features = ["serde"] } dirs = { workspace = true } git2 = { workspace = true } -serde = { workspace = true } +serde = { workspace = true, features = ["derive"] } tempfile = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } -ts-rs = { workspace = true } +ts-rs = { workspace = true, features = ["uuid-impl", "chrono-impl"] } shellexpand = "3.1.1" similar = { workspace = true } -uuid = { workspace = true } -tokio = { workspace = true } +uuid = { workspace = true, features = ["serde"] } +tokio = { workspace = true, features = ["process", "rt", "rt-multi-thread", "time"] } shlex = { workspace = true } which = { workspace = true } diff --git a/kagent-git/src/lib.rs b/kagent-git/src/lib.rs index 1c16def..9c2b774 100644 --- a/kagent-git/src/lib.rs +++ b/kagent-git/src/lib.rs @@ -2961,6 +2961,42 @@ impl GitService { Ok(repo) } + /// 将当前仓库同步到默认分支的远端最新 HEAD。 + /// + /// 该方法假定调用方管理该工作副本,允许用远端最新提交覆盖本地内容。 + /// 对于 skill 仓库缓存目录,这种“远端为准”的同步语义更合适。 + pub fn sync_repository_to_default_branch_head( + &self, + repo_path: &Path, + ) -> Result { + let repo = self.open_repo(repo_path)?; + let current_head = self.get_head_info(repo_path)?; + let remote = self.resolve_remote_for_branch(repo_path, ¤t_head.branch)?; + self.fetch_all_from_remote(&repo, &repo.find_remote(&remote.name)?)?; + + let remote_ref_name = format!("refs/remotes/{}/{}", remote.name, current_head.branch); + let remote_oid = repo + .find_reference(&remote_ref_name)? + .target() + .ok_or_else(|| { + GitServiceError::InvalidRepository(format!( + "Remote branch '{remote_ref_name}' has no target commit" + )) + })?; + + let local_ref_name = format!("refs/heads/{}", current_head.branch); + repo.reference( + &local_ref_name, + remote_oid, + true, + "sync local branch to remote head", + )?; + repo.set_head(&local_ref_name)?; + repo.checkout_head(Some(git2::build::CheckoutBuilder::new().force()))?; + + self.get_head_info(repo_path) + } + // ============================== // 文件统计方法 // ============================== @@ -3071,3 +3107,69 @@ impl GitService { Ok(stats) } } + +#[cfg(test)] +mod tests { + use super::*; + use git2::{Repository, Signature}; + use tempfile::TempDir; + + fn create_commit(repo: &Repository, relative_path: &str, contents: &str, message: &str) -> String { + let workdir = repo.workdir().expect("repo workdir"); + let file_path = workdir.join(relative_path); + if let Some(parent) = file_path.parent() { + std::fs::create_dir_all(parent).expect("create parent dirs"); + } + std::fs::write(&file_path, contents).expect("write file"); + + let mut index = repo.index().expect("open index"); + index + .add_path(Path::new(relative_path)) + .expect("add file to index"); + index.write().expect("write index"); + + let tree_id = index.write_tree().expect("write tree"); + let tree = repo.find_tree(tree_id).expect("find tree"); + let sig = Signature::now("Kagent Test", "test@kagent.dev").expect("signature"); + + let parent_commit = repo + .head() + .ok() + .and_then(|head| head.target()) + .map(|oid| repo.find_commit(oid).expect("find parent commit")); + + let parents = parent_commit.iter().collect::>(); + repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &parents) + .expect("create commit") + .to_string() + } + + #[test] + fn sync_repository_to_default_branch_head_fast_forwards_clone() { + let remote_dir = TempDir::new().expect("remote tempdir"); + let remote_repo = Repository::init(remote_dir.path()).expect("init remote repo"); + create_commit(&remote_repo, "README.md", "v1\n", "initial commit"); + + let clone_dir = TempDir::new().expect("clone tempdir"); + let clone_path = clone_dir.path().join("skill-repo"); + GitService::clone_repository( + remote_dir.path().to_str().expect("remote path str"), + &clone_path, + None, + ) + .expect("clone repo"); + + let updated_commit = create_commit(&remote_repo, "README.md", "v2\n", "update readme"); + let service = GitService::new(); + + let sync_head = service + .sync_repository_to_default_branch_head(&clone_path) + .expect("sync repository"); + + assert_eq!(sync_head.oid, updated_commit); + assert_eq!( + std::fs::read_to_string(clone_path.join("README.md")).expect("read cloned file"), + "v2\n" + ); + } +} diff --git a/kagent-ui/CHANGELOG.md b/kagent-ui/CHANGELOG.md index 933dc29..6af450d 100644 --- a/kagent-ui/CHANGELOG.md +++ b/kagent-ui/CHANGELOG.md @@ -2,6 +2,10 @@ ## 2026-04-16 +- 技能管理前端切换到技能仓库模型: + - `settings.ts` 的技能 DTO、请求体与映射逻辑改为 `repositoryName/gitUrl/syncStatus/commitId/lastSyncedAt/syncError`,并新增手动同步接口封装。 + - 项目设置中的技能管理页改为维护 Git 仓库地址,展示同步状态、最近 commit、最后同步时间,并支持手动触发同步。 + - 创建工作区弹窗中的技能选择列表改为展示仓库名称,避免继续读取旧的 `skillName` 字段。 - 修复聊天界面 assistant 回复重复显示: - 为 assistant UI 消息补充 `turnId` 元信息,并在前端状态层归并同一 turn 中相邻的 assistant 气泡,避免 `item/started`、`item/agentMessage/delta`、`item/completed` 之间的消息 ID 不一致时留下重复回复。 - `thread/resume` 历史恢复沿用同样的相邻 assistant 归并规则,避免切换或重开线程后再次出现重复欢迎语。 diff --git a/kagent-ui/app/assistant.tsx b/kagent-ui/app/assistant.tsx index fa95cad..8fc48e2 100644 --- a/kagent-ui/app/assistant.tsx +++ b/kagent-ui/app/assistant.tsx @@ -154,6 +154,8 @@ export const Assistant = ({ // 当工作区切换时自动打开会话面板 useEffect(() => { if (workspace.activeWorkspaceId && activeTopTab === "workspace") { + // 当前标签与工作区选择来自外部路由/状态,需要在同步后统一控制面板显隐。 + // eslint-disable-next-line react-hooks/set-state-in-effect setSessionPanelOpen(true); } else { setSessionPanelOpen(false); @@ -171,8 +173,6 @@ export const Assistant = ({ useEffect(() => { if (!aboutOpen) return; let mounted = true; - setBackendVersionLoading(true); - setBackendVersionError(null); fetchJson<{ version: string; git_version: string }>("/api/version") .then((data) => { if (!mounted) return; @@ -239,9 +239,14 @@ export const Assistant = ({ return (
setAboutOpen(true)} + onLogoClick={() => { + setBackendVersionLoading(true); + setBackendVersionError(null); + setAboutOpen(true); + }} overviewActive={activeTopTab === "overview"} leaderboardActive={activeTopTab === "efficiency"} resourceActive={activeTopTab === "resource"} diff --git a/kagent-ui/app/login/page.tsx b/kagent-ui/app/login/page.tsx index 93adf2b..663bd59 100644 --- a/kagent-ui/app/login/page.tsx +++ b/kagent-ui/app/login/page.tsx @@ -1,6 +1,7 @@ 'use client'; import { useState } from 'react'; +import { isAxiosError } from 'axios'; import { useRouter } from 'next/navigation'; import { useAuthStore } from '@/app/stores/auth-store'; import { api } from '@/lib/api'; @@ -26,8 +27,13 @@ export default function LoginPage() { login(res.data.token, res.data.user); toast.success('Logged in successfully'); router.push('/'); - } catch (error: any) { - toast.error(error.response?.data || 'Failed to login'); + } catch (error: unknown) { + const message = isAxiosError(error) + ? error.response?.data || error.message + : error instanceof Error + ? error.message + : 'Failed to login'; + toast.error(message); } finally { setLoading(false); } diff --git a/kagent-ui/app/register/page.tsx b/kagent-ui/app/register/page.tsx index bd8ea1c..747e0d5 100644 --- a/kagent-ui/app/register/page.tsx +++ b/kagent-ui/app/register/page.tsx @@ -1,6 +1,7 @@ 'use client'; import { useState } from 'react'; +import { isAxiosError } from 'axios'; import { useRouter } from 'next/navigation'; import { useAuthStore } from '@/app/stores/auth-store'; import { api } from '@/lib/api'; @@ -27,8 +28,13 @@ export default function RegisterPage() { login(res.data.token, res.data.user); toast.success('Registered successfully'); router.push('/'); - } catch (error: any) { - toast.error(error.response?.data || 'Failed to register'); + } catch (error: unknown) { + const message = isAxiosError(error) + ? error.response?.data || error.message + : error instanceof Error + ? error.message + : 'Failed to register'; + toast.error(message); } finally { setLoading(false); } diff --git a/kagent-ui/app/stores/auth-store.ts b/kagent-ui/app/stores/auth-store.ts index 168ee27..bbf3d34 100644 --- a/kagent-ui/app/stores/auth-store.ts +++ b/kagent-ui/app/stores/auth-store.ts @@ -30,10 +30,10 @@ export const useAuthStore = create()( const token = get().token; if (!token) return true; try { - const decoded: any = jwtDecode(token); + const decoded = jwtDecode<{ exp?: number }>(token); const currentTime = Date.now() / 1000; - return decoded.exp < currentTime; - } catch (error) { + return typeof decoded.exp !== 'number' || decoded.exp < currentTime; + } catch { return true; } }, diff --git a/kagent-ui/bun.lockb b/kagent-ui/bun.lockb index 4fa654c..6f3b433 100755 Binary files a/kagent-ui/bun.lockb and b/kagent-ui/bun.lockb differ diff --git a/kagent-ui/components/assistant-ui/attachment.tsx b/kagent-ui/components/assistant-ui/attachment.tsx index 4f513e4..3ac1248 100644 --- a/kagent-ui/components/assistant-ui/attachment.tsx +++ b/kagent-ui/components/assistant-ui/attachment.tsx @@ -31,6 +31,8 @@ const useFileSrc = (file: File | undefined) => { useEffect(() => { if (!file) { + // 文件被移除时需要立刻清掉本地 object URL 预览状态。 + // eslint-disable-next-line react-hooks/set-state-in-effect setSrc(undefined); return; } diff --git a/kagent-ui/components/assistant-ui/create-thread-dialog.tsx b/kagent-ui/components/assistant-ui/create-thread-dialog.tsx index cf04a4a..b677495 100644 --- a/kagent-ui/components/assistant-ui/create-thread-dialog.tsx +++ b/kagent-ui/components/assistant-ui/create-thread-dialog.tsx @@ -726,7 +726,7 @@ export function CreateThreadDialog({ }} disabled={submitting} /> - {item.skillName} + {item.repositoryName} ))}
diff --git a/kagent-ui/components/assistant-ui/project-overview.tsx b/kagent-ui/components/assistant-ui/project-overview.tsx index 58b270b..1f9de46 100644 --- a/kagent-ui/components/assistant-ui/project-overview.tsx +++ b/kagent-ui/components/assistant-ui/project-overview.tsx @@ -213,10 +213,6 @@ function Carousel({ images }: { images: OneDayImage[] }) { const [activeIndex, setActiveIndex] = useState(0); const total = images.length; - useEffect(() => { - setActiveIndex(0); - }, [images]); - if (total === 0) { return
暂无图片
; } @@ -281,7 +277,10 @@ function OneDayCard({ data }: { data: OneDayImage[] }) {
{dateText} {weekdayText}
- + item.id).join("|")} + images={data.slice(0, 5)} + /> ); } diff --git a/kagent-ui/components/assistant-ui/resource-tree-dialog.tsx b/kagent-ui/components/assistant-ui/resource-tree-dialog.tsx index aeaf2b7..0dec309 100644 --- a/kagent-ui/components/assistant-ui/resource-tree-dialog.tsx +++ b/kagent-ui/components/assistant-ui/resource-tree-dialog.tsx @@ -68,7 +68,7 @@ export const ResourceTreeDialog = ({ ) : (
- 选择 S3 凭证后点击"加载目录",可在树形结构中选择文件或目录以填充资源引用。 + 选择 S3 凭证后点击"加载目录",可在树形结构中选择文件或目录以填充资源引用。
)}
diff --git a/kagent-ui/components/assistant-ui/thread.tsx b/kagent-ui/components/assistant-ui/thread.tsx index 9304297..6043962 100644 --- a/kagent-ui/components/assistant-ui/thread.tsx +++ b/kagent-ui/components/assistant-ui/thread.tsx @@ -95,7 +95,9 @@ export const Thread: FC<{ selectedResourceCount = 0, }) => { const viewportRef = useRef(null); - const [selectedModel, setSelectedModel] = useState(""); + const [selectedModelOverride, setSelectedModelOverride] = useState( + null, + ); const normalizedModelOptions = useMemo(() => { const list = (modelOptions ?? []) @@ -104,20 +106,21 @@ export const Thread: FC<{ return Array.from(new Set(list)); }, [modelOptions]); - useEffect(() => { - setSelectedModel((current) => { - if (normalizedModelOptions.length === 0) { - return ""; - } - if (current && normalizedModelOptions.includes(current)) { - return current; - } - if (defaultModel && normalizedModelOptions.includes(defaultModel)) { - return defaultModel; - } - return normalizedModelOptions[0]; - }); - }, [defaultModel, normalizedModelOptions]); + const selectedModel = useMemo(() => { + if (normalizedModelOptions.length === 0) { + return ""; + } + if ( + selectedModelOverride && + normalizedModelOptions.includes(selectedModelOverride) + ) { + return selectedModelOverride; + } + if (defaultModel && normalizedModelOptions.includes(defaultModel)) { + return defaultModel; + } + return normalizedModelOptions[0]; + }, [defaultModel, normalizedModelOptions, selectedModelOverride]); const waitingAssistantReply = useMemo(() => { if (!running || messages.length === 0) return false; @@ -158,7 +161,7 @@ export const Thread: FC<{ disabled={disabled} modelOptions={normalizedModelOptions} selectedModel={selectedModel} - onSelectedModelChange={setSelectedModel} + onSelectedModelChange={(value) => setSelectedModelOverride(value)} modelProviderName={modelProviderName} modelProviderType={modelProviderType} onAssociateResource={onAssociateResource} @@ -184,7 +187,7 @@ export const Thread: FC<{ disabled={disabled} modelOptions={normalizedModelOptions} selectedModel={selectedModel} - onSelectedModelChange={setSelectedModel} + onSelectedModelChange={(value) => setSelectedModelOverride(value)} modelProviderName={modelProviderName} modelProviderType={modelProviderType} onAssociateResource={onAssociateResource} diff --git a/kagent-ui/components/auth/auth-guard.tsx b/kagent-ui/components/auth/auth-guard.tsx index df87a31..fdfb9b8 100644 --- a/kagent-ui/components/auth/auth-guard.tsx +++ b/kagent-ui/components/auth/auth-guard.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useSyncExternalStore } from 'react'; import { usePathname, useRouter } from 'next/navigation'; import { motion, AnimatePresence } from 'framer-motion'; import { Loader2, LogIn, Home } from 'lucide-react'; @@ -108,18 +108,17 @@ function TransitionLoader({ export function AuthGuard({ children }: AuthGuardProps) { const router = useRouter(); const pathname = usePathname(); - const [isHydrated, setIsHydrated] = useState(false); + const isHydrated = useSyncExternalStore( + () => () => undefined, + () => true, + () => false, + ); const token = useAuthStore((state) => state.token); const isAuthenticated = useAuthStore((state) => state.isAuthenticated); const isTokenExpired = useAuthStore((state) => state.isTokenExpired); const logout = useAuthStore((state) => state.logout); - // 等待 zustand persist 完成 hydration - useEffect(() => { - setIsHydrated(true); - }, []); - useEffect(() => { // 等待 hydration 完成 if (!isHydrated) return; diff --git a/kagent-ui/components/form/json-schema-form.tsx b/kagent-ui/components/form/json-schema-form.tsx index bc84abb..84a12bb 100644 --- a/kagent-ui/components/form/json-schema-form.tsx +++ b/kagent-ui/components/form/json-schema-form.tsx @@ -80,6 +80,8 @@ function KvField({ useEffect(() => { if (serialized === lastSerializedRef.current) return; lastSerializedRef.current = serialized; + // 该字段支持外部重置表单值,这里需要同步回本地编辑行状态。 + // eslint-disable-next-line react-hooks/set-state-in-effect setRows(normalizeKvRows(value)); }, [serialized, value]); diff --git a/kagent-ui/components/layout/app-top-bar.tsx b/kagent-ui/components/layout/app-top-bar.tsx index 63ad723..7b4d847 100644 --- a/kagent-ui/components/layout/app-top-bar.tsx +++ b/kagent-ui/components/layout/app-top-bar.tsx @@ -112,7 +112,6 @@ export function AppTopBar({ "flex min-w-0 items-center gap-2 rounded-md px-2 py-1 text-left transition-colors hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"; const [progressActive, setProgressActive] = useState(false); const [notifications, setNotifications] = useState([]); - const [notificationOpen, setNotificationOpen] = useState(false); const readIdsRef = useRef>(new Set()); const minShowMs = 1000; const showSinceRef = useRef(null); @@ -165,16 +164,9 @@ export function AppTopBar({ useEffect(() => { if (!projectId || typeof window === "undefined") { readIdsRef.current = new Set(); - setNotifications([]); return; } readIdsRef.current = parseReadIds(window.localStorage.getItem(readStorageKey(projectId))); - setNotifications((prev) => - prev.map((item) => ({ - ...item, - unread: !readIdsRef.current.has(item.id), - })), - ); }, [projectId]); useEffect(() => { @@ -234,20 +226,6 @@ export function AppTopBar({ }; }, [projectId, authToken]); - useEffect(() => { - if (!notificationOpen || !projectId || typeof window === "undefined") return; - setNotifications((prev) => { - if (prev.every((item) => !item.unread)) return prev; - const ids = prev.map((item) => item.id); - readIdsRef.current = new Set([...readIdsRef.current, ...ids]); - window.localStorage.setItem( - readStorageKey(projectId), - JSON.stringify(Array.from(readIdsRef.current)), - ); - return prev.map((item) => ({ ...item, unread: false })); - }); - }, [notificationOpen, projectId]); - const unreadCount = notifications.filter((item) => item.unread).length; return ( @@ -319,7 +297,25 @@ export function AppTopBar({ 效率 - + { + if (!open || !projectId || typeof window === "undefined") { + return; + } + setNotifications((prev) => { + if (prev.every((item) => !item.unread)) { + return prev; + } + const ids = prev.map((item) => item.id); + readIdsRef.current = new Set([...readIdsRef.current, ...ids]); + window.localStorage.setItem( + readStorageKey(projectId), + JSON.stringify(Array.from(readIdsRef.current)), + ); + return prev.map((item) => ({ ...item, unread: false })); + }); + }} + > ); @@ -239,6 +247,33 @@ export function SkillMgmt({ )}
+ {selectedSkill ? ( +
+
+
+
{selectedSkill.repositoryName}
+
{selectedSkill.gitUrl ?? "-"}
+
+
+ {getSkillSyncStatusMeta(selectedSkill.syncStatus).label} +
+
+
+
最新 commit:{selectedSkill.commitId ?? "暂无"}
+
最后同步:{formatTimestamp(selectedSkill.lastSyncedAt)}
+
更新时间:{formatTimestamp(selectedSkill.updatedAt)}
+ {selectedSkill.syncError ? ( +
错误信息:{selectedSkill.syncError}
+ ) : null} +
+
+ ) : null} +
+
(!open ? setDialogOpen(false) : null)}> - {editingId ? "修改技能" : "新增技能"} + {editingId ? "修改技能仓库" : "新增技能仓库"} - 维护项目技能列表,支持关联项目资源。 + 维护项目技能仓库列表,创建后会立即触发一次同步。
-
技能名称
+
仓库名称
- setSkillName(e.target.value)} /> + setRepositoryName(e.target.value)} + placeholder="例如:team-skills" + /> @@ -289,34 +332,21 @@ export function SkillMgmt({
-
技能描述
-