feat(portable): 预装国内渠道+本地模型自动发现+4个新技能
- release.yml 便携包离线预装 钉钉/飞书中国版/企业微信 插件(借鉴 openclaw-china 生态) - config-server 新增 /api/local-models:自动探测 Ollama/LM Studio 本地模型(借鉴 RealShocky/openclaw-windows) - Config.html 检测到本地模型自动生成卡片、免 Key 点选即用;新增钉钉渠道卡片 - skills-cn 新增 pdf-toolkit/web-to-markdown/qrcode-maker/image-compress(13→17) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -237,6 +237,13 @@ input:focus { border-color: #ff6b35; }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 本地模型自动发现(Ollama / LM Studio)-->
|
||||
<div id="localModelsBox" style="display:none; margin-top:6px;">
|
||||
<h3 style="color:#4caf50; font-size:0.95em; margin:12px 0 4px;">💻 检测到本机本地模型 <span class="tag free">离线·免费</span></h3>
|
||||
<p class="desc" style="margin-bottom:10px;">无需 API Key,纯本地推理。点选即用:</p>
|
||||
<div class="model-grid" id="localModelGrid"></div>
|
||||
</div>
|
||||
|
||||
<div class="btn-row">
|
||||
<button class="btn btn-primary" id="nextStep1" disabled>下一步 →</button>
|
||||
</div>
|
||||
@@ -332,6 +339,21 @@ input:focus { border-color: #ff6b35; }
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="model-card channel-card" onclick="toggleChannel(this, 'dingtalk')">
|
||||
<h4>📲 钉钉 <span class="tag cn">国内·最易</span></h4>
|
||||
<p>WebSocket 接入,免公网 IP</p>
|
||||
<div class="channel-form" id="form-dingtalk" onclick="event.stopPropagation()">
|
||||
<div class="form-group">
|
||||
<label>Client ID (AppKey)</label>
|
||||
<input type="text" id="ch-dingtalk-clientid" placeholder="dingxxx...">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Client Secret (AppSecret)</label>
|
||||
<input type="text" id="ch-dingtalk-secret" placeholder="应用密钥">
|
||||
<p class="hint"><a href="https://open-dev.dingtalk.com" target="_blank">→ 从钉钉开放平台创建机器人应用</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="model-card channel-card" onclick="toggleChannel(this, 'wecom')">
|
||||
<h4>🏢 企业微信</h4>
|
||||
<p>接入企业微信机器人</p>
|
||||
@@ -438,10 +460,15 @@ function setupStep2() {
|
||||
'uclaw-cloud': '虾盘云', minimax: 'MiniMax', kimi: 'Kimi (月之暗面)', deepseek: 'DeepSeek',
|
||||
zai: '智谱 GLM', qwen: '通义千问', doubao: '豆包',
|
||||
openai: 'OpenAI', anthropic: 'Anthropic Claude', groq: 'Groq',
|
||||
siliconflow: '硅基流动', custom: '自定义'
|
||||
siliconflow: '硅基流动', custom: '自定义',
|
||||
ollama: 'Ollama(本地)', lmstudio: 'LM Studio(本地)'
|
||||
};
|
||||
const name = providerNames[selectedProvider] || selectedProvider;
|
||||
document.getElementById('step2desc').textContent = `请填写 ${name} 的 API Key`;
|
||||
document.getElementById('step2desc').textContent = selectedLocal
|
||||
? `${name} 是本地模型,无需 API Key,直接点「保存并启动」即可`
|
||||
: `请填写 ${name} 的 API Key`;
|
||||
// 本地模型隐藏 Key 获取信息框
|
||||
document.getElementById('buyInfo').style.display = selectedLocal ? 'none' : 'block';
|
||||
|
||||
// Buy links
|
||||
const card = document.querySelector(`#step1 .model-card[data-provider="${selectedProvider}"]`);
|
||||
@@ -514,8 +541,13 @@ function buildOpenClawConfig(provider, base, model, apiKey) {
|
||||
|
||||
// --- Save config ---
|
||||
async function saveConfig() {
|
||||
const apiKey = document.getElementById('apiKey').value.trim();
|
||||
if (!apiKey) { showToast('请填写 API Key', true); return; }
|
||||
let apiKey = document.getElementById('apiKey').value.trim();
|
||||
// 本地模型(Ollama/LM Studio)不需要真实 Key,OpenClaw 配置结构里仍要有 apiKey 字段
|
||||
if (selectedLocal) {
|
||||
if (!apiKey) apiKey = 'local';
|
||||
} else if (!apiKey) {
|
||||
showToast('请填写 API Key', true); return;
|
||||
}
|
||||
|
||||
let base = selectedBase;
|
||||
let model = selectedModel;
|
||||
@@ -582,6 +614,52 @@ async function loadConfig() {
|
||||
}
|
||||
loadConfig();
|
||||
|
||||
// --- 本地模型自动发现(Ollama / LM Studio)---
|
||||
// 探测 /api/local-models,发现本机已运行的本地模型就动态生成卡片,点选即用、无需 Key。
|
||||
async function discoverLocalModels() {
|
||||
try {
|
||||
const res = await fetch('/api/local-models');
|
||||
if (!res.ok) return;
|
||||
const { providers } = await res.json();
|
||||
if (!providers || !providers.length) return;
|
||||
|
||||
const grid = document.getElementById('localModelGrid');
|
||||
grid.innerHTML = '';
|
||||
providers.forEach(p => {
|
||||
// 每个本地 provider 最多展示前 6 个模型,避免列表过长
|
||||
p.models.slice(0, 6).forEach(modelId => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'model-card';
|
||||
card.dataset.provider = p.provider; // ollama / lmstudio
|
||||
card.dataset.base = p.base;
|
||||
card.dataset.model = modelId;
|
||||
card.dataset.local = '1'; // 标记:本地模型免 Key
|
||||
card.innerHTML = `
|
||||
<span class="check">✓</span>
|
||||
<h4>${p.label} <span class="tag free">本地</span></h4>
|
||||
<p>${modelId}</p>`;
|
||||
card.addEventListener('click', () => {
|
||||
document.querySelectorAll('.model-card').forEach(c => c.classList.remove('selected'));
|
||||
card.classList.add('selected');
|
||||
selectedProvider = p.provider;
|
||||
selectedBase = p.base;
|
||||
selectedModel = modelId;
|
||||
selectedLocal = true;
|
||||
document.getElementById('nextStep1').disabled = false;
|
||||
});
|
||||
grid.appendChild(card);
|
||||
});
|
||||
});
|
||||
document.getElementById('localModelsBox').style.display = 'block';
|
||||
} catch { /* 无本地模型或探测失败:静默 */ }
|
||||
}
|
||||
let selectedLocal = false;
|
||||
// 选云端卡片时重置本地标记
|
||||
document.querySelectorAll('#step1 > .model-grid .model-card').forEach(c => {
|
||||
c.addEventListener('click', () => { selectedLocal = false; });
|
||||
});
|
||||
discoverLocalModels();
|
||||
|
||||
// Allow direct step preview via URL param, e.g. ?step=3
|
||||
const stepParam = new URLSearchParams(window.location.search).get('step');
|
||||
if (stepParam) goStep(parseInt(stepParam));
|
||||
@@ -641,6 +719,10 @@ async function launchOpenClaw() {
|
||||
const wcSecret = document.getElementById('ch-wecom-secret')?.value.trim();
|
||||
if (wcBotId && wcSecret) channels.wecom = { enabled: true, botId: wcBotId, secret: wcSecret };
|
||||
|
||||
const ddId = document.getElementById('ch-dingtalk-clientid')?.value.trim();
|
||||
const ddSecret = document.getElementById('ch-dingtalk-secret')?.value.trim();
|
||||
if (ddId && ddSecret) channels.dingtalk = { enabled: true, clientId: ddId, clientSecret: ddSecret };
|
||||
|
||||
// 2. Save channel config if any (merge into existing config)
|
||||
if (Object.keys(channels).length > 0) {
|
||||
try {
|
||||
|
||||
@@ -443,6 +443,40 @@ const server = http.createServer((req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// API: Discover local models (Ollama / LM Studio)
|
||||
// 借鉴 RealShocky/openclaw-windows:自动探测本机已装的本地模型,
|
||||
// 用户无需手填 baseUrl/模型名,直接点选即可(便携版纯离线推理卖点)。
|
||||
// 静默失败:探测不到就返回空数组,不影响 Config 页面。
|
||||
if (req.url === '/api/local-models' && req.method === 'GET') {
|
||||
(async () => {
|
||||
const probes = [
|
||||
{ provider: 'ollama', label: 'Ollama', base: 'http://127.0.0.1:11434/v1', api: 'http://127.0.0.1:11434/api/tags' },
|
||||
{ provider: 'lmstudio', label: 'LM Studio', base: 'http://127.0.0.1:1234/v1', api: 'http://127.0.0.1:1234/v1/models' },
|
||||
];
|
||||
const found = [];
|
||||
await Promise.all(probes.map(async (p) => {
|
||||
try {
|
||||
const ctrl = new AbortController();
|
||||
const t = setTimeout(() => ctrl.abort(), 1200);
|
||||
const r = await fetch(p.api, { signal: ctrl.signal });
|
||||
clearTimeout(t);
|
||||
if (!r.ok) return;
|
||||
const data = await r.json();
|
||||
// Ollama: { models:[{name}] } | LM Studio (OpenAI-style): { data:[{id}] }
|
||||
const models = Array.isArray(data.models)
|
||||
? data.models.map(m => m.name).filter(Boolean)
|
||||
: Array.isArray(data.data)
|
||||
? data.data.map(m => m.id).filter(Boolean)
|
||||
: [];
|
||||
if (models.length) found.push({ provider: p.provider, label: p.label, base: p.base, models });
|
||||
} catch { /* 探测失败:该 provider 未运行,跳过 */ }
|
||||
}));
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ providers: found }));
|
||||
})();
|
||||
return;
|
||||
}
|
||||
|
||||
// API: Save config
|
||||
if (req.url === '/api/config' && req.method === 'POST') {
|
||||
let body = '';
|
||||
|
||||
50
portable/skills-cn/image-compress/SKILL.md
Normal file
50
portable/skills-cn/image-compress/SKILL.md
Normal file
@@ -0,0 +1,50 @@
|
||||
---
|
||||
name: image-compress
|
||||
description: "图片压缩/转换 - 批量压缩、改尺寸、转格式(jpg/png/webp),本地处理不失隐私"
|
||||
metadata: { "openclaw": { "emoji": "🖼️" } }
|
||||
---
|
||||
|
||||
# 图片压缩 / 转换
|
||||
|
||||
帮用户压缩图片体积、调整尺寸、转换格式,全部本地处理,照片不外传。
|
||||
|
||||
## 能力概述
|
||||
|
||||
- **压缩**:降低体积(控制质量/分辨率),适合发邮件、传微信
|
||||
- **改尺寸**:按宽高或百分比缩放
|
||||
- **转格式**:jpg / png / webp 互转(webp 体积最小)
|
||||
- **批量**:处理整个文件夹
|
||||
|
||||
## 操作方式
|
||||
|
||||
用 Bash 工具,Python 的 Pillow 库(跨平台、纯本地):
|
||||
|
||||
```bash
|
||||
python -c "import PIL" 2>/dev/null || pip install -q Pillow
|
||||
|
||||
# 单张压缩(质量 75,宽度限制 1600px 等比缩放)
|
||||
python - <<'PY'
|
||||
from PIL import Image
|
||||
im = Image.open("input.jpg")
|
||||
if im.width > 1600:
|
||||
im = im.resize((1600, int(im.height*1600/im.width)))
|
||||
im.convert("RGB").save("output.jpg", "JPEG", quality=75, optimize=True)
|
||||
print("已压缩 -> output.jpg")
|
||||
PY
|
||||
|
||||
# 批量:当前目录所有 jpg/png -> webp(体积更小)
|
||||
python - <<'PY'
|
||||
from PIL import Image
|
||||
import glob, os
|
||||
for f in glob.glob("*.jpg") + glob.glob("*.png"):
|
||||
out = os.path.splitext(f)[0] + ".webp"
|
||||
Image.open(f).save(out, "WEBP", quality=80)
|
||||
print(f"{f} -> {out}")
|
||||
PY
|
||||
```
|
||||
|
||||
## 使用建议
|
||||
|
||||
- 处理前 `ls -lh` 看原图体积,处理后再 `ls -lh` 对比,告诉用户压缩了多少
|
||||
- 默认不覆盖原图(输出新文件名),除非用户明确要求覆盖
|
||||
- 含透明通道的 PNG 转 JPG 会丢透明,转 webp 可保留——按需选格式
|
||||
59
portable/skills-cn/pdf-toolkit/SKILL.md
Normal file
59
portable/skills-cn/pdf-toolkit/SKILL.md
Normal file
@@ -0,0 +1,59 @@
|
||||
---
|
||||
name: pdf-toolkit
|
||||
description: "PDF 工具箱 - 合并/拆分/提取文字/转图片,纯命令行处理本地 PDF"
|
||||
metadata: { "openclaw": { "emoji": "📄" } }
|
||||
---
|
||||
|
||||
# PDF 工具箱
|
||||
|
||||
帮用户对本地 PDF 文件做常见处理:合并、拆分、提取文字、转图片。优先用系统已有工具,
|
||||
没有就用 Python 的 pypdf(轻量纯 Python,必要时 `pip install pypdf` 自动安装)。
|
||||
|
||||
## 能力概述
|
||||
|
||||
- **合并**:把多个 PDF 拼成一个
|
||||
- **拆分**:按页拆成多份,或抽取指定页
|
||||
- **提取文字**:把 PDF 内容导出成纯文本,便于总结/检索
|
||||
- **页数/信息**:查看 PDF 总页数、元信息
|
||||
|
||||
## 操作方式
|
||||
|
||||
用 Bash 工具执行。下面以 pypdf 为例(跨平台、无需 Office/Acrobat):
|
||||
|
||||
```bash
|
||||
# 确保依赖(仅首次,已装会秒过)
|
||||
python -c "import pypdf" 2>/dev/null || pip install -q pypdf
|
||||
|
||||
# 合并 a.pdf b.pdf -> merged.pdf
|
||||
python - <<'PY'
|
||||
from pypdf import PdfWriter
|
||||
w = PdfWriter()
|
||||
for f in ["a.pdf", "b.pdf"]:
|
||||
w.append(f)
|
||||
w.write("merged.pdf"); w.close()
|
||||
print("已合并 -> merged.pdf")
|
||||
PY
|
||||
|
||||
# 提取全部文字
|
||||
python - <<'PY'
|
||||
from pypdf import PdfReader
|
||||
r = PdfReader("input.pdf")
|
||||
print("\n".join((p.extract_text() or "") for p in r.pages))
|
||||
PY
|
||||
|
||||
# 抽取第 1-3 页 -> sub.pdf
|
||||
python - <<'PY'
|
||||
from pypdf import PdfReader, PdfWriter
|
||||
r = PdfReader("input.pdf"); w = PdfWriter()
|
||||
for i in range(0, 3):
|
||||
w.add_page(r.pages[i])
|
||||
w.write("sub.pdf"); w.close()
|
||||
print("已抽取 1-3 页 -> sub.pdf")
|
||||
PY
|
||||
```
|
||||
|
||||
## 使用建议
|
||||
|
||||
- 处理前先 `ls` 确认文件存在、用 `python -c "from pypdf import PdfReader;print(len(PdfReader('x.pdf').pages))"` 看页数
|
||||
- 扫描件(图片型 PDF)提取不到文字属正常,需 OCR(提示用户)
|
||||
- 输出文件默认放在与源文件同目录,操作完告诉用户生成的文件名
|
||||
52
portable/skills-cn/qrcode-maker/SKILL.md
Normal file
52
portable/skills-cn/qrcode-maker/SKILL.md
Normal file
@@ -0,0 +1,52 @@
|
||||
---
|
||||
name: qrcode-maker
|
||||
description: "二维码生成 - 把网址/文本/WiFi 信息生成二维码图片,本地离线生成"
|
||||
metadata: { "openclaw": { "emoji": "🔳" } }
|
||||
---
|
||||
|
||||
# 二维码生成
|
||||
|
||||
帮用户把网址、文本、联系方式、WiFi 信息生成二维码图片,本地离线生成,不上传任何数据。
|
||||
|
||||
## 能力概述
|
||||
|
||||
- **网址/文本二维码**:任意内容转二维码 PNG
|
||||
- **WiFi 二维码**:扫码即连,免手输密码
|
||||
- **终端预览**:直接在对话里用字符画展示二维码
|
||||
|
||||
## 操作方式
|
||||
|
||||
用 Bash 工具,Python 的 qrcode 库(轻量、纯本地):
|
||||
|
||||
```bash
|
||||
python -c "import qrcode" 2>/dev/null || pip install -q "qrcode[pil]"
|
||||
|
||||
# 网址/文本 -> PNG
|
||||
python - <<'PY'
|
||||
import qrcode
|
||||
qrcode.make("https://u-claw.org").save("qrcode.png")
|
||||
print("已生成 -> qrcode.png")
|
||||
PY
|
||||
|
||||
# WiFi 二维码(扫码自动连网)
|
||||
python - <<'PY'
|
||||
import qrcode
|
||||
ssid, pwd, enc = "MyWiFi", "password123", "WPA" # enc: WPA / WEP / nopass
|
||||
data = f"WIFI:T:{enc};S:{ssid};P:{pwd};;"
|
||||
qrcode.make(data).save("wifi-qr.png")
|
||||
print("已生成 WiFi 二维码 -> wifi-qr.png")
|
||||
PY
|
||||
|
||||
# 终端字符画预览(不存文件,直接看)
|
||||
python - <<'PY'
|
||||
import qrcode
|
||||
q = qrcode.QRCode(); q.add_data("https://u-claw.org"); q.make()
|
||||
q.print_ascii(invert=True)
|
||||
PY
|
||||
```
|
||||
|
||||
## 使用建议
|
||||
|
||||
- 先确认用户要编码的内容、是否保存为文件
|
||||
- 生成后用 Read 工具把 PNG 展示给用户看
|
||||
- 内容过长(如整段文字)二维码会很密集,提示用户改用短链
|
||||
51
portable/skills-cn/web-to-markdown/SKILL.md
Normal file
51
portable/skills-cn/web-to-markdown/SKILL.md
Normal file
@@ -0,0 +1,51 @@
|
||||
---
|
||||
name: web-to-markdown
|
||||
description: "网页转 Markdown - 抓取任意网页正文并转成干净的 Markdown,便于阅读/收藏/二次创作"
|
||||
metadata: { "openclaw": { "emoji": "🔗" } }
|
||||
---
|
||||
|
||||
# 网页转 Markdown
|
||||
|
||||
把用户给的网页链接抓下来,提取正文,转成干净的 Markdown,方便保存、总结或二次创作。
|
||||
|
||||
## 能力概述
|
||||
|
||||
- **正文提取**:去掉导航/广告/页脚,只留主体内容
|
||||
- **转 Markdown**:标题、列表、链接、代码块保留结构
|
||||
- **保存**:可写入本地 .md 文件
|
||||
|
||||
## 操作方式
|
||||
|
||||
用 Bash 工具。首选 jina.ai 的免费 reader(无需依赖,最省事):
|
||||
|
||||
```bash
|
||||
# 最简:jina reader 直接返回干净 Markdown(在链接前加 https://r.jina.ai/)
|
||||
curl -s "https://r.jina.ai/https://example.com/article" -o article.md
|
||||
echo "已保存 -> article.md"; head -40 article.md
|
||||
```
|
||||
|
||||
离线或 jina 不可用时,用 Python 本地转换:
|
||||
|
||||
```bash
|
||||
python -c "import markdownify,requests" 2>/dev/null || pip install -q markdownify requests beautifulsoup4
|
||||
python - <<'PY'
|
||||
import requests, re
|
||||
from bs4 import BeautifulSoup
|
||||
from markdownify import markdownify as md
|
||||
url = "https://example.com/article"
|
||||
html = requests.get(url, timeout=15, headers={"User-Agent":"Mozilla/5.0"}).text
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
for t in soup(["script","style","nav","footer","aside"]): t.decompose()
|
||||
body = soup.find("article") or soup.find("main") or soup.body
|
||||
out = md(str(body), heading_style="ATX")
|
||||
out = re.sub(r"\n{3,}", "\n\n", out).strip()
|
||||
open("article.md","w",encoding="utf-8").write(out)
|
||||
print("已保存 -> article.md,", len(out), "字")
|
||||
PY
|
||||
```
|
||||
|
||||
## 使用建议
|
||||
|
||||
- 先问用户要不要保存到文件、文件名
|
||||
- 微信公众号/知乎等需要登录的页面可能抓取受限,如失败如实告知
|
||||
- 抓取后可顺手做"总结要点",结合 china-search / deepseek-helper 技能
|
||||
Reference in New Issue
Block a user