Skip to content

Commit 790138c

Browse files
walker83walker83
andauthored
feat(skills): add AI skill optimization with streaming support (agentscope-ai#853)
Co-authored-by: walker83 <itkigntao@126.com>
1 parent 0e79d78 commit 790138c

File tree

8 files changed

+425
-18
lines changed

8 files changed

+425
-18
lines changed

console/src/api/modules/skill.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
import { request } from "../request";
22
import type { HubSkillSpec, SkillSpec } from "../types";
33

4+
// Declare BASE_URL as global (injected by Vite)
5+
declare const BASE_URL: string;
6+
7+
// Get the API base URL for streaming requests
8+
function getStreamApiUrl(): string {
9+
const base = typeof BASE_URL === "string" ? BASE_URL : "";
10+
return `${base}/api`;
11+
}
12+
413
export const skillApi = {
514
listSkills: () => request<SkillSpec[]>("/skills"),
615

@@ -54,4 +63,68 @@ export const skillApi = {
5463
method: "POST",
5564
body: JSON.stringify(payload),
5665
}),
66+
67+
// Stream optimize skill with SSE (supports abort via signal)
68+
streamOptimizeSkill: async function (
69+
content: string,
70+
onChunk: (text: string) => void,
71+
signal: AbortSignal,
72+
language: string = "en",
73+
): Promise<void> {
74+
const apiUrl = getStreamApiUrl();
75+
76+
const response = await fetch(`${apiUrl}/skills/ai/optimize/stream`, {
77+
method: "POST",
78+
headers: {
79+
"Content-Type": "application/json",
80+
},
81+
body: JSON.stringify({ content, language }),
82+
signal,
83+
});
84+
85+
if (!response.ok) {
86+
throw new Error(`HTTP error! status: ${response.status}`);
87+
}
88+
89+
const reader = response.body?.getReader();
90+
if (!reader) {
91+
throw new Error("No reader available");
92+
}
93+
94+
const decoder = new TextDecoder();
95+
let buffer = "";
96+
97+
try {
98+
while (true) {
99+
const { done, value } = await reader.read();
100+
if (done) break;
101+
102+
buffer += decoder.decode(value, { stream: true });
103+
const lines = buffer.split("\n");
104+
105+
for (let i = 0; i < lines.length - 1; i++) {
106+
const line = lines[i].trim();
107+
if (line.startsWith("data: ")) {
108+
const data = line.slice(6);
109+
try {
110+
const parsed = JSON.parse(data);
111+
if (parsed.text) {
112+
onChunk(parsed.text);
113+
} else if (parsed.error) {
114+
throw new Error(parsed.error);
115+
} else if (parsed.done) {
116+
return;
117+
}
118+
} catch {
119+
// Skip invalid JSON
120+
}
121+
}
122+
}
123+
124+
buffer = lines[lines.length - 1];
125+
}
126+
} finally {
127+
reader.releaseLock();
128+
}
129+
},
57130
};

