fix(skill): 异步化删除目录防阻塞并限制批量删除上限以避免超时
This commit is contained in:
parent
43a23e2e88
commit
caab251f02
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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')
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user