forked from Selig/openclaw-skill
Initial commit: OpenClaw Skill Collection
6 custom skills (assign-task, dispatch-webhook, daily-briefing, task-capture, qmd-brain, tts-voice) with technical documentation. Compatible with Claude Code, OpenClaw, Codex CLI, and OpenCode.
This commit is contained in:
73
skills/assign-task/SKILL.md
Normal file
73
skills/assign-task/SKILL.md
Normal file
@@ -0,0 +1,73 @@
|
||||
---
|
||||
name: assign-task
|
||||
description: 分析使用者任務,判斷類型,分派給對應的 VPS(Claude Code 或 OpenCode),並追蹤執行狀態。
|
||||
triggers:
|
||||
- "幫我開發"
|
||||
- "建立專案"
|
||||
- "寫程式"
|
||||
- "實作功能"
|
||||
- "分派任務"
|
||||
- "assign task"
|
||||
- "新任務"
|
||||
tools:
|
||||
- web_fetch
|
||||
- exec
|
||||
- memory
|
||||
---
|
||||
|
||||
# Assign Task Skill
|
||||
|
||||
## 功能說明
|
||||
|
||||
分析使用者描述的任務,自動判斷應分派給哪個 VPS,呼叫 `dispatch-webhook` 執行,並回報狀態。
|
||||
|
||||
## 分派規則
|
||||
|
||||
| 任務類型 | 目標 | 說明 |
|
||||
|---------|------|------|
|
||||
| 主要專案開發 | VPS-A (Claude Code) | 核心業務邏輯、架構設計 |
|
||||
| 其他專案 / 實驗性功能 | VPS-B (OpenCode) | 輔助工具、腳本、研究 |
|
||||
| 文件撰寫 | VPS-B | README、技術文件 |
|
||||
| Code Review | VPS-A | 需要 Claude Opus 審查 |
|
||||
|
||||
## 執行流程
|
||||
|
||||
```
|
||||
1. 解析使用者意圖(任務類型、優先級、專案名稱)
|
||||
2. 判斷分派目標(VPS-A or VPS-B)
|
||||
3. 呼叫 dispatch-webhook skill 發送任務
|
||||
4. 等待回應(async 模式下記錄 task_id)
|
||||
5. 回報使用者:「✅ 任務已分派給 VPS-A,task_id: xxx」
|
||||
```
|
||||
|
||||
## 輸入格式
|
||||
|
||||
使用者自然語言描述,例如:
|
||||
- 「幫我開發一個 Python API 來處理 webhook」
|
||||
- 「建立一個 React 前端,連接現有的後端 API」
|
||||
|
||||
## 輸出格式
|
||||
|
||||
```
|
||||
✅ 任務已分派
|
||||
|
||||
📋 任務摘要:[任務描述摘要]
|
||||
🎯 分派目標:VPS-A / VPS-B
|
||||
🤖 執行模型:Claude Code Opus / OpenCode Codex
|
||||
🆔 Task ID:[uuid]
|
||||
⏱️ 預計完成:[估計時間]
|
||||
|
||||
執行中,完成後會通知你。
|
||||
```
|
||||
|
||||
## 設定(環境變數)
|
||||
|
||||
在 `~/.openclaw/workspace/.env` 或 openclaw.json env 區塊設定:
|
||||
|
||||
```
|
||||
VPS_A_WEBHOOK_URL=https://vps-a.example.com/webhook/openclaw
|
||||
VPS_A_WEBHOOK_TOKEN=<shared-secret>
|
||||
VPS_B_WEBHOOK_URL=https://vps-b.example.com/webhook/openclaw
|
||||
VPS_B_WEBHOOK_TOKEN=<shared-secret>
|
||||
CALLBACK_BASE_URL=https://oclaw.nature.edu.kg/webhook/callback
|
||||
```
|
||||
94
skills/assign-task/handler.ts
Normal file
94
skills/assign-task/handler.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* assign-task skill
|
||||
* 分析任務,判斷目標 VPS,呼叫 dispatch-webhook 執行
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
// 任務分派規則
|
||||
const DISPATCH_RULES = [
|
||||
{
|
||||
target: 'vps-a',
|
||||
label: 'VPS-A (Claude Code)',
|
||||
model: 'Claude Code Opus',
|
||||
keywords: ['主要專案', '核心功能', '架構', 'code review', 'review', '審查', 'API', '後端', 'backend'],
|
||||
},
|
||||
{
|
||||
target: 'vps-b',
|
||||
label: 'VPS-B (OpenCode)',
|
||||
model: 'OpenCode Codex',
|
||||
keywords: ['腳本', 'script', '工具', 'tool', '文件', 'doc', 'readme', '實驗', '前端', 'frontend', 'react', 'vue'],
|
||||
},
|
||||
];
|
||||
|
||||
function determineTarget(description: string): typeof DISPATCH_RULES[0] {
|
||||
const lower = description.toLowerCase();
|
||||
for (const rule of DISPATCH_RULES) {
|
||||
if (rule.keywords.some(k => lower.includes(k.toLowerCase()))) {
|
||||
return rule;
|
||||
}
|
||||
}
|
||||
// 預設分派到 VPS-A
|
||||
return DISPATCH_RULES[0];
|
||||
}
|
||||
|
||||
function determinePriority(description: string): string {
|
||||
const lower = description.toLowerCase();
|
||||
if (lower.includes('緊急') || lower.includes('urgent') || lower.includes('asap')) return 'high';
|
||||
if (lower.includes('之後') || lower.includes('有空') || lower.includes('低優先')) return 'low';
|
||||
return 'normal';
|
||||
}
|
||||
|
||||
export async function handler(ctx: any) {
|
||||
const { message, env, callSkill } = ctx;
|
||||
const description = message.text || message.content || '';
|
||||
|
||||
if (!description) {
|
||||
return { reply: '❌ 請描述你想要執行的任務。' };
|
||||
}
|
||||
|
||||
const taskId = randomUUID();
|
||||
const target = determineTarget(description);
|
||||
const priority = determinePriority(description);
|
||||
|
||||
// 建立任務 payload
|
||||
const task = {
|
||||
task_id: taskId,
|
||||
type: 'project_development',
|
||||
description: description.slice(0, 2000),
|
||||
priority,
|
||||
target: target.target,
|
||||
callback_url: `${env.CALLBACK_BASE_URL || 'https://oclaw.nature.edu.kg/webhook/callback'}`,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// 呼叫 dispatch-webhook skill
|
||||
let dispatchResult;
|
||||
try {
|
||||
dispatchResult = await callSkill('dispatch-webhook', {
|
||||
target: target.target,
|
||||
payload: task,
|
||||
webhookUrl: target.target === 'vps-a' ? env.VPS_A_WEBHOOK_URL : env.VPS_B_WEBHOOK_URL,
|
||||
webhookToken: target.target === 'vps-a' ? env.VPS_A_WEBHOOK_TOKEN : env.VPS_B_WEBHOOK_TOKEN,
|
||||
});
|
||||
} catch (err: any) {
|
||||
return {
|
||||
reply: `❌ Webhook 發送失敗:${err.message}\n\nTask ID: \`${taskId}\`(可稍後重試)`,
|
||||
};
|
||||
}
|
||||
|
||||
const priorityLabel = { high: '🔴 緊急', normal: '🟡 一般', low: '🟢 低優先' }[priority] || priority;
|
||||
|
||||
return {
|
||||
reply: `✅ 任務已分派
|
||||
|
||||
📋 **任務摘要**:${description.slice(0, 100)}${description.length > 100 ? '...' : ''}
|
||||
🎯 **分派目標**:${target.label}
|
||||
🤖 **執行模型**:${target.model}
|
||||
⚡ **優先級**:${priorityLabel}
|
||||
🆔 **Task ID**:\`${taskId}\`
|
||||
|
||||
執行中,完成後會通知你。`,
|
||||
metadata: { taskId, target: target.target, priority },
|
||||
};
|
||||
}
|
||||
85
skills/daily-briefing/SKILL.md
Normal file
85
skills/daily-briefing/SKILL.md
Normal file
@@ -0,0 +1,85 @@
|
||||
---
|
||||
name: daily-briefing
|
||||
description: 每日早安簡報:整合今日行程、天氣、待辦事項,以繁體中文發送 Telegram 摘要。
|
||||
triggers:
|
||||
- "今天行程"
|
||||
- "早安簡報"
|
||||
- "今日摘要"
|
||||
- "daily briefing"
|
||||
tools:
|
||||
- web_fetch
|
||||
- web_search
|
||||
- exec
|
||||
- memory
|
||||
---
|
||||
|
||||
# Daily Briefing Skill
|
||||
|
||||
## 功能說明
|
||||
|
||||
每日自動(或手動觸發)生成早安簡報,包含:
|
||||
1. 🌤️ 今日天氣(台灣地區)
|
||||
2. 📅 今日行程(來自 workspace/SCHEDULE.md 或 Google Calendar)
|
||||
3. ✅ 待辦事項(來自 workspace/TODO.md)
|
||||
4. 💡 今日重點提醒
|
||||
5. 📊 昨日工作回顧(選配)
|
||||
|
||||
## 設定
|
||||
|
||||
在 `workspace/USER.md` 設定:
|
||||
```markdown
|
||||
## 個人設定
|
||||
- 城市:台北
|
||||
- 時區:Asia/Taipei
|
||||
- 簡報語言:繁體中文
|
||||
- 天氣 API Key:<openweathermap-api-key>(選配)
|
||||
```
|
||||
|
||||
## 輸出格式範例
|
||||
|
||||
```
|
||||
☀️ **早安!2026-02-20 週五**
|
||||
|
||||
🌤️ **今日天氣(台北)**
|
||||
氣溫 16-22°C,多雲偶晴,東北風 2-3 級
|
||||
穿著建議:可帶薄外套
|
||||
|
||||
📅 **今日行程**
|
||||
• 09:00 - 週會(視訊)
|
||||
• 14:00 - 客戶簡報
|
||||
• 16:30 - Code Review
|
||||
|
||||
✅ **待辦事項(3 項)**
|
||||
• [ ] 完成 API 文件
|
||||
• [ ] 回覆客戶 email
|
||||
• [ ] 更新 deploy 腳本
|
||||
|
||||
💡 **今日提醒**
|
||||
• SSL 憑證 90 天後到期(2026-05-20)
|
||||
• 本週 sprint 截止日:2026-02-21
|
||||
|
||||
有什麼想先處理的嗎?
|
||||
```
|
||||
|
||||
## Cron 設定
|
||||
|
||||
```bash
|
||||
# 每日 08:00 自動觸發
|
||||
sudo openclaw cron add \
|
||||
--name "daily-briefing" \
|
||||
--cron "0 8 * * *" \
|
||||
--timezone "Asia/Taipei" \
|
||||
--session main \
|
||||
--system-event "請執行 daily-briefing skill,生成今日早安簡報並發送到 Telegram"
|
||||
```
|
||||
|
||||
## 擴充:Google Calendar 整合
|
||||
|
||||
若要連接 Google Calendar,在 workspace/TOOLS.md 記錄:
|
||||
```
|
||||
Google Calendar API:
|
||||
- Service Account: <path-to-credentials.json>
|
||||
- Calendar ID: primary
|
||||
```
|
||||
|
||||
然後 agent 可透過 Google Calendar API 抓取今日事件。
|
||||
125
skills/daily-briefing/handler.ts
Normal file
125
skills/daily-briefing/handler.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* daily-briefing skill
|
||||
* 生成每日早安簡報
|
||||
*/
|
||||
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
function getWeekday(date: Date): string {
|
||||
const days = ['日', '一', '二', '三', '四', '五', '六'];
|
||||
return `週${days[date.getDay()]}`;
|
||||
}
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
async function fetchWeather(city: string, apiKey?: string): Promise<string> {
|
||||
if (!apiKey) {
|
||||
// 無 API key,使用 web_search 取得天氣資訊
|
||||
return '(天氣資訊需設定 OpenWeatherMap API Key,或由 agent 透過 web_search 查詢)';
|
||||
}
|
||||
try {
|
||||
const url = `https://api.openweathermap.org/data/2.5/weather?q=${encodeURIComponent(city)}&appid=${apiKey}&units=metric&lang=zh_tw`;
|
||||
const res = await fetch(url);
|
||||
const data = await res.json() as any;
|
||||
if (data.main) {
|
||||
const temp = `${Math.round(data.main.temp_min)}-${Math.round(data.main.temp_max)}°C`;
|
||||
const desc = data.weather?.[0]?.description || '';
|
||||
return `氣溫 ${temp},${desc}`;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return '天氣資訊暫時無法取得';
|
||||
}
|
||||
|
||||
function readWorkspaceFile(workspace: string, filename: string): string {
|
||||
const path = join(workspace, filename);
|
||||
if (existsSync(path)) {
|
||||
return readFileSync(path, 'utf-8');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function parseTodos(todoContent: string): string[] {
|
||||
if (!todoContent) return [];
|
||||
return todoContent
|
||||
.split('\n')
|
||||
.filter(line => line.match(/^[-*]\s*\[\s*\]/))
|
||||
.map(line => line.replace(/^[-*]\s*/, ''))
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
function parseTodaySchedule(scheduleContent: string, dateStr: string): string[] {
|
||||
if (!scheduleContent) return [];
|
||||
const lines = scheduleContent.split('\n');
|
||||
const results: string[] = [];
|
||||
let inToday = false;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.includes(dateStr)) { inToday = true; continue; }
|
||||
if (inToday && line.match(/^#{1,3}\s/)) break;
|
||||
if (inToday && line.trim() && line.match(/^\d{2}:\d{2}/)) {
|
||||
results.push(line.trim());
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function handler(ctx: any) {
|
||||
const workspace = ctx.env?.OPENCLAW_WORKSPACE || process.env.HOME + '/.openclaw/workspace';
|
||||
const now = new Date();
|
||||
const dateStr = formatDate(now);
|
||||
const weekday = getWeekday(now);
|
||||
|
||||
// 讀取設定
|
||||
const userMd = readWorkspaceFile(workspace, 'USER.md');
|
||||
const cityMatch = userMd.match(/城市[::]\s*(.+)/);
|
||||
const city = cityMatch?.[1]?.trim() || '台北';
|
||||
const apiKeyMatch = userMd.match(/天氣 API Key[::]\s*(.+)/);
|
||||
const weatherApiKey = apiKeyMatch?.[1]?.trim();
|
||||
|
||||
// 讀取待辦與行程
|
||||
const todoContent = readWorkspaceFile(workspace, 'TODO.md');
|
||||
const scheduleContent = readWorkspaceFile(workspace, 'SCHEDULE.md');
|
||||
const memoryContent = readWorkspaceFile(workspace, `memory/${dateStr}.md`);
|
||||
|
||||
const todos = parseTodos(todoContent);
|
||||
const schedule = parseTodaySchedule(scheduleContent, dateStr);
|
||||
const weatherInfo = await fetchWeather(city, weatherApiKey);
|
||||
|
||||
// 組裝簡報
|
||||
const sections: string[] = [];
|
||||
|
||||
sections.push(`☀️ **早安!${dateStr} ${weekday}**\n`);
|
||||
|
||||
sections.push(`🌤️ **今日天氣(${city})**\n${weatherInfo}`);
|
||||
|
||||
if (schedule.length > 0) {
|
||||
sections.push(`📅 **今日行程**\n${schedule.map(s => `• ${s}`).join('\n')}`);
|
||||
} else {
|
||||
sections.push(`📅 **今日行程**\n• 暫無排程`);
|
||||
}
|
||||
|
||||
if (todos.length > 0) {
|
||||
sections.push(`✅ **待辦事項(${todos.length} 項)**\n${todos.map(t => `• ${t}`).join('\n')}`);
|
||||
} else {
|
||||
sections.push(`✅ **待辦事項**\n• 今日無待辦,保持輕鬆!`);
|
||||
}
|
||||
|
||||
if (memoryContent) {
|
||||
sections.push(`📝 **昨日記錄**\n${memoryContent.slice(0, 200)}...`);
|
||||
}
|
||||
|
||||
sections.push(`\n有什麼想先處理的嗎?`);
|
||||
|
||||
return {
|
||||
reply: sections.join('\n\n'),
|
||||
metadata: { date: dateStr, city, todoCount: todos.length, scheduleCount: schedule.length },
|
||||
};
|
||||
}
|
||||
114
skills/dispatch-webhook/SKILL.md
Normal file
114
skills/dispatch-webhook/SKILL.md
Normal file
@@ -0,0 +1,114 @@
|
||||
---
|
||||
name: dispatch-webhook
|
||||
description: 發送任務 Payload 到指定 VPS 的 Webhook endpoint,支援同步等待與非同步模式。
|
||||
triggers: []
|
||||
tools:
|
||||
- web_fetch
|
||||
- exec
|
||||
internal: true
|
||||
---
|
||||
|
||||
# Dispatch Webhook Skill
|
||||
|
||||
## 功能說明
|
||||
|
||||
底層 Webhook 發送工具,由 `assign-task` 呼叫。
|
||||
負責發送任務到 VPS-A 或 VPS-B,處理認證與錯誤重試。
|
||||
|
||||
## Webhook 協議
|
||||
|
||||
### 請求格式(OpenClaw → VPS)
|
||||
|
||||
```http
|
||||
POST <VPS_WEBHOOK_URL>
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
X-OpenClaw-Version: 1.0
|
||||
|
||||
{
|
||||
"task_id": "uuid",
|
||||
"type": "project_development",
|
||||
"description": "任務描述",
|
||||
"priority": "high|normal|low",
|
||||
"callback_url": "https://oclaw.nature.edu.kg/webhook/callback",
|
||||
"created_at": "ISO8601"
|
||||
}
|
||||
```
|
||||
|
||||
### 即時回應格式(VPS → OpenClaw,HTTP 202)
|
||||
|
||||
```json
|
||||
{
|
||||
"task_id": "uuid",
|
||||
"status": "accepted",
|
||||
"message": "任務已接受,開始執行"
|
||||
}
|
||||
```
|
||||
|
||||
### Callback 格式(任務完成後,VPS → OpenClaw)
|
||||
|
||||
```http
|
||||
POST <callback_url>
|
||||
Authorization: Bearer <callback_token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"task_id": "uuid",
|
||||
"status": "completed|failed",
|
||||
"summary": "執行摘要",
|
||||
"artifacts": ["file1.py", "docs/README.md"],
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
## VPS 端接收端實作範例
|
||||
|
||||
### Node.js / Express
|
||||
|
||||
```javascript
|
||||
app.post('/webhook/openclaw', (req, res) => {
|
||||
const token = req.headers.authorization?.replace('Bearer ', '');
|
||||
if (token !== process.env.OPENCLAW_WEBHOOK_TOKEN) {
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const task = req.body;
|
||||
res.status(202).json({ task_id: task.task_id, status: 'accepted' });
|
||||
|
||||
// 非同步執行任務
|
||||
processTask(task).then(result => {
|
||||
// 回傳結果給 OpenClaw
|
||||
fetch(task.callback_url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${CALLBACK_TOKEN}` },
|
||||
body: JSON.stringify({ task_id: task.task_id, status: 'completed', ...result }),
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 給 Claude Code 的提示詞(VPS-A)
|
||||
|
||||
任務接收後,PM agent 用這個提示詞啟動工作:
|
||||
|
||||
```
|
||||
你是專案經理,收到來自 OpenClaw 的任務指派。
|
||||
Task ID: {task_id}
|
||||
任務描述: {description}
|
||||
優先級: {priority}
|
||||
|
||||
請:
|
||||
1. 分析需求,制定實作計劃
|
||||
2. 分派子任務給適合的模型(Sonnet 寫程式,Haiku 做測試)
|
||||
3. 整合結果,確保品質
|
||||
4. 完成後透過 callback_url 回報結果
|
||||
```
|
||||
|
||||
## 錯誤處理
|
||||
|
||||
| 錯誤 | 處理方式 |
|
||||
|------|---------|
|
||||
| 連線失敗 | 重試 3 次,間隔 5s |
|
||||
| 401 Unauthorized | 立即失敗,提示設定 token |
|
||||
| 超時(> 30s) | 返回 accepted,等待 callback |
|
||||
| VPS 回傳 500 | 記錄錯誤,通知使用者 |
|
||||
86
skills/dispatch-webhook/handler.ts
Normal file
86
skills/dispatch-webhook/handler.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* dispatch-webhook skill
|
||||
* 發送 Webhook 到 VPS,處理重試與錯誤
|
||||
*/
|
||||
|
||||
interface DispatchInput {
|
||||
target: 'vps-a' | 'vps-b';
|
||||
payload: Record<string, unknown>;
|
||||
webhookUrl: string;
|
||||
webhookToken: string;
|
||||
timeoutMs?: number;
|
||||
retries?: number;
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(url: string, options: RequestInit, timeoutMs: number): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
return await fetch(url, { ...options, signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handler(ctx: any) {
|
||||
const input: DispatchInput = ctx.input || ctx.params;
|
||||
|
||||
if (!input.webhookUrl) {
|
||||
throw new Error(`${input.target.toUpperCase()} Webhook URL 未設定。請在環境變數設定 VPS_A_WEBHOOK_URL 或 VPS_B_WEBHOOK_URL`);
|
||||
}
|
||||
if (!input.webhookToken) {
|
||||
throw new Error(`${input.target.toUpperCase()} Webhook Token 未設定`);
|
||||
}
|
||||
|
||||
const timeoutMs = input.timeoutMs ?? 30000;
|
||||
const maxRetries = input.retries ?? 3;
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
const response = await fetchWithTimeout(
|
||||
input.webhookUrl,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${input.webhookToken}`,
|
||||
'X-OpenClaw-Version': '1.0',
|
||||
'X-OpenClaw-Task-Id': String(input.payload.task_id || ''),
|
||||
},
|
||||
body: JSON.stringify(input.payload),
|
||||
},
|
||||
timeoutMs
|
||||
);
|
||||
|
||||
if (response.status === 401) {
|
||||
throw new Error('Webhook Token 驗證失敗(401),請確認 VPS_WEBHOOK_TOKEN 設定正確');
|
||||
}
|
||||
|
||||
if (!response.ok && response.status !== 202) {
|
||||
const body = await response.text().catch(() => '');
|
||||
throw new Error(`VPS 回應錯誤 ${response.status}:${body.slice(0, 200)}`);
|
||||
}
|
||||
|
||||
const result = await response.json().catch(() => ({ status: 'accepted' }));
|
||||
return {
|
||||
success: true,
|
||||
status: result.status || 'accepted',
|
||||
task_id: input.payload.task_id,
|
||||
target: input.target,
|
||||
attempt,
|
||||
};
|
||||
|
||||
} catch (err: any) {
|
||||
lastError = err;
|
||||
if (err.message?.includes('401') || err.message?.includes('Token')) {
|
||||
break; // 認證錯誤不重試
|
||||
}
|
||||
if (attempt < maxRetries) {
|
||||
await new Promise(r => setTimeout(r, 5000 * attempt)); // 指數退避
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error('Webhook 發送失敗');
|
||||
}
|
||||
78
skills/qmd-brain/SKILL.md
Normal file
78
skills/qmd-brain/SKILL.md
Normal file
@@ -0,0 +1,78 @@
|
||||
---
|
||||
name: qmd-brain
|
||||
description: 第二大腦知識庫搜尋與索引。用 qmd(BM25+向量混合)搜尋本地 markdown 文件,並透過 PostgreSQL pgvector 進行深度語意搜尋。
|
||||
triggers:
|
||||
- "搜尋"
|
||||
- "查找"
|
||||
- "找資料"
|
||||
- "recall"
|
||||
- "記憶"
|
||||
- "之前說過"
|
||||
- "知識庫"
|
||||
- "找到"
|
||||
- "我之前"
|
||||
- "幫我找"
|
||||
- "查一下"
|
||||
- "有沒有"
|
||||
- "搜一下"
|
||||
- "brain search"
|
||||
- "qmd"
|
||||
tools:
|
||||
- exec
|
||||
- memory
|
||||
---
|
||||
|
||||
# qmd-brain Skill
|
||||
|
||||
## 功能說明
|
||||
|
||||
第二大腦(Second Brain)知識庫搜尋工具,整合兩層搜尋:
|
||||
|
||||
| 層次 | 工具 | 特色 |
|
||||
|------|------|------|
|
||||
| Layer 1 | qmd (BM25 全文搜尋) | 快速關鍵字比對,本地 SQLite |
|
||||
| Layer 2 | embed_to_pg (語意搜尋) | 向量相似度,PostgreSQL pgvector |
|
||||
|
||||
## 觸發範例
|
||||
|
||||
```
|
||||
使用者:「幫我找關於 nginx 設定的資料」
|
||||
→ qmd search "nginx 設定" → 返回相關文件段落
|
||||
|
||||
使用者:「之前有記過 Telegram bot 的設定嗎?」
|
||||
→ embed_to_pg search "Telegram bot token 設定" → 語意搜尋
|
||||
|
||||
使用者:「更新知識庫索引」
|
||||
→ qmd embed + embed_to_pg embed
|
||||
|
||||
使用者:「查知識庫統計」
|
||||
→ qmd collection list + embed_to_pg stats
|
||||
```
|
||||
|
||||
## 搜尋策略
|
||||
|
||||
1. **關鍵字搜尋**(qmd search):適合確定的詞彙、指令、設定名稱
|
||||
2. **語意搜尋**(embed_to_pg search):適合概念性問題、模糊記憶
|
||||
3. **混合搜尋**:先用 qmd 快速篩選,再用 pgvector 重排
|
||||
|
||||
## 輸出格式
|
||||
|
||||
搜尋結果包含:
|
||||
- 文件來源(檔案路徑)
|
||||
- 相關段落(前 200 字)
|
||||
- 相似度分數
|
||||
|
||||
## 重要路徑
|
||||
|
||||
```
|
||||
qmd 索引: ~/.cache/qmd/index.sqlite
|
||||
pgvector DB: postgresql://qmd_user@localhost/qmd_brain
|
||||
embed 腳本: /home/selig/apps/qmd-pg/embed_to_pg.py
|
||||
qmd collections:selig-home (/home/selig)
|
||||
```
|
||||
|
||||
## 每日排程
|
||||
|
||||
凌晨 02:00 自動執行:
|
||||
1. `qmd embed`(更新 BM25 + 本地向量索引)
|
||||
2. `embed_to_pg embed`(更新 PostgreSQL 向量庫)
|
||||
196
skills/qmd-brain/handler.ts
Normal file
196
skills/qmd-brain/handler.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* qmd-brain skill
|
||||
* 第二大腦知識庫:BM25 全文搜尋 + PostgreSQL pgvector 語意搜尋
|
||||
*
|
||||
* 依賴:
|
||||
* - qmd CLI (npm install -g @tobilu/qmd)
|
||||
* - embed_to_pg.py (Python venv at /home/selig/apps/qmd-pg/)
|
||||
*/
|
||||
|
||||
import { execSync, exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
const QMD_CMD = '/home/selig/.nvm/versions/node/v24.13.1/bin/qmd';
|
||||
const EMBED_PY = '/home/selig/apps/qmd-pg/venv/bin/python3 /home/selig/apps/qmd-pg/embed_to_pg.py';
|
||||
const MAX_SEARCH_LEN = 1500; // 回覆中搜尋結果最大字數
|
||||
|
||||
interface SearchResult {
|
||||
source: string;
|
||||
chunk: number;
|
||||
text: string;
|
||||
similarity?: number;
|
||||
}
|
||||
|
||||
interface QmdResult {
|
||||
path: string;
|
||||
text?: string;
|
||||
score?: number;
|
||||
}
|
||||
|
||||
/** 執行 qmd BM25 全文搜尋 */
|
||||
async function qmdSearch(query: string, topK = 5): Promise<string> {
|
||||
try {
|
||||
const { stdout } = await execAsync(
|
||||
`${QMD_CMD} search ${JSON.stringify(query)} --output markdown --limit ${topK}`,
|
||||
{ timeout: 15000, env: { ...process.env, HOME: '/home/selig' } }
|
||||
);
|
||||
return stdout.trim() || '(無結果)';
|
||||
} catch (e: any) {
|
||||
return `qmd search 錯誤: ${e.message?.split('\n')[0]}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** 執行 PostgreSQL 向量語意搜尋 */
|
||||
async function pgSearch(query: string, topK = 5): Promise<SearchResult[]> {
|
||||
try {
|
||||
const { stdout } = await execAsync(
|
||||
`${EMBED_PY} search ${JSON.stringify(query)} --top-k ${topK} --json`,
|
||||
{ timeout: 20000 }
|
||||
);
|
||||
return JSON.parse(stdout) as SearchResult[];
|
||||
} catch (e: any) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** 格式化 pgvector 搜尋結果 */
|
||||
function formatPgResults(results: SearchResult[]): string {
|
||||
if (!results.length) return '(向量庫無相關結果)';
|
||||
return results.map((r, i) => {
|
||||
const fname = r.source.split('/').pop() || r.source;
|
||||
const snippet = r.text.slice(0, 200).replace(/\n/g, ' ');
|
||||
const score = r.similarity ? `${(r.similarity * 100).toFixed(1)}%` : '';
|
||||
return `**[${i + 1}] ${fname}** ${score}\n> ${snippet}...`;
|
||||
}).join('\n\n');
|
||||
}
|
||||
|
||||
/** 觸發向量索引更新 */
|
||||
async function triggerEmbed(): Promise<string> {
|
||||
try {
|
||||
// 背景執行,不等待完成
|
||||
exec(
|
||||
`${QMD_CMD} embed 2>&1 >> /tmp/qmd-embed.log & ${EMBED_PY} embed 2>&1 >> /tmp/qmd-embed.log &`,
|
||||
{ env: { ...process.env, HOME: '/home/selig' } }
|
||||
);
|
||||
return '✅ 索引更新已在背景啟動,約需 1-5 分鐘完成。';
|
||||
} catch (e: any) {
|
||||
return `❌ 索引啟動失敗: ${e.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** 取得向量庫統計 */
|
||||
async function getStats(): Promise<string> {
|
||||
const results: string[] = [];
|
||||
|
||||
// qmd collection list
|
||||
try {
|
||||
const { stdout } = await execAsync(
|
||||
`${QMD_CMD} collection list`,
|
||||
{ timeout: 5000, env: { ...process.env, HOME: '/home/selig' } }
|
||||
);
|
||||
results.push(`**qmd Collections:**\n\`\`\`\n${stdout.trim()}\n\`\`\``);
|
||||
} catch (e: any) {
|
||||
results.push(`qmd: ${e.message?.split('\n')[0]}`);
|
||||
}
|
||||
|
||||
// pgvector stats
|
||||
try {
|
||||
const { stdout } = await execAsync(
|
||||
`${EMBED_PY} stats`,
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
results.push(`**PostgreSQL pgvector:**\n\`\`\`\n${stdout.trim()}\n\`\`\``);
|
||||
} catch (e: any) {
|
||||
results.push(`pgvector: ${e.message?.split('\n')[0]}`);
|
||||
}
|
||||
|
||||
return results.join('\n\n');
|
||||
}
|
||||
|
||||
/** 判斷使用者意圖 */
|
||||
function detectIntent(message: string): 'search' | 'embed' | 'stats' | 'vsearch' {
|
||||
const lower = message.toLowerCase();
|
||||
|
||||
if (lower.match(/更新|重新索引|rebuild|index|embed|掃描/)) return 'embed';
|
||||
if (lower.match(/統計|stat|幾個|多少|collection/)) return 'stats';
|
||||
if (lower.match(/語意|向量|vsearch|概念|類似/)) return 'vsearch';
|
||||
return 'search';
|
||||
}
|
||||
|
||||
/** 從訊息提取搜尋關鍵字 */
|
||||
function extractQuery(message: string): string {
|
||||
return message
|
||||
.replace(/^(搜尋|查找|找資料|幫我找|查一下|搜一下|找到|有沒有|之前說過|我之前|recall|brain search|qmd)[:::]?\s*/i, '')
|
||||
.replace(/(請|幫我|的資料|的內容|相關|嗎|?|\?)/g, '')
|
||||
.trim() || message.trim();
|
||||
}
|
||||
|
||||
// ─── 主 handler ─────────────────────────────────────────────────────────────
|
||||
|
||||
export async function handler(ctx: any) {
|
||||
const message = ctx.message?.text || ctx.message?.content || '';
|
||||
const intent = detectIntent(message);
|
||||
const query = extractQuery(message);
|
||||
|
||||
// 更新索引
|
||||
if (intent === 'embed') {
|
||||
const result = await triggerEmbed();
|
||||
return { reply: `🧠 **第二大腦索引更新**\n\n${result}` };
|
||||
}
|
||||
|
||||
// 統計
|
||||
if (intent === 'stats') {
|
||||
const stats = await getStats();
|
||||
return { reply: `📊 **知識庫統計**\n\n${stats}` };
|
||||
}
|
||||
|
||||
// 語意搜尋(只用 pgvector)
|
||||
if (intent === 'vsearch') {
|
||||
if (!query) {
|
||||
return { reply: '請提供搜尋關鍵字,例如:「語意搜尋 Telegram 機器人設定」' };
|
||||
}
|
||||
const results = await pgSearch(query, 5);
|
||||
const formatted = formatPgResults(results);
|
||||
return {
|
||||
reply: `🔍 **語意搜尋**:${query}\n\n${formatted}`,
|
||||
metadata: { query, results_count: results.length, engine: 'pgvector' },
|
||||
};
|
||||
}
|
||||
|
||||
// 混合搜尋(BM25 + 向量)
|
||||
if (!query) {
|
||||
return { reply: '請提供搜尋關鍵字,例如:「搜尋 nginx 設定」' };
|
||||
}
|
||||
|
||||
// 並行執行兩種搜尋
|
||||
const [bm25Result, vectorResults] = await Promise.all([
|
||||
qmdSearch(query, 3),
|
||||
pgSearch(query, 3),
|
||||
]);
|
||||
|
||||
const vectorFormatted = formatPgResults(vectorResults);
|
||||
const hasVector = vectorResults.length > 0 && !vectorFormatted.includes('無相關結果');
|
||||
|
||||
let reply = `🧠 **第二大腦搜尋**:${query}\n\n`;
|
||||
reply += `### 全文搜尋 (BM25)\n${bm25Result}`;
|
||||
|
||||
if (hasVector) {
|
||||
reply += `\n\n### 語意搜尋 (向量)\n${vectorFormatted}`;
|
||||
}
|
||||
|
||||
// 截斷過長回覆
|
||||
if (reply.length > MAX_SEARCH_LEN) {
|
||||
reply = reply.slice(0, MAX_SEARCH_LEN) + '\n\n...(結果已截斷,輸入「語意搜尋 [關鍵字]」可專注向量搜尋)';
|
||||
}
|
||||
|
||||
return {
|
||||
reply,
|
||||
metadata: {
|
||||
query,
|
||||
bm25_chars: bm25Result.length,
|
||||
vector_results: vectorResults.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
64
skills/task-capture/SKILL.md
Normal file
64
skills/task-capture/SKILL.md
Normal file
@@ -0,0 +1,64 @@
|
||||
---
|
||||
name: task-capture
|
||||
description: 快速將使用者說的事情記錄到 TODO.md,支援優先級、標籤、截止日。
|
||||
triggers:
|
||||
- "記住"
|
||||
- "記一下"
|
||||
- "待辦"
|
||||
- "todo"
|
||||
- "提醒我"
|
||||
- "別忘了"
|
||||
- "加到清單"
|
||||
tools:
|
||||
- exec
|
||||
- memory
|
||||
---
|
||||
|
||||
# Task Capture Skill
|
||||
|
||||
## 功能說明
|
||||
|
||||
快速捕捉待辦事項,記錄到 `workspace/TODO.md`,支援:
|
||||
- 優先級標記(🔴 緊急 / 🟡 一般 / 🟢 低優先)
|
||||
- 標籤(#工作 #個人 #專案)
|
||||
- 截止日期(`due: 2026-02-25`)
|
||||
- 自動分類
|
||||
|
||||
## 使用方式
|
||||
|
||||
```
|
||||
使用者:「記一下要 review PR #42,明天下班前」
|
||||
↓
|
||||
Agent 記錄:- [ ] 🟡 review PR #42 due:2026-02-21 #工作
|
||||
↓
|
||||
回覆:「✅ 已記錄:review PR #42(截止:明天下班前)」
|
||||
```
|
||||
|
||||
## TODO.md 格式
|
||||
|
||||
```markdown
|
||||
# TODO
|
||||
|
||||
## 🔴 緊急
|
||||
- [ ] 修復 production 的 nginx 錯誤 due:2026-02-20 #緊急
|
||||
|
||||
## 🟡 進行中
|
||||
- [ ] 完成 CLIProxyAPI 文件 due:2026-02-22 #工作
|
||||
- [ ] 回覆客戶報告 #工作
|
||||
|
||||
## 🟢 稍後
|
||||
- [ ] 整理 skill 庫 #個人
|
||||
- [ ] 看 OpenClaw 官方文件 #學習
|
||||
|
||||
## ✅ 已完成
|
||||
- [x] 安裝 OpenClaw ✅ 2026-02-19
|
||||
- [x] 設定 Telegram Bot ✅ 2026-02-19
|
||||
```
|
||||
|
||||
## 相關指令
|
||||
|
||||
```
|
||||
使用者:「顯示我的待辦」 → 列出 TODO.md 未完成項目
|
||||
使用者:「完成第一項」 → 將第一項標為 [x]
|
||||
使用者:「清除已完成」 → 移除 [x] 項目
|
||||
```
|
||||
123
skills/task-capture/handler.ts
Normal file
123
skills/task-capture/handler.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* task-capture skill
|
||||
* 快速記錄待辦事項到 TODO.md
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
function getDateStr(): string {
|
||||
const now = new Date();
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function detectPriority(text: string): { emoji: string; label: string } {
|
||||
const lower = text.toLowerCase();
|
||||
if (lower.includes('緊急') || lower.includes('urgent') || lower.includes('asap') || lower.includes('立刻')) {
|
||||
return { emoji: '🔴', label: '緊急' };
|
||||
}
|
||||
if (lower.includes('之後') || lower.includes('有空') || lower.includes('低優先') || lower.includes('不急')) {
|
||||
return { emoji: '🟢', label: '低優先' };
|
||||
}
|
||||
return { emoji: '🟡', label: '一般' };
|
||||
}
|
||||
|
||||
function detectDueDate(text: string): string | null {
|
||||
// 明天
|
||||
if (text.includes('明天')) {
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
return `due:${tomorrow.getFullYear()}-${String(tomorrow.getMonth() + 1).padStart(2, '0')}-${String(tomorrow.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
// 下週
|
||||
if (text.includes('下週') || text.includes('下周')) {
|
||||
const next = new Date();
|
||||
next.setDate(next.getDate() + 7);
|
||||
return `due:${next.getFullYear()}-${String(next.getMonth() + 1).padStart(2, '0')}-${String(next.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
// 明確日期 YYYY-MM-DD
|
||||
const match = text.match(/(\d{4}-\d{2}-\d{2})/);
|
||||
if (match) return `due:${match[1]}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
function detectTags(text: string): string[] {
|
||||
const tags: string[] = [];
|
||||
if (text.match(/專案|project|開發|dev|code|PR|review/i)) tags.push('#工作');
|
||||
if (text.match(/個人|私人|自己|家/)) tags.push('#個人');
|
||||
if (text.match(/學習|看|讀|研究|study/i)) tags.push('#學習');
|
||||
// 保留原有 #tag
|
||||
const existing = text.match(/#[\w\u4e00-\u9fa5]+/g) || [];
|
||||
tags.push(...existing);
|
||||
return [...new Set(tags)];
|
||||
}
|
||||
|
||||
function cleanTaskText(text: string): string {
|
||||
return text
|
||||
.replace(/^(記住|記一下|待辦|todo|提醒我|別忘了|加到清單)[:::]?\s*/i, '')
|
||||
.replace(/(明天|下週|下周|緊急|urgent|asap)/gi, '')
|
||||
.replace(/#[\w\u4e00-\u9fa5]+/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
export async function handler(ctx: any) {
|
||||
const workspace = ctx.env?.OPENCLAW_WORKSPACE || process.env.HOME + '/.openclaw/workspace';
|
||||
const message = ctx.message?.text || ctx.message?.content || '';
|
||||
const todoPath = join(workspace, 'TODO.md');
|
||||
|
||||
if (!message) {
|
||||
return { reply: '❌ 請告訴我要記錄什麼。' };
|
||||
}
|
||||
|
||||
const taskText = cleanTaskText(message);
|
||||
if (!taskText) {
|
||||
return { reply: '❌ 無法識別待辦內容,請再說清楚一點。' };
|
||||
}
|
||||
|
||||
const priority = detectPriority(message);
|
||||
const dueDate = detectDueDate(message);
|
||||
const tags = detectTags(message);
|
||||
|
||||
// 組裝 TODO 項目
|
||||
const parts = [`- [ ] ${priority.emoji} ${taskText}`];
|
||||
if (dueDate) parts.push(dueDate);
|
||||
if (tags.length > 0) parts.push(tags.join(' '));
|
||||
const todoLine = parts.join(' ');
|
||||
|
||||
// 讀取或建立 TODO.md
|
||||
let content = '';
|
||||
if (existsSync(todoPath)) {
|
||||
content = readFileSync(todoPath, 'utf-8');
|
||||
} else {
|
||||
content = `# TODO\n\n## 🔴 緊急\n\n## 🟡 進行中\n\n## 🟢 稍後\n\n## ✅ 已完成\n`;
|
||||
}
|
||||
|
||||
// 插入到對應優先級區段
|
||||
const sectionMap: Record<string, string> = {
|
||||
'緊急': '## 🔴 緊急',
|
||||
'一般': '## 🟡 進行中',
|
||||
'低優先': '## 🟢 稍後',
|
||||
};
|
||||
const section = sectionMap[priority.label];
|
||||
|
||||
if (content.includes(section)) {
|
||||
content = content.replace(section, `${section}\n${todoLine}`);
|
||||
} else {
|
||||
content += `\n${todoLine}\n`;
|
||||
}
|
||||
|
||||
writeFileSync(todoPath, content, 'utf-8');
|
||||
|
||||
const dueDateDisplay = dueDate ? `(截止:${dueDate.replace('due:', '')})` : '';
|
||||
|
||||
return {
|
||||
reply: `✅ 已記錄
|
||||
|
||||
${priority.emoji} **${taskText}**${dueDateDisplay}
|
||||
${tags.length > 0 ? `標籤:${tags.join(' ')}` : ''}
|
||||
|
||||
輸入「顯示我的待辦」查看完整清單。`,
|
||||
metadata: { taskText, priority: priority.label, dueDate, tags },
|
||||
};
|
||||
}
|
||||
56
skills/tts-voice/SKILL.md
Normal file
56
skills/tts-voice/SKILL.md
Normal file
@@ -0,0 +1,56 @@
|
||||
---
|
||||
name: tts-voice
|
||||
description: 文字轉語音(LuxTTS Voice Cloning)。將文字合成為 48kHz 高品質語音,支援語音克隆。
|
||||
triggers:
|
||||
- "文字轉語音"
|
||||
- "tts"
|
||||
- "語音合成"
|
||||
- "唸出來"
|
||||
- "說出來"
|
||||
- "轉語音"
|
||||
- "voice"
|
||||
tools:
|
||||
- exec
|
||||
---
|
||||
|
||||
# tts-voice Skill
|
||||
|
||||
## 功能說明
|
||||
|
||||
透過本機 LuxTTS 服務(port 7860)將文字合成為語音。支援:
|
||||
|
||||
- 預設參考音訊快速合成
|
||||
- 自訂語速(speed)、步數(steps)、溫度(t_shift)
|
||||
|
||||
## 觸發範例
|
||||
|
||||
```
|
||||
使用者:「tts 你好,歡迎來到我的頻道」
|
||||
→ 使用預設參考音訊合成語音,回傳 wav 檔
|
||||
|
||||
使用者:「文字轉語音 Hello world」
|
||||
→ 合成英文語音
|
||||
|
||||
使用者:「語音合成 慢速 這是一段測試」
|
||||
→ speed=0.8 慢速合成
|
||||
|
||||
使用者:「唸出來 快速 今天天氣真好」
|
||||
→ speed=1.3 快速合成
|
||||
```
|
||||
|
||||
## 參數
|
||||
|
||||
| 修飾詞 | 效果 |
|
||||
|--------|------|
|
||||
| 慢速 / slow | speed=0.8 |
|
||||
| 快速 / fast | speed=1.3 |
|
||||
| 高品質 / hq | num_steps=6, t_shift=0.95 |
|
||||
| (無修飾)| speed=1.0, num_steps=4, t_shift=0.9 |
|
||||
|
||||
## 技術細節
|
||||
|
||||
- 服務:LuxTTS(FastAPI,port 7860,systemd user service)
|
||||
- 認證:session cookie(自動取得)
|
||||
- 參考音訊:`~/LuxTTS/ref_speech.wav`
|
||||
- 輸出:48kHz wav(存放 `/tmp/tts_output_*.wav`)
|
||||
- CPU 模式合成約 15-20 秒
|
||||
192
skills/tts-voice/handler.ts
Normal file
192
skills/tts-voice/handler.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* tts-voice skill
|
||||
* 文字轉語音:透過本機 LuxTTS API 合成語音
|
||||
*
|
||||
* 依賴:
|
||||
* - LuxTTS server running on port 7860 (systemd user service)
|
||||
* - ~/LuxTTS/.env (LUXTTS_USER, LUXTTS_PASS)
|
||||
* - curl CLI
|
||||
*/
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import { readFileSync, existsSync, unlinkSync } from 'fs';
|
||||
|
||||
const LUXTTS_BASE = 'http://localhost:7860';
|
||||
const REF_AUDIO = '/home/selig/LuxTTS/ref_speech.wav';
|
||||
const ENV_PATH = '/home/selig/LuxTTS/.env';
|
||||
const COOKIE_JAR = '/tmp/luxtts-skill-cookie';
|
||||
|
||||
// Trigger keywords to strip from user message
|
||||
const TRIGGER_WORDS = [
|
||||
'文字轉語音', '語音合成', '唸出來', '說出來', '轉語音',
|
||||
'tts', 'voice',
|
||||
];
|
||||
|
||||
// Speed/quality modifiers
|
||||
const MODIFIERS: Record<string, Partial<TtsParams>> = {
|
||||
'慢速': { speed: 0.8 },
|
||||
'slow': { speed: 0.8 },
|
||||
'快速': { speed: 1.3 },
|
||||
'fast': { speed: 1.3 },
|
||||
'高品質': { numSteps: 6, tShift: 0.95 },
|
||||
'hq': { numSteps: 6, tShift: 0.95 },
|
||||
};
|
||||
|
||||
interface TtsParams {
|
||||
speed: number;
|
||||
numSteps: number;
|
||||
tShift: number;
|
||||
}
|
||||
|
||||
/** Read credentials from .env */
|
||||
function loadCredentials(): { user: string; pass: string } {
|
||||
try {
|
||||
const content = readFileSync(ENV_PATH, 'utf-8');
|
||||
const user = content.match(/^LUXTTS_USER=(.+)$/m)?.[1]?.trim() || 'admin';
|
||||
const pass = content.match(/^LUXTTS_PASS=(.+)$/m)?.[1]?.trim() || '';
|
||||
return { user, pass };
|
||||
} catch {
|
||||
return { user: 'admin', pass: '' };
|
||||
}
|
||||
}
|
||||
|
||||
/** Ensure we have a valid session cookie */
|
||||
function ensureCookie(): boolean {
|
||||
const { user, pass } = loadCredentials();
|
||||
if (!pass) return false;
|
||||
|
||||
try {
|
||||
execSync(
|
||||
`curl -s -o /dev/null -w "%{http_code}" -c ${COOKIE_JAR} ` +
|
||||
`-d "username=${user}&password=${pass}" ` +
|
||||
`${LUXTTS_BASE}/luxtts/login`,
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
return existsSync(COOKIE_JAR);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if LuxTTS service is alive */
|
||||
function healthCheck(): boolean {
|
||||
try {
|
||||
const result = execSync(
|
||||
`curl -s -o /dev/null -w "%{http_code}" ${LUXTTS_BASE}/luxtts/api/health`,
|
||||
{ timeout: 5000 }
|
||||
).toString().trim();
|
||||
return result === '200';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Extract text and modifiers from user message */
|
||||
function parseMessage(message: string): { text: string; params: TtsParams } {
|
||||
let cleaned = message;
|
||||
const params: TtsParams = { speed: 1.0, numSteps: 4, tShift: 0.9 };
|
||||
|
||||
// Remove trigger words
|
||||
for (const trigger of TRIGGER_WORDS) {
|
||||
const re = new RegExp(trigger, 'gi');
|
||||
cleaned = cleaned.replace(re, '');
|
||||
}
|
||||
|
||||
// Extract and apply modifiers
|
||||
for (const [mod, overrides] of Object.entries(MODIFIERS)) {
|
||||
const re = new RegExp(mod, 'gi');
|
||||
if (re.test(cleaned)) {
|
||||
cleaned = cleaned.replace(re, '');
|
||||
Object.assign(params, overrides);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up
|
||||
cleaned = cleaned.replace(/^[\s::,,、]+/, '').replace(/[\s::,,、]+$/, '').trim();
|
||||
|
||||
return { text: cleaned, params };
|
||||
}
|
||||
|
||||
/** Call LuxTTS API to generate speech */
|
||||
function generateSpeech(text: string, params: TtsParams): string | null {
|
||||
const timestamp = Date.now();
|
||||
const outPath = `/tmp/tts_output_${timestamp}.wav`;
|
||||
|
||||
try {
|
||||
const httpCode = execSync(
|
||||
`curl -s -o ${outPath} -w "%{http_code}" ` +
|
||||
`-b ${COOKIE_JAR} ` +
|
||||
`-X POST ${LUXTTS_BASE}/luxtts/api/tts ` +
|
||||
`-F "ref_audio=@${REF_AUDIO}" ` +
|
||||
`-F "text=${text.replace(/"/g, '\\"')}" ` +
|
||||
`-F "num_steps=${params.numSteps}" ` +
|
||||
`-F "t_shift=${params.tShift}" ` +
|
||||
`-F "speed=${params.speed}"`,
|
||||
{ timeout: 120000 } // 2 min timeout for CPU synthesis
|
||||
).toString().trim();
|
||||
|
||||
if (httpCode === '200' && existsSync(outPath)) {
|
||||
return outPath;
|
||||
}
|
||||
|
||||
// Clean up failed output
|
||||
if (existsSync(outPath)) unlinkSync(outPath);
|
||||
return null;
|
||||
} catch {
|
||||
if (existsSync(outPath)) unlinkSync(outPath);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 主 handler ─────────────────────────────────────────────────────────────
|
||||
|
||||
export async function handler(ctx: any) {
|
||||
const message = ctx.message?.text || ctx.message?.content || '';
|
||||
|
||||
if (!message.trim()) {
|
||||
return { reply: '請提供要合成的文字,例如:「tts 你好,歡迎來到我的頻道」' };
|
||||
}
|
||||
|
||||
// Parse user input
|
||||
const { text, params } = parseMessage(message);
|
||||
|
||||
if (!text) {
|
||||
return { reply: '請提供要合成的文字,例如:「tts 你好,歡迎來到我的頻道」' };
|
||||
}
|
||||
|
||||
// Health check
|
||||
if (!healthCheck()) {
|
||||
return { reply: '❌ LuxTTS 服務未啟動,請先執行:`systemctl --user start luxtts`' };
|
||||
}
|
||||
|
||||
// Ensure authentication
|
||||
if (!ensureCookie()) {
|
||||
return { reply: '❌ LuxTTS 認證失敗,請檢查 ~/LuxTTS/.env 的帳密設定' };
|
||||
}
|
||||
|
||||
// Build parameter description
|
||||
const paramDesc: string[] = [];
|
||||
if (params.speed !== 1.0) paramDesc.push(`語速 ${params.speed}x`);
|
||||
if (params.numSteps !== 4) paramDesc.push(`步數 ${params.numSteps}`);
|
||||
const paramStr = paramDesc.length ? `(${paramDesc.join('、')})` : '';
|
||||
|
||||
// Generate
|
||||
const wavPath = generateSpeech(text, params);
|
||||
|
||||
if (!wavPath) {
|
||||
return {
|
||||
reply: `❌ 語音合成失敗,請稍後再試。`,
|
||||
metadata: { text, params, error: true },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
reply: `🔊 語音合成完成${paramStr}\n\n📝 文字:${text}\n📂 檔案:\`${wavPath}\``,
|
||||
metadata: {
|
||||
text,
|
||||
params,
|
||||
output: wavPath,
|
||||
},
|
||||
files: [wavPath],
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user