feat: add summary info

This commit is contained in:
alan.cl
2026-03-13 13:40:02 +08:00
parent ca9905cb07
commit 87bcb9a4ae
7 changed files with 131 additions and 263 deletions

View File

@@ -469,13 +469,6 @@ async def skill_upload(
return Result.failed(code="E5002", msg=f"Upload failed: {str(e)}")
class _GitHubImportRequest(_BaseModel):
"""Request body for importing a skill from a GitHub repository."""
github_url: str
branch: str = "main"
def _parse_github_url(
github_url: str,
) -> "tuple[str, str, str, Optional[str]]":
@@ -658,201 +651,18 @@ def _extract_skill_from_zip(
return skill_name
@router.post("/v1/skills/import_from_github", response_model=Result)
async def skill_import_from_github(
body: _GitHubImportRequest,
user_token: UserRequest = Depends(get_user_from_headers),
):
"""Import a skill directly from a public GitHub repository.
The endpoint downloads the repository as a ZIP archive using the GitHub
archive API, extracts it, optionally navigates to a sub-directory,
validates the presence of a ``SKILL.md`` file, and installs the skill
into ``skills/user/<skill-name>/``.
Args:
body (_GitHubImportRequest): ``github_url`` (required) and optional
``branch`` (default ``"main"``).
Returns:
Result: ``{"file_path": "<relative-path>", "message": "..."}`` on
success, or a ``Result.failed`` with an appropriate error code.
Error codes:
- ``E4003``: Malformed or non-GitHub URL.
- ``E4004``: ``SKILL.md`` not found in the downloaded content.
- ``E5003``: Network / download failure.
- ``E5002``: Unexpected server-side error.
"""
import httpx
# --- parse URL ----------------------------------------------------------
try:
owner, repo, branch_override, subdir = _parse_github_url(body.github_url)
except ValueError as exc:
return Result.failed(code="E4003", msg=str(exc))
branch = branch_override or body.branch
# --- build download URL -------------------------------------------------
zip_url = f"https://github.com/{owner}/{repo}/archive/refs/heads/{branch}.zip"
# --- download -----------------------------------------------------------
skills_dir = Path(DEFAULT_SKILLS_DIR).expanduser().resolve()
user_dir = skills_dir / "user"
user_dir.mkdir(parents=True, exist_ok=True)
upload_dir = Path(resolve_root_path("pilot/tmp") or "pilot/tmp").resolve()
upload_dir.mkdir(parents=True, exist_ok=True)
try:
async with httpx.AsyncClient(
follow_redirects=True,
timeout=httpx.Timeout(120.0),
) as client:
response = await client.get(zip_url)
if response.status_code != 200:
return Result.failed(
code="E5003",
msg=(
f"Failed to download {zip_url!r}: HTTP {response.status_code}"
),
)
content_bytes = response.content
except httpx.RequestError as exc:
logger.exception("Network error while downloading skill from GitHub")
return Result.failed(
code="E5003", msg=f"Network error downloading skill: {str(exc)}"
)
# --- save raw zip to tmp ------------------------------------------------
zip_filename = f"{repo}-{branch}.zip"
tmp_file = upload_dir / zip_filename
tmp_file.write_bytes(content_bytes)
# --- extract & validate -------------------------------------------------
try:
buf = io.BytesIO(content_bytes)
with zipfile.ZipFile(buf, "r") as zf:
# Security: reject path-traversal entries
for name in zf.namelist():
if ".." in name or name.startswith("/"):
return Result.failed(
code="E4002",
msg=f"Unsafe path in archive: {name}",
)
all_names = zf.namelist()
# GitHub archives always have a single top-level dir:
# "<repo>-<branch>/"
top_dirs = {n.split("/")[0] for n in all_names if "/" in n}
archive_root = top_dirs.pop() if len(top_dirs) == 1 else None
# Determine the directory inside the archive that holds SKILL.md
if subdir:
# User pointed at a sub-directory; look for SKILL.md there.
if archive_root:
skill_prefix = f"{archive_root}/{subdir}/"
else:
skill_prefix = f"{subdir}/"
else:
skill_prefix = f"{archive_root}/" if archive_root else ""
# Find the SKILL.md entry
skill_md_entry = next(
(
n
for n in all_names
if n == skill_prefix + "SKILL.md"
or (not skill_prefix and n.endswith("/SKILL.md"))
),
None,
)
if skill_md_entry is None:
return Result.failed(
code="E4004",
msg=(
"No SKILL.md found in the repository"
+ (f" under '{subdir}'" if subdir else "")
+ ". Make sure the skill directory contains a SKILL.md file."
),
)
# Determine skill name from SKILL.md frontmatter (best-effort)
try:
skill_md_bytes = zf.read(skill_md_entry)
skill_md_text = skill_md_bytes.decode("utf-8", errors="replace")
from dbgpt.agent.claude_skill import FileBasedSkill
# Write to a tmp location so FileBasedSkill can parse it
tmp_skill_dir = upload_dir / f"_gh_parse_{uuid.uuid4().hex}"
tmp_skill_dir.mkdir(parents=True, exist_ok=True)
tmp_skill_md = tmp_skill_dir / "SKILL.md"
tmp_skill_md.write_text(skill_md_text, encoding="utf-8")
fskill = FileBasedSkill(str(tmp_skill_md))
skill_name = fskill.metadata.name
shutil.rmtree(tmp_skill_dir, ignore_errors=True)
except Exception:
# Fall back to repo name or subdir name
skill_name = subdir.split("/")[-1] if subdir else repo
# Sanitise skill name to a safe directory name
safe_name = re.sub(r"[^A-Za-z0-9_\-]", "_", skill_name)
dest = user_dir / safe_name
if dest.exists():
shutil.rmtree(dest)
dest.mkdir(parents=True, exist_ok=True)
# Extract only the files that live under skill_prefix
for member in all_names:
if not member.startswith(skill_prefix) or member == skill_prefix:
continue
# Relative path inside the skill directory
rel = member[len(skill_prefix) :]
if not rel:
continue
target = dest / rel
if member.endswith("/"):
target.mkdir(parents=True, exist_ok=True)
else:
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(zf.read(member))
rel_path = str(dest.relative_to(skills_dir))
return Result.succ(
{
"file_path": rel_path,
"message": f"Skill imported successfully from GitHub: {rel_path}",
}
)
except zipfile.BadZipFile as exc:
logger.exception("Bad ZIP received from GitHub")
return Result.failed(
code="E5003", msg=f"Downloaded archive is not a valid ZIP: {str(exc)}"
)
except Exception as exc:
logger.exception("Failed to import skill from GitHub")
return Result.failed(code="E5002", msg=f"Import failed: {str(exc)}")
@router.post("/v1/skills/import_github", response_model=Result)
async def skill_import_from_github_v2(
request: Request,
user_token: UserRequest = Depends(get_user_from_headers),
):
"""Import a skill from a GitHub or skills.sh URL (v2 — raw JSON body).
"""Import a skill from a GitHub or skills.sh URL.
Accepts ``{ "url": "..." }`` from the frontend, downloads the repository
ZIP, extracts the skill, installs it to ``skills/user/<name>/``, and
returns a success response.
Compared to the v1 endpoint (``/v1/skills/import_from_github``), this
endpoint:
This endpoint:
- Accepts a raw JSON body ``{ "url": "..." }`` (no Pydantic model).
- Supports branch fallback: tries ``main`` first, then ``master`` if 404.

View File

@@ -65,6 +65,7 @@ export const ChatEn = {
export_pdf: 'Export PDF',
task_files: 'Task Files',
web_preview: 'Web Preview',
content_summary: 'Summary',
skill_label: 'Skill',
database_label: 'Database',
waiting_to_start: 'Waiting to start...',

View File

@@ -73,6 +73,7 @@ export const ChatZh: Resources['translation'] = {
export_pdf: '导出 PDF',
task_files: '任务文件',
web_preview: '网页预览',
content_summary: '摘要',
skill_label: '技能',
database_label: '数据库',
waiting_to_start: '等待开始...',
@@ -124,18 +125,18 @@ export const ChatZh: Resources['translation'] = {
artifact_type_generic: '产物',
download: '下载',
download_as_zip: '下载为 ZIP',
skill_added_success: '技能 "{{skillName}}" 已添加',
added: '已添加',
add: '添加',
add_context: '添加上下文(文件、数据库、知识库)',
select_skill: '选择技能',
skill_selected: '技能:{{name}}',
select_database: '选择数据库',
database_selected: '数据库:{{name}}',
select_knowledge: '选择知识库',
knowledge_selected: '知识库:{{name}}',
voice_input: '语音输入',
voice_input_coming_soon: '语音输入即将上线',
expand_panel: '展开面板',
search_skill: '搜索技能',
skill_added_success: '技能 "{{skillName}}" 已添加',
added: '已添加',
add: '添加',
add_context: '添加上下文(文件、数据库、知识库)',
select_skill: '选择技能',
skill_selected: '技能:{{name}}',
select_database: '选择数据库',
database_selected: '数据库:{{name}}',
select_knowledge: '选择知识库',
knowledge_selected: '知识库:{{name}}',
voice_input: '语音输入',
voice_input_coming_soon: '语音输入即将上线',
expand_panel: '展开面板',
search_skill: '搜索技能',
} as const;

View File

@@ -205,7 +205,8 @@ const getFileTypeLabel = (fileName: string, t: any, mimeType?: string): string =
return t('file_type_spreadsheet');
if (ext === 'csv' || mimeType?.includes('csv')) return t('file_type_spreadsheet');
if (ext === 'pdf' || mimeType?.includes('pdf')) return t('file_type_pdf');
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext) || mimeType?.includes('image')) return t('file_type_image');
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext) || mimeType?.includes('image'))
return t('file_type_image');
if (['doc', 'docx'].includes(ext) || mimeType?.includes('word')) return t('file_type_word');
if (['txt', 'md'].includes(ext) || mimeType?.includes('text')) return t('file_type_text');
return t('file_type_generic');
@@ -473,7 +474,10 @@ const StepCard: React.FC<{
}, []);
const isThinkingStep =
step.status === 'running' &&
(step.title === t('thinking') || step.title === '思考中' || step.title === '正在思考中' || step.title?.toLowerCase() === 'thinking');
(step.title === t('thinking') ||
step.title === '思考中' ||
step.title === '正在思考中' ||
step.title?.toLowerCase() === 'thinking');
if (isThinkingStep) {
return (
<div
@@ -898,7 +902,7 @@ const ManusLeftPanel: React.FC<ManusLeftPanelProps> = ({
<div className='text-sm font-medium text-gray-800 dark:text-gray-200 truncate'>
{attachedSkill.name}
</div>
<div className='text-[11px] text-gray-400 dark:text-gray-500'>{t('skill_label')}</div>
<div className='text-[11px] text-gray-400 dark:text-gray-500'>{t('skill_label')}</div>
</div>
</div>
)}
@@ -911,9 +915,9 @@ const ManusLeftPanel: React.FC<ManusLeftPanelProps> = ({
<div className='text-sm font-medium text-gray-800 dark:text-gray-200 truncate'>
{attachedDb.db_name}
</div>
<div className='text-[11px] text-gray-400 dark:text-gray-500'>
{attachedDb.db_type || t('database_label')}
</div>
<div className='text-[11px] text-gray-400 dark:text-gray-500'>
{attachedDb.db_type || t('database_label')}
</div>
</div>
</div>
)}
@@ -945,7 +949,7 @@ const ManusLeftPanel: React.FC<ManusLeftPanelProps> = ({
<span className='text-sm text-blue-600 dark:text-blue-400'>{t('db_gpt_thinking')}</span>
</div>
) : (
<span className='text-sm'>{t('waiting_to_start')}</span>
<span className='text-sm'>{t('waiting_to_start')}</span>
)}
{isWorking && stepThoughts?.[activeStepId || 'initial'] && (
<ThoughtBubble text={stepThoughts[activeStepId || 'initial']} />

View File

@@ -101,9 +101,13 @@ export interface ManusRightPanelProps {
databaseName?: string;
/** Skill name for the skill-preview tab (set when a skill is created/packaged) */
skillName?: string | null;
/** Summary content to display in the summary tab */
summaryContent?: string;
/** Whether the summary is currently streaming */
isSummaryStreaming?: boolean;
}
export type PanelView = 'execution' | 'files' | 'html-preview' | 'skill-preview';
export type PanelView = 'execution' | 'files' | 'html-preview' | 'skill-preview' | 'summary';
// Get icon for step type
const getStepTypeIcon = (type: StepType) => {
@@ -1456,6 +1460,8 @@ const ManusRightPanel: React.FC<ManusRightPanelProps> = ({
databaseType,
databaseName,
skillName,
summaryContent,
isSummaryStreaming,
}) => {
const { t } = useTranslation();
const [inputCollapsed, setInputCollapsed] = useState(false);
@@ -1661,7 +1667,7 @@ const ManusRightPanel: React.FC<ManusRightPanelProps> = ({
</div>
{/* View Toggle Tabs */}
{((artifacts && artifacts.length > 0) || previewArtifact || skillName) && (
{((artifacts && artifacts.length > 0) || previewArtifact || skillName || !!summaryContent) && (
<div className='flex items-center gap-0 px-5 bg-white dark:bg-[#111217] border-b border-gray-200 dark:border-gray-800'>
<button
onClick={() => setPanelView('execution')}
@@ -1715,6 +1721,23 @@ const ManusRightPanel: React.FC<ManusRightPanelProps> = ({
)}
</button>
)}
{!!summaryContent && (
<button
onClick={() => setPanelView('summary')}
className={classNames(
'px-4 py-2.5 text-xs font-medium transition-colors relative',
panelView === 'summary'
? 'text-gray-900 dark:text-gray-100'
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300',
)}
>
<FileTextOutlined className='mr-1.5' />
{t('content_summary')}
{panelView === 'summary' && (
<div className='absolute bottom-0 left-0 right-0 h-[2px] bg-gray-900 dark:bg-gray-100 rounded-full' />
)}
</button>
)}
{previewArtifact && (
<button
onClick={() => setPanelView('html-preview')}
@@ -1776,6 +1799,13 @@ const ManusRightPanel: React.FC<ManusRightPanelProps> = ({
);
})()}
</div>
) : panelView === 'summary' && summaryContent ? (
<div className='prose prose-sm dark:prose-invert max-w-none text-gray-800 dark:text-gray-200 leading-relaxed'>
<MarkDownContext>{summaryContent}</MarkDownContext>
{isSummaryStreaming && (
<span className='inline-block w-1.5 h-4 bg-blue-500 animate-pulse ml-0.5 align-text-bottom' />
)}
</div>
) : panelView === 'files' ? (
<div className='space-y-0'>
<div className='flex items-center gap-1 mb-4 bg-gray-100/80 dark:bg-gray-800/60 rounded-lg p-1'>
@@ -1932,7 +1962,9 @@ const ManusRightPanel: React.FC<ManusRightPanelProps> = ({
<div className='text-sm font-semibold text-gray-800 dark:text-gray-200 truncate'>
{skillDisplayName}
</div>
<div className='text-[11px] text-gray-400 dark:text-gray-500'>{t('skill_label')}</div>
<div className='text-[11px] text-gray-400 dark:text-gray-500'>
{t('skill_label')}
</div>
</div>
</div>
{skillDescription && (

View File

@@ -11,7 +11,6 @@ import {
PlusOutlined,
SearchOutlined,
} from '@ant-design/icons';
import { useRequest } from 'ahooks';
import {
Button,
Dropdown,
@@ -30,7 +29,7 @@ import {
message,
} from 'antd';
import type { DataNode } from 'antd/es/tree';
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
interface SkillItem {
@@ -105,45 +104,56 @@ function Skills() {
const [importLoading, setImportLoading] = useState(false);
const [importForm] = Form.useForm();
const {
data: skillsList = [],
loading: listLoading,
refresh: refreshList,
} = useRequest<SkillItem[], []>(async () => {
try {
const response = await axios.get(`${process.env.API_BASE_URL ?? ''}/api/v1/skills/list`);
if (response?.success && Array.isArray(response.data)) {
return response.data as SkillItem[];
}
return [];
} catch (err) {
console.error('[Skills] Failed to fetch list:', err);
return [];
}
});
const [skillsList, setSkillsList] = useState<SkillItem[]>([]);
const [listLoading, setListLoading] = useState(false);
const [skillDetail, setSkillDetail] = useState<SkillDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const listFetchCountRef = useRef(0);
const {
data: skillDetail,
loading: detailLoading,
run: fetchDetail,
mutate: setSkillDetail,
} = useRequest<SkillDetail | null, [string, string]>(
async (skillName: string, filePath: string) => {
try {
const response = await axios.get(`${process.env.API_BASE_URL ?? ''}/api/v1/skills/detail`, {
params: { skill_name: skillName, file_path: filePath },
});
if (response?.success && response.data) {
return response.data as SkillDetail;
}
return null;
} catch (err) {
console.error('[Skills] Failed to fetch detail:', err);
return null;
const fetchSkillsList = useCallback(async () => {
setListLoading(true);
const currentFetch = ++listFetchCountRef.current;
try {
const response = (await axios.get(`${process.env.API_BASE_URL ?? ''}/api/v1/skills/list`)) as any;
if (currentFetch !== listFetchCountRef.current) return;
if (response?.success && Array.isArray(response.data)) {
setSkillsList(response.data as SkillItem[]);
} else {
setSkillsList([]);
}
},
{ manual: true },
);
} catch (err) {
if (currentFetch !== listFetchCountRef.current) return;
console.error('[Skills] Failed to fetch list:', err);
setSkillsList([]);
} finally {
if (currentFetch === listFetchCountRef.current) {
setListLoading(false);
}
}
}, []);
useEffect(() => {
fetchSkillsList();
}, [fetchSkillsList]);
const fetchDetail = useCallback(async (skillName: string, filePath: string) => {
setDetailLoading(true);
try {
const response = await (axios.get(`${process.env.API_BASE_URL ?? ''}/api/v1/skills/detail`, {
params: { skill_name: skillName, file_path: filePath },
}) as Promise<any>);
if (response?.success && response.data) {
setSkillDetail(response.data as SkillDetail);
} else {
setSkillDetail(null);
}
} catch (err) {
console.error('[Skills] Failed to fetch detail:', err);
setSkillDetail(null);
} finally {
setDetailLoading(false);
}
}, []);
const filteredSkills = useMemo(() => {
let list = skillsList;
@@ -170,7 +180,7 @@ function Skills() {
setDetailOpen(false);
setSelectedSkill(null);
setSkillDetail(null);
}, [setSkillDetail]);
}, []);
const handleToggle = useCallback((skillId: string, checked: boolean) => {
setEnabledMap(prev => ({ ...prev, [skillId]: checked }));
@@ -241,9 +251,9 @@ function Skills() {
message.success(t('skills_upload_success', { count: successCount }));
setUploadOpen(false);
setUploadFileList([]);
refreshList();
fetchSkillsList();
}
}, [uploadFileList, refreshList, t]);
}, [uploadFileList, fetchSkillsList, t]);
const uploadProps: UploadProps = {
multiple: true,
@@ -269,12 +279,16 @@ function Skills() {
try {
const values = await importForm.validateFields();
setImportLoading(true);
const res = await axios.post('/api/v1/skills/import_github', { url: values.github_url }, { timeout: 60000 }) as any;
const res = (await axios.post(
'/api/v1/skills/import_github',
{ url: values.github_url },
{ timeout: 60000 },
)) as any;
if (res?.success) {
message.success(t('skills_github_import_success'));
setImportModalVisible(false);
importForm.resetFields();
refreshList();
fetchSkillsList();
} else {
message.error(res?.err_msg || t('skills_github_import_failed'));
}

View File

@@ -1792,10 +1792,11 @@ const Playground: NextPage = () => {
);
setActiveMessageId(responseId);
if (payload.content) {
if (payload.content && payload.content.trim()) {
setStreamingSummary('');
setSummaryComplete(false);
setRightPanelTab('summary');
setRightPanelView('summary');
const summaryText = cleanFinalContent(payload.content);
const streamInterval = setInterval(() => {
@@ -2718,7 +2719,6 @@ const Playground: NextPage = () => {
</div>
</div>
</div>
</div>
{/* Panel toggle handle — placed between panels to avoid overflow clipping */}
<div className='relative z-20 flex-shrink-0'>
@@ -2727,7 +2727,11 @@ const Playground: NextPage = () => {
onClick={() => setRightPanelCollapsed(prev => !prev)}
className='absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-4 h-8 flex items-center justify-center bg-white dark:bg-[#1a1b1e] border border-gray-200 dark:border-gray-700 rounded-full shadow-sm hover:bg-gray-100 dark:hover:bg-gray-800 hover:w-5 hover:shadow-md transition-all duration-200 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
>
{rightPanelCollapsed ? <LeftOutlined style={{ fontSize: 10 }} /> : <RightOutlined style={{ fontSize: 10 }} />}
{rightPanelCollapsed ? (
<LeftOutlined style={{ fontSize: 10 }} />
) : (
<RightOutlined style={{ fontSize: 10 }} />
)}
</button>
</Tooltip>
</div>
@@ -2781,6 +2785,8 @@ const Playground: NextPage = () => {
onPanelViewChange={setRightPanelView}
previewArtifact={previewArtifact}
skillName={createdSkillNames[activeViewMsg?.id || ''] || null}
summaryContent={streamingSummary || activeViewMsg?.context || ''}
isSummaryStreaming={!_summaryComplete && !!streamingSummary}
/>
);
})()}