fix(skill): 异步化删除目录防阻塞并限制批量删除上限以避免超时

This commit is contained in:
supreme0597 2026-05-25 23:35:00 +08:00
parent 43a23e2e88
commit caab251f02
9 changed files with 67 additions and 46 deletions

View File

@ -25,9 +25,9 @@ class RemoteSkillsBatchPreparation:
temp_home: str | None temp_home: str | None
results: list[dict] results: list[dict]
def cleanup(self) -> None: async def cleanup(self) -> None:
if self.temp_home: if self.temp_home:
shutil.rmtree(self.temp_home, ignore_errors=True) await asyncio.to_thread(shutil.rmtree, self.temp_home, ignore_errors=True)
def _normalize_source(source: str) -> str: def _normalize_source(source: str) -> str:
@ -152,7 +152,7 @@ async def list_remote_skills(source: str) -> list[dict[str, str]]:
cwd=workdir, cwd=workdir,
) )
finally: finally:
shutil.rmtree(temp_home, ignore_errors=True) await asyncio.to_thread(shutil.rmtree, temp_home, ignore_errors=True)
skills = _parse_available_skills(output) skills = _parse_available_skills(output)
if not skills: if not skills:
@ -219,7 +219,7 @@ async def install_remote_skill(
created_by=created_by, created_by=created_by,
) )
finally: finally:
shutil.rmtree(temp_home, ignore_errors=True) await asyncio.to_thread(shutil.rmtree, temp_home, ignore_errors=True)
async def install_remote_skills_batch( async def install_remote_skills_batch(
@ -262,7 +262,7 @@ async def install_remote_skills_batch(
return results return results
finally: finally:
preparation.cleanup() await preparation.cleanup()
async def prepare_remote_skills_batch( async def prepare_remote_skills_batch(
@ -327,7 +327,7 @@ async def prepare_remote_skills_batch(
return RemoteSkillsBatchPreparation(temp_home=temp_home, results=results) return RemoteSkillsBatchPreparation(temp_home=temp_home, results=results)
except Exception: except Exception:
shutil.rmtree(temp_home, ignore_errors=True) await asyncio.to_thread(shutil.rmtree, temp_home, ignore_errors=True)
raise raise
@ -382,6 +382,6 @@ async def search_remote_skills(query: str) -> list[dict[str, str]]:
cwd=workdir, cwd=workdir,
) )
finally: finally:
shutil.rmtree(temp_home, ignore_errors=True) await asyncio.to_thread(shutil.rmtree, temp_home, ignore_errors=True)
return _parse_search_skills(output) return _parse_search_skills(output)

View File

@ -459,12 +459,12 @@ async def _import_skill_dir_impl(
temp_target = skills_root / f".{final_slug}.tmp-{uuid.uuid4().hex[:8]}" temp_target = skills_root / f".{final_slug}.tmp-{uuid.uuid4().hex[:8]}"
if temp_target.exists(): if temp_target.exists():
shutil.rmtree(temp_target) await asyncio.to_thread(shutil.rmtree, temp_target)
shutil.move(str(stage_dir), str(temp_target)) shutil.move(str(stage_dir), str(temp_target))
final_dir = skills_root / final_slug final_dir = skills_root / final_slug
if final_dir.exists(): if final_dir.exists():
shutil.rmtree(temp_target, ignore_errors=True) await asyncio.to_thread(shutil.rmtree, temp_target, ignore_errors=True)
raise ValueError(f"技能目录冲突,请重试: {final_slug}") raise ValueError(f"技能目录冲突,请重试: {final_slug}")
temp_target.rename(final_dir) temp_target.rename(final_dir)
@ -480,7 +480,7 @@ async def _import_skill_dir_impl(
created_by=created_by, created_by=created_by,
) )
except Exception: except Exception:
shutil.rmtree(final_dir, ignore_errors=True) await asyncio.to_thread(shutil.rmtree, final_dir, ignore_errors=True)
raise raise
return item return item
@ -728,7 +728,7 @@ async def delete_skill_node(db: AsyncSession, *, slug: str, relative_path: str)
raise ValueError("不允许删除根目录 SKILL.md") raise ValueError("不允许删除根目录 SKILL.md")
if target.is_dir(): if target.is_dir():
shutil.rmtree(target) await asyncio.to_thread(shutil.rmtree, target)
else: else:
target.unlink() target.unlink()
@ -774,11 +774,13 @@ async def delete_skill(db: AsyncSession, *, slug: str) -> None:
raise raise
if trash_dir and trash_dir.exists(): if trash_dir and trash_dir.exists():
shutil.rmtree(trash_dir, ignore_errors=True) await asyncio.to_thread(shutil.rmtree, trash_dir, ignore_errors=True)
async def delete_skills_batch(db: AsyncSession, *, slugs: list[str]) -> list[dict]: async def delete_skills_batch(db: AsyncSession, *, slugs: list[str]) -> list[dict]:
"""批量删除多个 skills单技能独立的子事务与回滚""" """批量删除多个 skills单技能独立的子事务与回滚"""
if len(slugs) > 50:
raise ValueError("批量删除的技能数量不能超过 50 个")
results = [] results = []
for slug in slugs: for slug in slugs:
try: try:
@ -897,7 +899,7 @@ async def install_builtin_skill(db: AsyncSession, slug: str, *, installed_by: st
created_by=installed_by or BUILTIN_SKILL_OPERATOR, created_by=installed_by or BUILTIN_SKILL_OPERATOR,
) )
except Exception: except Exception:
shutil.rmtree(target_dir, ignore_errors=True) await asyncio.to_thread(shutil.rmtree, target_dir, ignore_errors=True)
raise raise

View File

@ -77,7 +77,7 @@ class RemoteSkillSearchRequest(BaseModel):
class SkillBatchDeleteRequest(BaseModel): class SkillBatchDeleteRequest(BaseModel):
slugs: list[str] = Field(..., description="需要批量删除的 skill slug 列表") slugs: list[str] = Field(..., max_length=50, description="需要批量删除的 skill slug 列表,最多支持 50 个")
def _raise_from_value_error(e: ValueError) -> None: def _raise_from_value_error(e: ValueError) -> None:

View File

@ -1053,6 +1053,13 @@ async def test_delete_skills_batch_ok(tmp_path: Path, monkeypatch: pytest.Monkey
assert not (tmp_path / "skills" / "skill-b").exists() assert not (tmp_path / "skills" / "skill-b").exists()
@pytest.mark.asyncio
async def test_delete_skills_batch_limit_exceeded():
slugs = [f"skill-{i}" for i in range(51)]
with pytest.raises(ValueError, match="批量删除的技能数量不能超过 50 个"):
await svc.delete_skills_batch(None, slugs=slugs)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_delete_skill_concurrent_lock(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): async def test_delete_skill_concurrent_lock(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path)) monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path))

View File

@ -1614,10 +1614,10 @@ const hasVisibleAssistantBody = (message) => {
const { content, reasoningContent } = extractAssistantMessageBody(message) const { content, reasoningContent } = extractAssistantMessageBody(message)
return Boolean( return Boolean(
content || content ||
reasoningContent || reasoningContent ||
message.error_type || message.error_type ||
message.extra_metadata?.error_type || message.extra_metadata?.error_type ||
message.isStoppedByUser message.isStoppedByUser
) )
} }