console/src/locales/en.json

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"copied": "Copied to clipboard",
2121
"copyFailed": "Failed to copy to clipboard",
2222
"contentPlaceholder": "Enter content...",
23+
"help": "Help",
2324
"close": "Close"
2425
},
2526
"nav": {
@@ -80,7 +81,7 @@
8081
"pleaseInputName": "Please input skill name",
8182
"pleaseInputContent": "Please input skill content",
8283
"skillNamePlaceholder": "e.g., weather_query",
83-
"contentPlaceholder": "---\nname: <skill_name> (required)\ndescription: <skill description> (required)\nmetadata: { \"copaw\": { \"emoji\": \"🔧\" } }\n---\n\nSkill implementation content...\n\n# Example:\n# ---\n# name: cron\n# description: Manage cron jobs via copaw commands - create, query, pause, resume, delete tasks\n# metadata: { \"copaw\": { \"emoji\": \"\" } }\n# ---",
84+
"contentPlaceholder": "[Format]\n---\nname: skill_name (required, lowercase with underscores)\ndescription: Brief description (required)\n---\n\nSkill implementation (Markdown format)\n\n[Example]\n---\nname: weather_query\ndescription: Query weather info for a city\n---\n\n## Features\nQuery real-time weather data.\n\n## Usage\nUser inputs city name, returns weather info.",
8485
"createSuccess": "Skill created successfully",
8586
"createFailed": "Failed to create skill",
8687
"deleteConfirm": "Are you sure you want to delete this skill?",
@@ -93,7 +94,12 @@
9394
"frontmatterDescriptionRequired": "Skills missing required field : description",
9495
"editNotSupported": "Edit operation is not supported by backend API",
9596
"editNote": "Note: Backend API does not support editing skills. You can only view or toggle enable/disable status.",
96-
"create": "Create"
97+
"create": "Create",
98+
"optimizeWithAI": "AI Optimize",
99+
"stopOptimize": "Stop",
100+
"optimizeSuccess": "Skill optimized successfully",
101+
"optimizeFailed": "Failed to optimize skill",
102+
"noContentToOptimize": "No content to optimize"
97103
},
98104
"mcp": {
99105
"title": "MCP Clients",

console/src/locales/ru.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@
8080
"pleaseInputName": "Пожалуйста, введите название навыка",
8181
"pleaseInputContent": "Пожалуйста, введите содержимое навыка",
8282
"skillNamePlaceholder": "например, weather_query",
83-
"contentPlaceholder": "---\nname: <skill_name> (обязательно)\ndescription: <описание навыка> (обязательно)\nmetadata: { \"copaw\": { \"emoji\": \"🔧\" } }\n---\n\nСодержимое реализации навыка...\n\n# Пример:\n# ---\n# name: cron\n# description: Управление cron-задачами через команды copaw - создание, просмотр, пауза, возобновление и удаление задач\n# metadata: { \"copaw\": { \"emoji\": \"\" } }\n# ---",
83+
"contentPlaceholder": "[Формат]\n---\nname: skill_name (обязательно, строчные буквы с подчёркиванием)\ndescription: Краткое описание (обязательно)\n---\n\nРеализация навыка (формат Markdown)\n\n[Пример]\n---\nname: weather_query\ndescription: Запрос информации о погоде для города\n---\n\n## Функции\nЗапрос данных о погоде в реальном времени.\n\n## Использование\nПользователь вводит название города, возвращается информация о погоде.",
8484
"createSuccess": "Навык успешно создан",
8585
"createFailed": "Не удалось создать навык",
8686
"deleteConfirm": "Вы уверены, что хотите удалить этот навык?",
@@ -93,7 +93,12 @@
9393
"frontmatterDescriptionRequired": "В навыке отсутствует обязательное поле: description",
9494
"editNotSupported": "Операция редактирования не поддерживается API бэкенда",
9595
"editNote": "Примечание: API бэкенда не поддерживает редактирование навыков. Можно только просматривать и переключать статус включения/отключения.",
96-
"create": "Создать"
96+
"create": "Создать",
97+
"optimizeWithAI": "AI Оптимизация",
98+
"stopOptimize": "Стоп",
99+
"optimizeSuccess": "Навык успешно оптимизирован",
100+
"optimizeFailed": "Не удалось оптимизировать навык",
101+
"noContentToOptimize": "Нет содержимого для оптимизации"
97102
},
98103
"mcp": {
99104
"title": "MCP-клиенты",

console/src/locales/zh.json

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"copied": "已复制到剪贴板",
2121
"copyFailed": "复制到剪贴板失败",
2222
"contentPlaceholder": "输入内容...",
23+
"help": "帮助",
2324
"close": "关闭"
2425
},
2526
"nav": {
@@ -80,7 +81,7 @@
8081
"pleaseInputName": "请输入技能名称",
8182
"pleaseInputContent": "请输入技能内容",
8283
"skillNamePlaceholder": "例如:weather_query",
83-
"contentPlaceholder": "---\nname: <技能名称>(必填)\ndescription: <技能功能描述>(必填\nmetadata: { \"copaw\": { \"emoji\": \"🔧\" } }\n---\n\n技能实现内容...\n\n# 示例:\n# ---\n# name: cron\n# description: 通过 copaw 命令管理定时任务 - 创建、查询、暂停、恢复、删除任务\n# metadata: { \"copaw\": { \"emoji\": \"\" } }\n# ---",
84+
"contentPlaceholder": "【格式要求】\n---\nname: 技能名称(必填,英文小写下划线\ndescription: 功能描述(必填,简洁清晰)\n---\n\n技能实现内容(Markdown格式)\n\n【示例】\n---\nname: weather_query\ndescription: 查询指定城市的天气信息\n---\n\n## 功能\n查询实时天气数据。\n\n## 使用\n用户输入城市名,返回天气信息。",
8485
"createSuccess": "技能创建成功",
8586
"createFailed": "技能创建失败",
8687
"deleteConfirm": "确定要删除此技能吗?",
@@ -93,7 +94,12 @@
9394
"frontmatterDescriptionRequired": "Skills 中缺少必填字段:description",
9495
"editNotSupported": "后端API不支持编辑操作",
9596
"editNote": "注意:后端API不支持编辑技能。您只能查看或切换启用/禁用状态。",
96-
"create": "创建"
97+
"create": "创建",
98+
"optimizeWithAI": "AI优化",
99+
"stopOptimize": "停止",
100+
"optimizeSuccess": "技能优化成功",
101+
"optimizeFailed": "技能优化失败",
102+
"noContentToOptimize": "没有可优化的内容"
97103
},
98104
"mcp": {
99105
"title": "MCP 客户端",

console/src/pages/Agent/Skills/components/SkillDrawer.tsx

Lines changed: 77 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1-
import { useState, useEffect, useCallback } from "react";
1+
import { useState, useEffect, useCallback, useRef } from "react";
22
import { Drawer, Form, Input, Button, message } from "@agentscope-ai/design";
33
import { useTranslation } from "react-i18next";
4+
import { ThunderboltOutlined, StopOutlined } from "@ant-design/icons";
45
import type { FormInstance } from "antd";
56
import type { SkillSpec } from "../../../../api/types";
67
import { MarkdownCopy } from "../../../../components/MarkdownCopy/MarkdownCopy";
8+
import { api } from "../../../../api";
79

810
/**
911
* Parse frontmatter from content string.
@@ -48,9 +50,11 @@ export function SkillDrawer({
4850
onSubmit,
4951
onContentChange,
5052
}: SkillDrawerProps) {
51-
const { t } = useTranslation();
53+
const { t, i18n } = useTranslation();
5254
const [showMarkdown, setShowMarkdown] = useState(true);
5355
const [contentValue, setContentValue] = useState("");
56+
const [optimizing, setOptimizing] = useState(false);
57+
const abortControllerRef = useRef<AbortController | null>(null);
5458

5559
const validateFrontmatter = useCallback(
5660
(_: unknown, value: string) => {
@@ -105,13 +109,55 @@ export function SkillDrawer({
105109
const handleContentChange = (content: string) => {
106110
setContentValue(content);
107111
form.setFieldsValue({ content });
108-
// Re-validate the content field to give real-time feedback
109112
form.validateFields(["content"]).catch(() => {});
110113
if (onContentChange) {
111114
onContentChange(content);
112115
}
113116
};
114117

118+
const handleOptimize = async () => {
119+
if (!contentValue.trim()) {
120+
message.warning(t("skills.noContentToOptimize"));
121+
return;
122+
}
123+
124+
setOptimizing(true);
125+
abortControllerRef.current = new AbortController();
126+
const originalContent = contentValue;
127+
setContentValue(""); // Clear content for streaming output
128+
129+
try {
130+
await api.streamOptimizeSkill(
131+
originalContent,
132+
(textChunk) => {
133+
setContentValue((prev) => {
134+
const newContent = prev + textChunk;
135+
form.setFieldsValue({ content: newContent });
136+
return newContent;
137+
});
138+
},
139+
abortControllerRef.current.signal,
140+
i18n.language, // Pass current language to API
141+
);
142+
message.success(t("skills.optimizeSuccess"));
143+
} catch (error: any) {
144+
if (error.name !== "AbortError") {
145+
message.error(error.message || t("skills.optimizeFailed"));
146+
}
147+
} finally {
148+
setOptimizing(false);
149+
abortControllerRef.current = null;
150+
}
151+
};
152+
153+
const handleStopOptimize = () => {
154+
if (abortControllerRef.current) {
155+
abortControllerRef.current.abort();
156+
setOptimizing(false);
157+
abortControllerRef.current = null;
158+
}
159+
};
160+
115161
return (
116162
<Drawer
117163
width={520}
@@ -154,15 +200,37 @@ export function SkillDrawer({
154200
<div
155201
style={{
156202
display: "flex",
157-
justifyContent: "flex-end",
158-
gap: 8,
203+
justifyContent: "space-between",
159204
marginTop: 16,
160205
}}
161206
>
162-
<Button onClick={onClose}>{t("common.cancel")}</Button>
163-
<Button type="primary" htmlType="submit">
164-
{t("skills.create")}
165-
</Button>
207+
<div style={{ display: "flex", gap: 8 }}>
208+
{!optimizing ? (
209+
<Button
210+
type="default"
211+
icon={<ThunderboltOutlined />}
212+
onClick={handleOptimize}
213+
disabled={!contentValue.trim()}
214+
>
215+
{t("skills.optimizeWithAI")}
216+
</Button>
217+
) : (
218+
<Button
219+
type="default"
220+
danger
221+
icon={<StopOutlined />}
222+
onClick={handleStopOptimize}
223+
>
224+
{t("skills.stopOptimize")}
225+
</Button>
226+
)}
227+
</div>
228+
<div style={{ display: "flex", gap: 8 }}>
229+
<Button onClick={onClose}>{t("common.cancel")}</Button>
230+
<Button type="primary" htmlType="submit">
231+
{t("skills.create")}
232+
</Button>
233+
</div>
166234
</div>
167235
</Form.Item>
168236
</>

src/copaw/app/routers/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from .local_models import router as local_models_router
77
from .providers import router as providers_router
88
from .skills import router as skills_router
9+
from .skills_stream import router as skills_stream_router
910
from .workspace import router as workspace_router
1011
from .envs import router as envs_router
1112
from .ollama_models import router as ollama_models_router
@@ -29,6 +30,7 @@
2930
router.include_router(providers_router)
3031
router.include_router(runner_router)
3132
router.include_router(skills_router)
33+
router.include_router(skills_stream_router)
3234
router.include_router(tools_router)
3335
router.include_router(workspace_router)
3436
router.include_router(envs_router)

src/copaw/app/routers/skills.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -229,9 +229,12 @@ async def load_skill_file(
229229
Returns:
230230
File content as string, or None if not found
231231
232-
Example:
233-
GET /skills/my_skill/files/customized/references/doc.md
234-
GET /skills/builtin_skill/files/builtin/scripts/utils/helper.py
232+
Example:
233+
234+
GET /skills/my_skill/files/customized/references/doc.md
235+
236+
GET /skills/builtin_skill/files/builtin/scripts/utils/helper.py
237+
235238
"""
236239
content = SkillService.load_skill_file(
237240
skill_name=skill_name,

0 commit comments

Comments
 (0)