skillfree 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SKILL.md +37 -0
- package/bin/skillfree.js +66 -0
- package/install.sh +103 -0
- package/package.json +22 -0
- package/scripts/commands/auth.js +59 -0
- package/scripts/commands/chat.js +32 -0
- package/scripts/commands/image.js +92 -0
- package/scripts/commands/models.js +32 -0
- package/scripts/commands/music.js +28 -0
- package/scripts/commands/pilot.js +100 -0
- package/scripts/commands/stt.js +47 -0
- package/scripts/commands/tts.js +67 -0
- package/scripts/commands/video.js +97 -0
- package/scripts/lib/client.js +92 -0
- package/scripts/lib/fetch-retry.js +62 -0
package/SKILL.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# SkillFree Skill
|
|
2
|
+
|
|
3
|
+
## 功能
|
|
4
|
+
|
|
5
|
+
SkillFree 是一个统一的 AI 能力调用层。通过一个 API Key,调用图像生成、语音合成、视频生成、智能对话等多种 AI 能力。
|
|
6
|
+
|
|
7
|
+
## 使用方式
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
# 对话(1 积分)
|
|
11
|
+
skillfree pilot --type chat --prompt "帮我写一首诗"
|
|
12
|
+
|
|
13
|
+
# 图像生成(5 积分)
|
|
14
|
+
skillfree pilot --type image --prompt "赛博朋克的上海" --output ./out.png
|
|
15
|
+
|
|
16
|
+
# 语音合成(2 积分)
|
|
17
|
+
skillfree pilot --type tts --text "你好,龙虾" --output hello.mp3
|
|
18
|
+
|
|
19
|
+
# 视频生成(20 积分)
|
|
20
|
+
skillfree pilot --type video --prompt "海浪拍打礁石" --output ./video.mp4
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## 配置
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
export SKILLFREE_API_KEY=sk-sf-xxxxxxxx
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
或通过登录:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
skillfree auth login
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## 充值
|
|
36
|
+
|
|
37
|
+
充多少用多少,积分永不过期:https://skillfree.ai/billing
|
package/bin/skillfree.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// skillfree CLI 入口
|
|
3
|
+
|
|
4
|
+
const { program } = require('commander')
|
|
5
|
+
const pkg = require('../package.json')
|
|
6
|
+
|
|
7
|
+
program
|
|
8
|
+
.name('skillfree')
|
|
9
|
+
.description('🦞 一个 API,满足所有龙虾技能需求')
|
|
10
|
+
.version(pkg.version)
|
|
11
|
+
|
|
12
|
+
// ── auth ──────────────────────────────────────────────────────────────────────
|
|
13
|
+
const auth = program.command('auth').description('账号管理')
|
|
14
|
+
auth
|
|
15
|
+
.command('login')
|
|
16
|
+
.description('登录并保存 API Key')
|
|
17
|
+
.action(async () => {
|
|
18
|
+
const { authLogin } = require('../scripts/commands/auth')
|
|
19
|
+
await authLogin()
|
|
20
|
+
})
|
|
21
|
+
auth
|
|
22
|
+
.command('status')
|
|
23
|
+
.description('查看当前登录状态和积分')
|
|
24
|
+
.action(async () => {
|
|
25
|
+
const { authStatus } = require('../scripts/commands/auth')
|
|
26
|
+
await authStatus()
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
// ── pilot ─────────────────────────────────────────────────────────────────────
|
|
30
|
+
program
|
|
31
|
+
.command('pilot')
|
|
32
|
+
.description('AI 智能调度,支持 chat / image / tts / stt / video / music')
|
|
33
|
+
.option('--type <type>', '调用类型: chat | image | tts | stt | video | music', 'chat')
|
|
34
|
+
.option('--prompt <text>', '输入提示词')
|
|
35
|
+
.option('--text <text>', '文字内容(tts 用)')
|
|
36
|
+
.option('--file <path>', '输入文件路径(stt 用)')
|
|
37
|
+
.option('--output <path>', '输出文件路径')
|
|
38
|
+
.option('--model <model>', '指定模型(可选)')
|
|
39
|
+
.option('--voice <voice>', 'TTS 声音(默认 nova)')
|
|
40
|
+
.option('--size <size>', '图像尺寸(默认 1024x1024)')
|
|
41
|
+
.option('--duration <sec>', '视频/音乐时长(秒)')
|
|
42
|
+
.action(async (flags) => {
|
|
43
|
+
const { pilot } = require('../scripts/commands/pilot')
|
|
44
|
+
await pilot(flags).catch(e => { console.error('❌', e.message); process.exit(1) })
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
// ── chat (快捷方式) ───────────────────────────────────────────────────────────
|
|
48
|
+
program
|
|
49
|
+
.command('chat <prompt>')
|
|
50
|
+
.description('快捷对话(等同于 pilot --type chat)')
|
|
51
|
+
.option('--model <model>', '指定模型')
|
|
52
|
+
.action(async (prompt, flags) => {
|
|
53
|
+
const { pilot } = require('../scripts/commands/pilot')
|
|
54
|
+
await pilot({ type: 'chat', prompt, ...flags }).catch(e => { console.error('❌', e.message); process.exit(1) })
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
// ── balance ───────────────────────────────────────────────────────────────────
|
|
58
|
+
program
|
|
59
|
+
.command('balance')
|
|
60
|
+
.description('查看积分余额')
|
|
61
|
+
.action(async () => {
|
|
62
|
+
const { authStatus } = require('../scripts/commands/auth')
|
|
63
|
+
await authStatus()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
program.parse(process.argv)
|
package/install.sh
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# install.sh — SkillFree 一键安装脚本
|
|
3
|
+
# curl -fsSL https://skillfree.ai/install.sh | bash
|
|
4
|
+
|
|
5
|
+
set -e
|
|
6
|
+
|
|
7
|
+
CYAN='\033[0;36m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; NC='\033[0m'
|
|
8
|
+
info() { echo -e "${CYAN}[SkillFree]${NC} $*"; }
|
|
9
|
+
success() { echo -e "${GREEN}[SkillFree]${NC} ✓ $*"; }
|
|
10
|
+
warn() { echo -e "${YELLOW}[SkillFree]${NC} ⚠ $*"; }
|
|
11
|
+
err() { echo -e "${RED}[SkillFree]${NC} ✗ $*"; exit 1; }
|
|
12
|
+
|
|
13
|
+
echo ""
|
|
14
|
+
echo -e "${CYAN}🦞 SkillFree 安装程序${NC}"
|
|
15
|
+
echo -e " 一个 API,满足所有龙虾技能需求"
|
|
16
|
+
echo ""
|
|
17
|
+
|
|
18
|
+
# ── 1. 检查 Node.js ──────────────────────────────────────────────────────────
|
|
19
|
+
if ! command -v node &>/dev/null; then
|
|
20
|
+
err "需要 Node.js 18+,请先安装:https://nodejs.org"
|
|
21
|
+
fi
|
|
22
|
+
NODE_VER=$(node -e "process.stdout.write(process.versions.node.split('.')[0])")
|
|
23
|
+
[ "$NODE_VER" -lt 18 ] && err "Node.js 版本过低(当前 v$NODE_VER),需要 18+"
|
|
24
|
+
|
|
25
|
+
# ── 2. 安装 CLI 包 ────────────────────────────────────────────────────────────
|
|
26
|
+
info "安装 skillfree CLI..."
|
|
27
|
+
npm install -g skillfree --quiet 2>/dev/null \
|
|
28
|
+
|| npx --yes skillfree --version &>/dev/null \
|
|
29
|
+
|| warn "全局安装需要权限,尝试 sudo npm install -g skillfree"
|
|
30
|
+
success "CLI 安装完成"
|
|
31
|
+
|
|
32
|
+
# ── 3. 输入 API Key ───────────────────────────────────────────────────────────
|
|
33
|
+
echo ""
|
|
34
|
+
echo -e " 请前往 ${CYAN}https://skillfree.ai/dashboard${NC} 获取 API Key"
|
|
35
|
+
read -rp " 输入你的 API Key (sk-sf-...): " API_KEY
|
|
36
|
+
|
|
37
|
+
if [[ -z "$API_KEY" || "$API_KEY" != sk-sf-* ]]; then
|
|
38
|
+
warn "未输入有效 Key,跳过配置(稍后可运行 skillfree auth login)"
|
|
39
|
+
else
|
|
40
|
+
# ── 4. 写入环境变量 ─────────────────────────────────────────────────────────
|
|
41
|
+
SHELL_RC="$HOME/.zshrc"
|
|
42
|
+
[[ "$SHELL" == *bash* ]] && SHELL_RC="$HOME/.bashrc"
|
|
43
|
+
if ! grep -q "SKILLFREE_API_KEY" "$SHELL_RC" 2>/dev/null; then
|
|
44
|
+
echo "" >> "$SHELL_RC"
|
|
45
|
+
echo "# SkillFree" >> "$SHELL_RC"
|
|
46
|
+
echo "export SKILLFREE_API_KEY=\"$API_KEY\"" >> "$SHELL_RC"
|
|
47
|
+
else
|
|
48
|
+
sed -i.bak "s/export SKILLFREE_API_KEY=.*/export SKILLFREE_API_KEY=\"$API_KEY\"/" "$SHELL_RC"
|
|
49
|
+
fi
|
|
50
|
+
export SKILLFREE_API_KEY="$API_KEY"
|
|
51
|
+
success "API Key 已写入 $SHELL_RC"
|
|
52
|
+
|
|
53
|
+
# ── 5. 自动配置各 AI 工具 ────────────────────────────────────────────────────
|
|
54
|
+
SKILL_SRC_DIR="$HOME/.skillfree/skill"
|
|
55
|
+
mkdir -p "$SKILL_SRC_DIR"
|
|
56
|
+
|
|
57
|
+
# 下载 SKILL.md(龙虾技能描述)
|
|
58
|
+
curl -fsSL "https://skillfree.ai/skill/SKILL.md" -o "$SKILL_SRC_DIR/SKILL.md" 2>/dev/null \
|
|
59
|
+
|| echo "# SkillFree\nUse skillfree CLI for AI tasks." > "$SKILL_SRC_DIR/SKILL.md"
|
|
60
|
+
|
|
61
|
+
# OpenClaw
|
|
62
|
+
for dir in "$HOME/.agents/skills" "$HOME/.openclaw/workspace/skills"; do
|
|
63
|
+
if [ -d "$dir" ]; then
|
|
64
|
+
mkdir -p "$dir/skillfree"
|
|
65
|
+
cp "$SKILL_SRC_DIR/SKILL.md" "$dir/skillfree/SKILL.md"
|
|
66
|
+
echo "SKILLFREE_API_KEY=$API_KEY" > "$dir/skillfree/.env"
|
|
67
|
+
success "OpenClaw 配置完成 → $dir/skillfree/"
|
|
68
|
+
fi
|
|
69
|
+
done
|
|
70
|
+
|
|
71
|
+
# Claude Code
|
|
72
|
+
if [ -d "$HOME/.claude" ]; then
|
|
73
|
+
mkdir -p "$HOME/.claude/skills/skillfree"
|
|
74
|
+
cp "$SKILL_SRC_DIR/SKILL.md" "$HOME/.claude/skills/skillfree/SKILL.md"
|
|
75
|
+
success "Claude Code 配置完成 → ~/.claude/skills/skillfree/"
|
|
76
|
+
fi
|
|
77
|
+
|
|
78
|
+
# Codex CLI
|
|
79
|
+
if [ -d "$HOME/.codex" ]; then
|
|
80
|
+
mkdir -p "$HOME/.codex/skills/skillfree"
|
|
81
|
+
cp "$SKILL_SRC_DIR/SKILL.md" "$HOME/.codex/skills/skillfree/SKILL.md"
|
|
82
|
+
success "Codex 配置完成 → ~/.codex/skills/skillfree/"
|
|
83
|
+
fi
|
|
84
|
+
|
|
85
|
+
# Continue.dev
|
|
86
|
+
if [ -d "$HOME/.continue" ]; then
|
|
87
|
+
mkdir -p "$HOME/.continue/skills/skillfree"
|
|
88
|
+
cp "$SKILL_SRC_DIR/SKILL.md" "$HOME/.continue/skills/skillfree/SKILL.md"
|
|
89
|
+
success "Continue.dev 配置完成 → ~/.continue/skills/skillfree/"
|
|
90
|
+
fi
|
|
91
|
+
fi
|
|
92
|
+
|
|
93
|
+
# ── 6. 完成 ───────────────────────────────────────────────────────────────────
|
|
94
|
+
echo ""
|
|
95
|
+
echo -e "${GREEN}🎉 安装完成!${NC}"
|
|
96
|
+
echo ""
|
|
97
|
+
echo -e " ${CYAN}快速开始:${NC}"
|
|
98
|
+
echo -e " $ skillfree pilot --type chat --prompt \"你好龙虾\""
|
|
99
|
+
echo -e " $ skillfree pilot --type image --prompt \"赛博朋克的上海\" --output ./img.png"
|
|
100
|
+
echo ""
|
|
101
|
+
echo -e " ${CYAN}充值积分:${NC} https://skillfree.ai/billing"
|
|
102
|
+
echo -e " ${CYAN}查看文档:${NC} https://skillfree.ai/docs"
|
|
103
|
+
echo ""
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "skillfree",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "🦞 一个 API,满足所有龙虾技能需求",
|
|
5
|
+
"main": "bin/skillfree.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"skillfree": "./bin/skillfree.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "node bin/skillfree.js --help"
|
|
11
|
+
},
|
|
12
|
+
"keywords": ["ai", "skillfree", "openclaw", "claude", "llm"],
|
|
13
|
+
"homepage": "https://skillfree.tech",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "https://github.com/ChongC1990/skillfree"
|
|
17
|
+
},
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"commander": "^12.0.0"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
const readline = require('readline')
|
|
2
|
+
const { saveConfig, BASE_URL } = require('../lib/client')
|
|
3
|
+
|
|
4
|
+
async function authLogin() {
|
|
5
|
+
console.log(`\n🦞 SkillFree 登录`)
|
|
6
|
+
console.log(` 控制台:${BASE_URL}/app\n`)
|
|
7
|
+
|
|
8
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
|
|
9
|
+
const ask = (q) => new Promise(resolve => rl.question(q, resolve))
|
|
10
|
+
|
|
11
|
+
console.log('请前往控制台 → API Keys → 创建 Key,然后粘贴到此处。')
|
|
12
|
+
const key = (await ask('API Key (sk-sf-...): ')).trim()
|
|
13
|
+
rl.close()
|
|
14
|
+
|
|
15
|
+
if (!key.startsWith('sk-sf-')) {
|
|
16
|
+
console.error('❌ 格式不正确,Key 应以 sk-sf- 开头')
|
|
17
|
+
process.exit(1)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// 验证 key 是否有效
|
|
21
|
+
process.stdout.write('验证中...')
|
|
22
|
+
try {
|
|
23
|
+
const res = await fetch(`${BASE_URL}/v1/balance`, {
|
|
24
|
+
headers: { 'Authorization': `Bearer ${key}` }
|
|
25
|
+
})
|
|
26
|
+
if (!res.ok) throw new Error(res.status)
|
|
27
|
+
const data = await res.json()
|
|
28
|
+
saveConfig({ apiKey: key })
|
|
29
|
+
console.log(` ✅\n`)
|
|
30
|
+
console.log(`🎉 登录成功!当前积分:${data.credits}`)
|
|
31
|
+
console.log(`\n快速开始:`)
|
|
32
|
+
console.log(` skillfree pilot --type chat --prompt "你好"`)
|
|
33
|
+
} catch (e) {
|
|
34
|
+
console.log(` ❌\nAPI Key 无效,请重新检查`)
|
|
35
|
+
process.exit(1)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function authStatus() {
|
|
40
|
+
const { getApiKey, BASE_URL } = require('../lib/client')
|
|
41
|
+
const key = getApiKey()
|
|
42
|
+
if (!key) {
|
|
43
|
+
console.log('未登录。运行: skillfree auth login')
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
const res = await fetch(`${BASE_URL}/v1/balance`, {
|
|
47
|
+
headers: { 'Authorization': `Bearer ${key}` }
|
|
48
|
+
})
|
|
49
|
+
if (!res.ok) {
|
|
50
|
+
console.log('API Key 已失效,请重新登录')
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
const data = await res.json()
|
|
54
|
+
console.log(`✅ 已登录`)
|
|
55
|
+
console.log(` 剩余积分:${data.credits}`)
|
|
56
|
+
console.log(` Key:${key.slice(0, 10)}****${key.slice(-4)}`)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = { authLogin, authStatus }
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const { run } = require('./run')
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Chat completion command
|
|
5
|
+
* @param {object} params - Chat parameters
|
|
6
|
+
* @param {string} params.model - Model in "vendor/model" format
|
|
7
|
+
* @param {string} [params.prompt] - Simple prompt (converted to messages)
|
|
8
|
+
* @param {Array} [params.messages] - Full messages array
|
|
9
|
+
* @param {string} [params.system] - System prompt
|
|
10
|
+
* @param {boolean} [params.stream] - Enable streaming
|
|
11
|
+
* @param {number} [params.maxTokens] - Max tokens
|
|
12
|
+
* @param {number} [params.temperature] - Temperature
|
|
13
|
+
* @returns {Promise<object|AsyncGenerator>} Chat response or stream
|
|
14
|
+
*/
|
|
15
|
+
async function chat(params) {
|
|
16
|
+
let messages = params.messages
|
|
17
|
+
if (!messages && params.prompt) {
|
|
18
|
+
messages = [{ role: 'user', content: params.prompt }]
|
|
19
|
+
}
|
|
20
|
+
if (!messages) {
|
|
21
|
+
throw new Error('Either --prompt or --messages is required')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const inputs = { messages }
|
|
25
|
+
if (params.system) inputs.system = params.system
|
|
26
|
+
if (params.maxTokens) inputs.max_tokens = params.maxTokens
|
|
27
|
+
if (params.temperature !== undefined) inputs.temperature = params.temperature
|
|
28
|
+
|
|
29
|
+
return run({ model: params.model, inputs, stream: params.stream })
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = { chat }
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
const { run } = require('./run')
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Image generation command
|
|
5
|
+
* @param {object} params - Image parameters
|
|
6
|
+
* @param {string} params.model - Model in "vendor/model" format
|
|
7
|
+
* @param {string} params.prompt - Image generation prompt
|
|
8
|
+
* @param {string} [params.size] - Image size (e.g., "1024x1024")
|
|
9
|
+
* @param {string} [params.output] - Output file path
|
|
10
|
+
* @returns {Promise<object>} Image generation result
|
|
11
|
+
*/
|
|
12
|
+
async function image(params) {
|
|
13
|
+
if (!params.prompt) {
|
|
14
|
+
throw new Error('--prompt is required for image generation')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const [vendor] = params.model.split('/')
|
|
18
|
+
const inputs = {}
|
|
19
|
+
|
|
20
|
+
if (vendor === 'vertex') {
|
|
21
|
+
inputs.messages = [{ role: 'user', content: params.prompt }]
|
|
22
|
+
} else if (vendor === 'mm') {
|
|
23
|
+
// MM uses prompt and size in "1024*1536" format
|
|
24
|
+
inputs.prompt = params.prompt
|
|
25
|
+
if (params.size) {
|
|
26
|
+
// Convert "1024x1536" to "1024*1536" if needed
|
|
27
|
+
inputs.size = params.size.replace('x', '*')
|
|
28
|
+
}
|
|
29
|
+
} else {
|
|
30
|
+
inputs.prompt = params.prompt
|
|
31
|
+
if (params.size) inputs.size = params.size
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return run({ model: params.model, inputs, output: params.output })
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Image upscale command (FAL creative-upscaler)
|
|
39
|
+
* @param {object} params - Upscale parameters
|
|
40
|
+
* @param {string} params.imageUrl - URL of image to upscale
|
|
41
|
+
* @param {number} [params.scale] - Upscale factor: 2 or 4 (default: 2)
|
|
42
|
+
* @param {string} [params.outputFormat] - "png" or "jpeg" (default: "png")
|
|
43
|
+
* @param {string} [params.output] - Output file path
|
|
44
|
+
* @returns {Promise<object>} Upscale result {image_url, images}
|
|
45
|
+
*/
|
|
46
|
+
async function upscale(params) {
|
|
47
|
+
if (!params.imageUrl) {
|
|
48
|
+
throw new Error('--image-url is required for upscale')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const inputs = {
|
|
52
|
+
image_url: params.imageUrl,
|
|
53
|
+
scale: params.scale || 2,
|
|
54
|
+
output_format: params.outputFormat || 'png',
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return run({ model: 'fal/upscale', inputs, output: params.output })
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Image-to-image transformation command (FAL FLUX dev)
|
|
62
|
+
* @param {object} params - img2img parameters
|
|
63
|
+
* @param {string} params.imageUrl - URL of source image
|
|
64
|
+
* @param {string} params.prompt - Transformation description
|
|
65
|
+
* @param {number} [params.strength] - Transform strength 0.0-1.0 (default: 0.75)
|
|
66
|
+
* @param {string} [params.imageSize] - Size preset: square_hd, square, portrait_4_3, landscape_16_9, etc. (default: square_hd)
|
|
67
|
+
* @param {string} [params.outputFormat] - "jpeg" or "png" (default: "jpeg")
|
|
68
|
+
* @param {number} [params.numImages] - Number of images 1-4 (default: 1)
|
|
69
|
+
* @param {string} [params.output] - Output file path
|
|
70
|
+
* @returns {Promise<object>} img2img result {image_url, images}
|
|
71
|
+
*/
|
|
72
|
+
async function img2img(params) {
|
|
73
|
+
if (!params.imageUrl) {
|
|
74
|
+
throw new Error('--image-url is required for img2img')
|
|
75
|
+
}
|
|
76
|
+
if (!params.prompt) {
|
|
77
|
+
throw new Error('--prompt is required for img2img')
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const inputs = {
|
|
81
|
+
image_url: params.imageUrl,
|
|
82
|
+
prompt: params.prompt,
|
|
83
|
+
strength: params.strength || 0.75,
|
|
84
|
+
image_size: params.imageSize || 'square_hd',
|
|
85
|
+
output_format: params.outputFormat || 'jpeg',
|
|
86
|
+
num_images: params.numImages || 1,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return run({ model: 'fal/img2img', inputs, output: params.output })
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = { image, upscale, img2img }
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const { apiHubGet } = require('../lib/client')
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* List available models from API Hub
|
|
5
|
+
* @param {object} [params] - List parameters
|
|
6
|
+
* @param {string} [params.type] - Filter by category (chat, tts, image, video, scraping, etc.)
|
|
7
|
+
* @param {string} [params.vendor] - Filter by vendor
|
|
8
|
+
* @returns {Promise<object>} Models list
|
|
9
|
+
*/
|
|
10
|
+
async function listModels(params = {}) {
|
|
11
|
+
const response = await apiHubGet('/v1/models')
|
|
12
|
+
let models = response.models || []
|
|
13
|
+
|
|
14
|
+
// Filter by category/type
|
|
15
|
+
if (params.type) {
|
|
16
|
+
const typeFilter = params.type.toLowerCase()
|
|
17
|
+
models = models.filter(m =>
|
|
18
|
+
m.category?.toLowerCase() === typeFilter ||
|
|
19
|
+
m.type?.toLowerCase() === typeFilter
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Filter by vendor
|
|
24
|
+
if (params.vendor) {
|
|
25
|
+
const vendorFilter = params.vendor.toLowerCase()
|
|
26
|
+
models = models.filter(m => m.vendor?.toLowerCase() === vendorFilter)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return { count: models.length, models }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = { listModels }
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
const { run } = require('./run')
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Music generation command
|
|
5
|
+
* @param {object} params - Music parameters
|
|
6
|
+
* @param {string} params.model - Model in "vendor/model" format
|
|
7
|
+
* @param {string} params.prompt - Music generation prompt
|
|
8
|
+
* @param {number} [params.duration] - Duration in seconds
|
|
9
|
+
* @param {string} [params.output] - Output file path
|
|
10
|
+
* @returns {Promise<object>} Music generation result
|
|
11
|
+
*/
|
|
12
|
+
async function music(params) {
|
|
13
|
+
if (!params.prompt) {
|
|
14
|
+
throw new Error('--prompt is required for music generation')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const inputs = {
|
|
18
|
+
prompt: params.prompt,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (params.duration) {
|
|
22
|
+
inputs.duration = parseInt(params.duration)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return run({ model: params.model, inputs, output: params.output })
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
module.exports = { music }
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
const { post, postStream, BASE_URL } = require('../lib/client')
|
|
2
|
+
const fs = require('fs')
|
|
3
|
+
const path = require('path')
|
|
4
|
+
|
|
5
|
+
async function pilot(flags) {
|
|
6
|
+
const type = flags.type || 'chat'
|
|
7
|
+
const prompt = flags.prompt || flags.text || ''
|
|
8
|
+
const output = flags.output || null
|
|
9
|
+
|
|
10
|
+
// 构造请求体
|
|
11
|
+
const body = { type, inputs: {} }
|
|
12
|
+
|
|
13
|
+
switch (type) {
|
|
14
|
+
case 'chat':
|
|
15
|
+
body.model = flags.model
|
|
16
|
+
body.inputs.messages = [{ role: 'user', content: prompt }]
|
|
17
|
+
break
|
|
18
|
+
case 'image':
|
|
19
|
+
body.model = flags.model
|
|
20
|
+
body.inputs.prompt = prompt
|
|
21
|
+
if (flags.size) body.inputs.size = flags.size
|
|
22
|
+
break
|
|
23
|
+
case 'tts':
|
|
24
|
+
body.model = flags.model
|
|
25
|
+
body.inputs.text = prompt
|
|
26
|
+
body.inputs.input = prompt
|
|
27
|
+
body.inputs.voice = flags.voice || 'nova'
|
|
28
|
+
break
|
|
29
|
+
case 'stt':
|
|
30
|
+
if (flags.file) {
|
|
31
|
+
body.inputs.audio_data = fs.readFileSync(path.resolve(flags.file)).toString('base64')
|
|
32
|
+
body.inputs.filename = path.basename(flags.file)
|
|
33
|
+
}
|
|
34
|
+
break
|
|
35
|
+
case 'video':
|
|
36
|
+
body.model = flags.model
|
|
37
|
+
body.inputs.prompt = prompt
|
|
38
|
+
body.inputs.duration = flags.duration ? parseInt(flags.duration) : 5
|
|
39
|
+
break
|
|
40
|
+
case 'music':
|
|
41
|
+
body.model = flags.model
|
|
42
|
+
body.inputs.prompt = prompt
|
|
43
|
+
break
|
|
44
|
+
default:
|
|
45
|
+
body.inputs.prompt = prompt
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 流式输出(chat 默认流式)
|
|
49
|
+
if (type === 'chat' && !output) {
|
|
50
|
+
const res = await postStream('/chat/completions', {
|
|
51
|
+
model: flags.model || 'DeepSeek-V3.2-Fast',
|
|
52
|
+
messages: [{ role: 'user', content: prompt }],
|
|
53
|
+
stream: true,
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
const reader = res.body.getReader()
|
|
57
|
+
const decoder = new TextDecoder()
|
|
58
|
+
let buf = ''
|
|
59
|
+
process.stdout.write('\n')
|
|
60
|
+
while (true) {
|
|
61
|
+
const { done, value } = await reader.read()
|
|
62
|
+
if (done) break
|
|
63
|
+
buf += decoder.decode(value, { stream: true })
|
|
64
|
+
const lines = buf.split('\n')
|
|
65
|
+
buf = lines.pop()
|
|
66
|
+
for (const line of lines) {
|
|
67
|
+
if (line.startsWith('data: ')) {
|
|
68
|
+
const chunk = line.slice(6)
|
|
69
|
+
if (chunk === '[DONE]') { process.stdout.write('\n'); return }
|
|
70
|
+
try {
|
|
71
|
+
const d = JSON.parse(chunk)
|
|
72
|
+
const text = d.choices?.[0]?.delta?.content
|
|
73
|
+
if (text) process.stdout.write(text)
|
|
74
|
+
} catch {}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 非流式
|
|
82
|
+
const result = await post('/pilot', body)
|
|
83
|
+
|
|
84
|
+
// 如果有媒体 URL 且指定了输出文件,下载保存
|
|
85
|
+
const mediaUrl = result.image_url || result.video_url || result.audio_url
|
|
86
|
+
|| result.data?.[0]?.url || result.data?.[0]
|
|
87
|
+
if (output && mediaUrl) {
|
|
88
|
+
const resp = await fetch(typeof mediaUrl === 'string' ? mediaUrl : mediaUrl.url)
|
|
89
|
+
fs.writeFileSync(output, Buffer.from(await resp.arrayBuffer()))
|
|
90
|
+
console.log(`✅ 已保存到 ${output}`)
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 文本输出
|
|
95
|
+
const text = result.choices?.[0]?.message?.content
|
|
96
|
+
|| result.text || result.transcript || JSON.stringify(result, null, 2)
|
|
97
|
+
console.log(text)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = { pilot }
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
const fs = require('fs')
|
|
2
|
+
const path = require('path')
|
|
3
|
+
const { run } = require('./run')
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Speech-to-text command
|
|
7
|
+
* @param {object} params - STT parameters
|
|
8
|
+
* @param {string} params.file - Local audio file path
|
|
9
|
+
* @param {string} [params.model] - Model (default: openai/whisper-1)
|
|
10
|
+
* @param {string} [params.prompt] - Optional prompt to guide transcription style
|
|
11
|
+
* @param {string} [params.language] - Optional language code (e.g., "en")
|
|
12
|
+
* @param {string} [params.output] - Optional output file path for transcript
|
|
13
|
+
* @returns {Promise<object>} STT result with transcribed text
|
|
14
|
+
*/
|
|
15
|
+
async function stt(params) {
|
|
16
|
+
if (!params.file) {
|
|
17
|
+
throw new Error('--file is required for STT (local audio file path)')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const filePath = path.resolve(params.file)
|
|
21
|
+
if (!fs.existsSync(filePath)) {
|
|
22
|
+
throw new Error(`Audio file not found: ${filePath}`)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const audioData = fs.readFileSync(filePath).toString('base64')
|
|
26
|
+
const filename = path.basename(filePath)
|
|
27
|
+
|
|
28
|
+
const inputs = {
|
|
29
|
+
audio_data: audioData,
|
|
30
|
+
filename,
|
|
31
|
+
}
|
|
32
|
+
if (params.prompt) inputs.prompt = params.prompt
|
|
33
|
+
if (params.language) inputs.language = params.language
|
|
34
|
+
|
|
35
|
+
const model = params.model || 'openai/whisper-1'
|
|
36
|
+
const result = await run({ model, inputs })
|
|
37
|
+
|
|
38
|
+
const text = result.text || JSON.stringify(result)
|
|
39
|
+
|
|
40
|
+
if (params.output) {
|
|
41
|
+
fs.writeFileSync(params.output, text)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return { text, ...(params.output ? { saved: params.output } : {}) }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = { stt }
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
const { run } = require('./run')
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Text-to-speech command
|
|
5
|
+
* @param {object} params - TTS parameters
|
|
6
|
+
* @param {string} params.model - Model in "vendor/model" format
|
|
7
|
+
* @param {string} params.text - Text to synthesize
|
|
8
|
+
* @param {string} [params.voiceId] - Voice ID (provider-specific)
|
|
9
|
+
* @param {string} params.output - Output audio file path
|
|
10
|
+
* @returns {Promise<object>} TTS result
|
|
11
|
+
*/
|
|
12
|
+
async function tts(params) {
|
|
13
|
+
if (!params.text) {
|
|
14
|
+
throw new Error('--text is required for TTS')
|
|
15
|
+
}
|
|
16
|
+
if (!params.output) {
|
|
17
|
+
throw new Error('--output is required for TTS')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const inputs = {}
|
|
21
|
+
|
|
22
|
+
// Provider-specific input mapping
|
|
23
|
+
const [vendor] = params.model.split('/')
|
|
24
|
+
if (vendor === 'elevenlabs') {
|
|
25
|
+
// ElevenLabs uses 'text' and requires voice_id - default to "Rachel"
|
|
26
|
+
inputs.text = params.text
|
|
27
|
+
inputs.voice_id = params.voiceId || 'EXAVITQu4vr4xnSDxMaL'
|
|
28
|
+
} else if (vendor === 'minimax') {
|
|
29
|
+
// MiniMax uses 'text' and 'voice_setting' object
|
|
30
|
+
inputs.text = params.text
|
|
31
|
+
inputs.voice_setting = {
|
|
32
|
+
voice_id: params.voiceId || 'male-qn-qingse',
|
|
33
|
+
speed: 1.0,
|
|
34
|
+
vol: 1.0,
|
|
35
|
+
pitch: 0,
|
|
36
|
+
}
|
|
37
|
+
} else if (vendor === 'openai') {
|
|
38
|
+
// OpenAI TTS uses 'input' and 'voice'
|
|
39
|
+
inputs.input = params.text
|
|
40
|
+
inputs.voice = params.voiceId || 'alloy'
|
|
41
|
+
} else if (vendor === 'replicate') {
|
|
42
|
+
// Replicate XTTS uses 'text' and requires 'speaker' (audio URL for voice cloning)
|
|
43
|
+
inputs.text = params.text
|
|
44
|
+
if (params.speaker) {
|
|
45
|
+
inputs.speaker = params.speaker
|
|
46
|
+
} else if (params.voiceId) {
|
|
47
|
+
inputs.speaker = params.voiceId
|
|
48
|
+
} else {
|
|
49
|
+
// Default speaker sample
|
|
50
|
+
inputs.speaker =
|
|
51
|
+
'https://replicate.delivery/pbxt/Jt79w0xsT64R1JsiJ0LQRL8UcWspg5J4RFrU6YwEKpOT1ukS/male.wav'
|
|
52
|
+
}
|
|
53
|
+
} else if (vendor === 'mm') {
|
|
54
|
+
// MM TTS (qwen3-tts-flash) uses 'text' and optional 'voice'
|
|
55
|
+
inputs.text = params.text
|
|
56
|
+
if (params.voiceId) {
|
|
57
|
+
inputs.voice = params.voiceId
|
|
58
|
+
}
|
|
59
|
+
} else {
|
|
60
|
+
// Default: use 'text'
|
|
61
|
+
inputs.text = params.text
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return run({ model: params.model, inputs, output: params.output })
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = { tts }
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
const { run } = require('./run')
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Video generation command
|
|
5
|
+
* @param {object} params - Video parameters
|
|
6
|
+
* @param {string} params.model - Model in "vendor/model" format
|
|
7
|
+
* @param {string} params.prompt - Video generation prompt
|
|
8
|
+
* @param {string} [params.output] - Output file path
|
|
9
|
+
* @returns {Promise<object>} Video generation result
|
|
10
|
+
*/
|
|
11
|
+
async function video(params) {
|
|
12
|
+
if (!params.prompt) {
|
|
13
|
+
throw new Error('--prompt is required for video generation')
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const [vendor] = params.model.split('/')
|
|
17
|
+
const inputs = {}
|
|
18
|
+
|
|
19
|
+
if (vendor === 'vertex') {
|
|
20
|
+
// Vertex/Veo uses instances array format
|
|
21
|
+
inputs.instances = [{ prompt: params.prompt }]
|
|
22
|
+
inputs.parameters = {}
|
|
23
|
+
} else if (vendor === 'mm') {
|
|
24
|
+
// MM video models: t2v (text-to-video), i2v (image-to-video)
|
|
25
|
+
inputs.prompt = params.prompt
|
|
26
|
+
if (params.size) {
|
|
27
|
+
// Convert "1280x720" to "1280*720" if needed
|
|
28
|
+
inputs.size = params.size.replace('x', '*')
|
|
29
|
+
}
|
|
30
|
+
if (params.duration) {
|
|
31
|
+
inputs.duration = parseInt(params.duration)
|
|
32
|
+
}
|
|
33
|
+
if (params.image) {
|
|
34
|
+
// i2v mode: image-to-video
|
|
35
|
+
inputs.image_url = params.image
|
|
36
|
+
}
|
|
37
|
+
} else {
|
|
38
|
+
// MiniMax and others use 'prompt'
|
|
39
|
+
inputs.prompt = params.prompt
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return run({ model: params.model, inputs, output: params.output })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Multimodal understanding command (video/image/audio analysis)
|
|
47
|
+
* @param {object} params - Multimodal parameters
|
|
48
|
+
* @param {string} params.model - Model in "vendor/model" format (e.g., mm/qwen3-vl-plus)
|
|
49
|
+
* @param {string} params.prompt - Text prompt/question about the media
|
|
50
|
+
* @param {string} [params.video] - Video URL to analyze
|
|
51
|
+
* @param {string} [params.image] - Image URL to analyze
|
|
52
|
+
* @param {string} [params.audio] - Audio URL to analyze/transcribe
|
|
53
|
+
* @returns {Promise<object>} Multimodal analysis result
|
|
54
|
+
*/
|
|
55
|
+
async function multimodal(params) {
|
|
56
|
+
if (!params.prompt) {
|
|
57
|
+
throw new Error('--prompt is required for multimodal')
|
|
58
|
+
}
|
|
59
|
+
if (!params.video && !params.image && !params.audio) {
|
|
60
|
+
throw new Error('At least one of --video, --image, or --audio is required')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const [vendor] = params.model.split('/')
|
|
64
|
+
const inputs = {}
|
|
65
|
+
|
|
66
|
+
if (vendor === 'mm') {
|
|
67
|
+
// MM multimodal models use messages format
|
|
68
|
+
const content = []
|
|
69
|
+
if (params.video) {
|
|
70
|
+
content.push({ video: params.video })
|
|
71
|
+
if (params.fps) {
|
|
72
|
+
content[content.length - 1].fps = parseInt(params.fps)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (params.image) {
|
|
76
|
+
content.push({ image: params.image })
|
|
77
|
+
}
|
|
78
|
+
if (params.audio) {
|
|
79
|
+
content.push({ audio: params.audio })
|
|
80
|
+
}
|
|
81
|
+
content.push({ text: params.prompt })
|
|
82
|
+
|
|
83
|
+
inputs.input = {
|
|
84
|
+
messages: [{ role: 'user', content }]
|
|
85
|
+
}
|
|
86
|
+
} else {
|
|
87
|
+
// Generic format
|
|
88
|
+
inputs.prompt = params.prompt
|
|
89
|
+
if (params.video) inputs.video_url = params.video
|
|
90
|
+
if (params.image) inputs.image_url = params.image
|
|
91
|
+
if (params.audio) inputs.audio_url = params.audio
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return run({ model: params.model, inputs })
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = { video, multimodal }
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
const fs = require('fs')
|
|
2
|
+
const path = require('path')
|
|
3
|
+
const os = require('os')
|
|
4
|
+
|
|
5
|
+
// ─── 配置路径 ─────────────────────────────────────────────────────────────────
|
|
6
|
+
// API Key 存储在 ~/.skillfree/config.json
|
|
7
|
+
const CONFIG_DIR = path.join(os.homedir(), '.skillfree')
|
|
8
|
+
const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json')
|
|
9
|
+
|
|
10
|
+
const BASE_URL = 'https://skillfree.tech'
|
|
11
|
+
const API_BASE = `${BASE_URL}/v1`
|
|
12
|
+
|
|
13
|
+
// ─── 读写配置 ─────────────────────────────────────────────────────────────────
|
|
14
|
+
function loadConfig() {
|
|
15
|
+
try {
|
|
16
|
+
if (fs.existsSync(CONFIG_PATH)) {
|
|
17
|
+
return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'))
|
|
18
|
+
}
|
|
19
|
+
} catch {}
|
|
20
|
+
return {}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function saveConfig(cfg) {
|
|
24
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true })
|
|
25
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + '\n')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let _config = loadConfig()
|
|
29
|
+
|
|
30
|
+
function getApiKey() {
|
|
31
|
+
return _config.apiKey || process.env.SKILLFREE_API_KEY || ''
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ─── HTTP 工具 ────────────────────────────────────────────────────────────────
|
|
35
|
+
async function request(endpoint, options = {}) {
|
|
36
|
+
const apiKey = getApiKey()
|
|
37
|
+
if (!apiKey) {
|
|
38
|
+
throw new Error('未登录,请先运行: skillfree auth login')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const url = `${API_BASE}${endpoint}`
|
|
42
|
+
const res = await fetch(url, {
|
|
43
|
+
...options,
|
|
44
|
+
headers: {
|
|
45
|
+
'Content-Type': 'application/json',
|
|
46
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
47
|
+
...(options.headers || {}),
|
|
48
|
+
},
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
if (res.status === 402) throw new Error('积分不足,请前往 https://skillfree.tech/app 充值')
|
|
52
|
+
if (res.status === 401) throw new Error('API Key 无效或已过期,请重新登录: skillfree auth login')
|
|
53
|
+
|
|
54
|
+
return res
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function post(endpoint, body) {
|
|
58
|
+
const res = await request(endpoint, {
|
|
59
|
+
method: 'POST',
|
|
60
|
+
body: JSON.stringify(body),
|
|
61
|
+
})
|
|
62
|
+
return res.json()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function postStream(endpoint, body) {
|
|
66
|
+
const res = await request(endpoint, {
|
|
67
|
+
method: 'POST',
|
|
68
|
+
headers: { 'Accept': 'text/event-stream' },
|
|
69
|
+
body: JSON.stringify({ ...body, stream: true }),
|
|
70
|
+
})
|
|
71
|
+
return res
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function get(endpoint) {
|
|
75
|
+
const res = await request(endpoint, { method: 'GET' })
|
|
76
|
+
return res.json()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ─── 导出 ─────────────────────────────────────────────────────────────────────
|
|
80
|
+
module.exports = {
|
|
81
|
+
BASE_URL,
|
|
82
|
+
API_BASE,
|
|
83
|
+
loadConfig,
|
|
84
|
+
saveConfig,
|
|
85
|
+
getApiKey,
|
|
86
|
+
get _config() { return _config },
|
|
87
|
+
set _config(v) { _config = v },
|
|
88
|
+
request,
|
|
89
|
+
post,
|
|
90
|
+
postStream,
|
|
91
|
+
get,
|
|
92
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared fetch with retry logic for network errors and rate limits
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const MAX_RETRIES = 3
|
|
6
|
+
const INITIAL_DELAY = 5000 // 5 seconds
|
|
7
|
+
|
|
8
|
+
function sleep(ms) {
|
|
9
|
+
return new Promise(resolve => setTimeout(resolve, ms))
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Fetch with automatic retry for network errors and rate limits (429)
|
|
14
|
+
*
|
|
15
|
+
* Design principles:
|
|
16
|
+
* - No HTTP timeout: Let server control (video generation can take 10+ minutes)
|
|
17
|
+
* - Retry on: Network errors (TypeError: fetch failed), 429 rate limit
|
|
18
|
+
* - Don't retry: 4xx client errors (except 429), 5xx server errors (server has retry)
|
|
19
|
+
*
|
|
20
|
+
* @param {string} url - The URL to fetch
|
|
21
|
+
* @param {object} options - Fetch options
|
|
22
|
+
* @param {number} maxRetries - Maximum number of retries (default: 3)
|
|
23
|
+
* @returns {Promise<Response>} The fetch response
|
|
24
|
+
*/
|
|
25
|
+
async function fetchWithRetry(url, options = {}, maxRetries = MAX_RETRIES) {
|
|
26
|
+
let lastError
|
|
27
|
+
|
|
28
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
29
|
+
try {
|
|
30
|
+
const response = await fetch(url, options)
|
|
31
|
+
|
|
32
|
+
// Handle rate limit (429)
|
|
33
|
+
if (response.status === 429) {
|
|
34
|
+
if (attempt >= maxRetries) {
|
|
35
|
+
return response // Return the 429 response on final attempt
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const retryAfter = response.headers.get('Retry-After')
|
|
39
|
+
const delay = retryAfter ? parseInt(retryAfter) * 1000 : INITIAL_DELAY * attempt
|
|
40
|
+
console.log(`Rate limited. Waiting ${delay / 1000}s before retry ${attempt}/${maxRetries}...`)
|
|
41
|
+
await sleep(delay)
|
|
42
|
+
continue
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Return response for all other status codes (let caller handle errors)
|
|
46
|
+
return response
|
|
47
|
+
} catch (error) {
|
|
48
|
+
lastError = error
|
|
49
|
+
|
|
50
|
+
// Only retry on network errors (TypeError: fetch failed)
|
|
51
|
+
if (attempt < maxRetries) {
|
|
52
|
+
const delay = INITIAL_DELAY * attempt
|
|
53
|
+
console.log(`Network error: ${error.message}. Retry ${attempt}/${maxRetries} in ${delay / 1000}s...`)
|
|
54
|
+
await sleep(delay)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
throw lastError
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = { fetchWithRetry, sleep }
|