View File

@ -100,7 +100,10 @@ const copySvgAsPng = async (svgEl, btn) => {
} }
// 3) 退 // 3) 退
if (!width || !height) { width = 800; height = 600 } if (!width || !height) {
width = 800
height = 600
}
const img = await new Promise((resolve, reject) => { const img = await new Promise((resolve, reject) => {
const image = new Image() const image = new Image()
@ -117,11 +120,9 @@ const copySvgAsPng = async (svgEl, btn) => {
// SVG // SVG
ctx.drawImage(img, 0, 0, width, height) ctx.drawImage(img, 0, 0, width, height)
const pngBlob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png')) const pngBlob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'))
if (pngBlob) { if (pngBlob) {
await navigator.clipboard.write([ await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })])
new ClipboardItem({ 'image/png': pngBlob })
])
showCopiedFeedback(btn) showCopiedFeedback(btn)
} }
} catch (err) { } catch (err) {

View File

@ -141,7 +141,11 @@
:disabled="installingRemoteSkill" :disabled="installingRemoteSkill"
> >
<template #suffix> <template #suffix>
<a-dropdown :trigger="['click']" placement="bottomRight" overlay-class-name="history-dropdown-menu"> <a-dropdown
:trigger="['click']"
placement="bottomRight"
overlay-class-name="history-dropdown-menu"
>
<div class="history-trigger-wrapper"> <div class="history-trigger-wrapper">
<a-tooltip title="历史仓库"> <a-tooltip title="历史仓库">
<History <History
@ -159,14 +163,22 @@
<template v-else> <template v-else>
<a-menu-item v-for="item in repoHistory" :key="item"> <a-menu-item v-for="item in repoHistory" :key="item">
<div class="history-item-menu-row"> <div class="history-item-menu-row">
<span class="history-item-text" :title="item">{{ item }}</span> <span class="history-item-text" :title="item">{{
<span class="history-item-del-btn" @click.stop="deleteHistoryItem(item)"> item
}}</span>
<span
class="history-item-del-btn"
@click.stop="deleteHistoryItem(item)"
>
<Trash2 :size="12" /> <Trash2 :size="12" />
</span> </span>
</div> </div>
</a-menu-item> </a-menu-item>
<a-menu-divider /> <a-menu-divider />
<a-menu-item key="clear-all-history" class="clear-history-menu-item"> <a-menu-item
key="clear-all-history"
class="clear-history-menu-item"
>
<div class="clear-history-btn-content"> <div class="clear-history-btn-content">
<Trash2 :size="12" class="clear-icon" /> <Trash2 :size="12" class="clear-icon" />
<span>清空历史记录</span> <span>清空历史记录</span>
@ -372,9 +384,7 @@
@click="startInstallRemoteSkills" @click="startInstallRemoteSkills"
> >
开始安装 (已选 开始安装 (已选
{{ {{ activeTab === 'repo' ? selectedRepoSkills.length : selectedSearchSkills.length }}
activeTab === 'repo' ? selectedRepoSkills.length : selectedSearchSkills.length
}}
) )
</a-button> </a-button>
</div> </div>
@ -1121,12 +1131,13 @@ defineExpose({
margin-right: -4px; margin-right: -4px;
border-radius: 4px; border-radius: 4px;
transition: background-color 0.2s ease; transition: background-color 0.2s ease;
&:hover { &:hover {
background-color: var(--gray-100); background-color: var(--gray-100);
} }
&:focus, &:focus-visible { &:focus,
&:focus-visible {
outline: none; outline: none;
} }
} }
@ -1135,22 +1146,20 @@ defineExpose({
color: var(--gray-400); color: var(--gray-400);
transition: color 0.2s ease; transition: color 0.2s ease;
outline: none; outline: none;
&:hover { &:hover {
color: var(--main-color); color: var(--main-color);
} }
&.has-history { &.has-history {
color: var(--gray-500); color: var(--gray-500);
&:hover { &:hover {
color: var(--main-color); color: var(--main-color);
} }
} }
} }
.repo-hint-text { .repo-hint-text {
font-size: 12px; font-size: 12px;
color: var(--gray-400); color: var(--gray-400);
@ -1488,4 +1497,3 @@ defineExpose({
} }
} }
</style> </style>

View File

@ -35,7 +35,10 @@ const run = () => {
const lines = result.split('\n') const lines = result.split('\n')
assert.equal(lines.length, 1, 'Should be compressed to single line') assert.equal(lines.length, 1, 'Should be compressed to single line')
assert.ok(result.includes('svg-inline-render'), 'Should contain wrapper') assert.ok(result.includes('svg-inline-render'), 'Should contain wrapper')
assert.ok(result.includes('<stop offset="0%"/><stop offset="100%"/>'), 'Blank lines should be removed between tags') assert.ok(
result.includes('<stop offset="0%"/><stop offset="100%"/>'),
'Blank lines should be removed between tags'
)
assert.ok(result.includes('svg-copy-btn'), 'Buttons should be inside single-line output') assert.ok(result.includes('svg-copy-btn'), 'Buttons should be inside single-line output')
console.log('T3 Blank lines compressed: PASS') console.log('T3 Blank lines compressed: PASS')
} }
@ -162,4 +165,4 @@ const run = () => {
console.log('\nAll 16 tests passed!') console.log('\nAll 16 tests passed!')
} }
run() run()

View File

@ -32,10 +32,10 @@ export function renderSvgBlocks(markdown) {
while (i < lines.length) { while (i < lines.length) {
const closeMatch = lines[i].match(/^( {0,3})(`{3,}|~{3,})\s*$/) const closeMatch = lines[i].match(/^( {0,3})(`{3,}|~{3,})\s*$/)
if ( if (
closeMatch closeMatch &&
&& closeMatch[1].length <= indent.length closeMatch[1].length <= indent.length &&
&& closeMatch[2][0] === fenceChar[0] closeMatch[2][0] === fenceChar[0] &&
&& closeMatch[2].length >= fenceChar.length closeMatch[2].length >= fenceChar.length
) { ) {
closed = true closed = true
// 压缩 SVG 为单行,防止 markdown-it HTML 块解析截断 // 压缩 SVG 为单行,防止 markdown-it HTML 块解析截断
@ -70,4 +70,4 @@ export function renderSvgBlocks(markdown) {
} }
return output.join('\n') return output.join('\n')
} }