mirror of
https://github.com/xmanrui/OpenClaw-bot-review.git
synced 2026-08-14 00:47:49 +00:00
merge: local changes
This commit is contained in:
@@ -71,21 +71,10 @@ Open [http://localhost:3000](http://localhost:3000) in your browser.
|
||||
By default, the dashboard reads config from `~/.openclaw/openclaw.json`. To use a custom path, set the `OPENCLAW_HOME` environment variable:
|
||||
|
||||
```bash
|
||||
OPENCLAW_HOME=/opt/openclaw
|
||||
OPENCLAW_HOME=/opt/openclaw
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Gateway Chat URL
|
||||
|
||||
By default, session chat links point to `http://<your-lan-ip>:18789/chat?...`. If your OpenClaw gateway is accessible via a custom domain or reverse proxy (e.g. `https://openclaw.local`), create a `.env.local` file in the dashboard root:
|
||||
|
||||
```bash
|
||||
# .env.local
|
||||
NEXT_PUBLIC_GATEWAY_CHAT_BASE_URL=https://openclaw.local
|
||||
```
|
||||
|
||||
This overrides the auto-detected LAN address. The token is still appended automatically.
|
||||
|
||||
## Docker Deployment
|
||||
|
||||
You can also deploy the dashboard using Docker:
|
||||
@@ -178,17 +167,10 @@ npm run dev
|
||||
默认读取 `~/.openclaw/openclaw.json`,可通过环境变量指定自定义路径:
|
||||
|
||||
```bash
|
||||
OPENCLAW_HOME=/opt/openclaw
|
||||
OPENCLAW_HOME=/opt/openclaw
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Gateway 聊天链接地址
|
||||
|
||||
默认情况下,会话聊天链接指向 `http://<局域网IP>:18789/chat?...`。如果你的 Gateway 通过自定义域名或反向代理访问(例如 `https://openclaw.local`),在仪表盘根目录创建 `.env.local` 文件:
|
||||
|
||||
```bash
|
||||
# .env.local
|
||||
NEXT_PUBLIC_GATEWAY_CHAT_BASE_URL=https://openclaw.local
|
||||
```
|
||||
|
||||
设置后将覆盖自动检测的局域网地址,Token 仍会自动附加。
|
||||
## 作者联系方式(contact)
|
||||
小红书:[主页](https://xhslink.com/m/AsJKWgEBt1I)
|
||||
<br/>微信:xmanr123
|
||||
+230
-19
@@ -68,6 +68,7 @@ export interface AgentActivity {
|
||||
lastActive: number
|
||||
subagents?: SubagentInfo[]
|
||||
cronJobs?: CronJobInfo[]
|
||||
lastTask?: string
|
||||
}
|
||||
|
||||
type AgentConfigEntry = {
|
||||
@@ -821,6 +822,144 @@ async function parseCronJobs(agentSessionsDir: string, cronJobsForAgent: CronSto
|
||||
return cronJobs
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the actual user-typed text from a session message block.
|
||||
*
|
||||
* Handles three formats:
|
||||
* 1. Subagent spawn: contains "[Subagent Task]: ..." — extract what follows the label
|
||||
* 2. Channel message (Telegram etc.): injected "Conversation info" + "Sender" code fences
|
||||
* before the real text — extract what comes after the last ``` fence
|
||||
* 3. Fallback: strip XML context blocks and leading timestamp
|
||||
*/
|
||||
function extractUserText(rawText: string): string | null {
|
||||
// Strip XML context injections first (relevant-memories etc.)
|
||||
const noXml = rawText.replace(/<[a-z][\s\S]*?<\/[a-z][^>]*>/gi, '')
|
||||
|
||||
// Strategy 1: subagent task — "[Subagent Task]: ..."
|
||||
const subagentMatch = noXml.match(/\[Subagent Task\]:\s*([\s\S]+)/)
|
||||
if (subagentMatch) {
|
||||
return subagentMatch[1].replace(/\s+/g, ' ').trim().slice(0, 500)
|
||||
}
|
||||
|
||||
// Strategy 2: channel message — text after last ``` fence
|
||||
const lastFence = rawText.lastIndexOf('```')
|
||||
if (lastFence !== -1) {
|
||||
const afterFence = rawText.slice(lastFence + 3).trim()
|
||||
if (afterFence.length > 3) {
|
||||
return afterFence.replace(/\s+/g, ' ').trim().slice(0, 500)
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 3: strip timestamp prefix and return remaining
|
||||
const noTs = noXml.replace(/^\[[^\]]{5,40}\]\s*/, '').trim()
|
||||
const text = noTs.replace(/\s+/g, ' ').trim()
|
||||
return text.length > 5 ? text.slice(0, 500) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the last user message text from a session file — used as the agent's "last task".
|
||||
*
|
||||
* Priority:
|
||||
* 1. The original subagent spawn task ("[Subagent Task]: ...") — scan from the start
|
||||
* 2. Most recent real user message — scan from the end, skip system notifications
|
||||
*/
|
||||
async function extractLastUserTask(sessionFilePath: string): Promise<string | null> {
|
||||
try {
|
||||
const content = await fs.readFile(sessionFilePath, 'utf8')
|
||||
const allLines = content.split('\n').filter(l => l.trim())
|
||||
|
||||
// Pass 1: find the first [Subagent Task] (set at spawn time, stable throughout session)
|
||||
for (const line of allLines.slice(0, 40)) {
|
||||
try {
|
||||
const record = JSON.parse(line)
|
||||
if (record.type !== 'message' || !record.message) continue
|
||||
if (record.message.role !== 'user') continue
|
||||
const blocks = Array.isArray(record.message.content) ? record.message.content : []
|
||||
for (const block of blocks) {
|
||||
if (block?.type !== 'text' || typeof block.text !== 'string') continue
|
||||
if (!block.text.includes('[Subagent Task]')) continue
|
||||
const text = extractUserText(block.text)
|
||||
if (text) return text
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
|
||||
// Pass 2: most recent real user message (direct agents, e.g. main)
|
||||
const skipPhrases = ['A completed subagent task', 'Action:\n', 'END_UNTRUSTED_CHILD_RESULT', 'Continue where you left off']
|
||||
const recent = allLines.slice(-80)
|
||||
for (let i = recent.length - 1; i >= 0; i--) {
|
||||
try {
|
||||
const record = JSON.parse(recent[i])
|
||||
if (record.type !== 'message' || !record.message) continue
|
||||
if (record.message.role !== 'user') continue
|
||||
const blocks = Array.isArray(record.message.content) ? record.message.content : []
|
||||
for (const block of blocks) {
|
||||
if (block?.type !== 'text' || typeof block.text !== 'string') continue
|
||||
if (skipPhrases.some(p => block.text.includes(p))) continue
|
||||
const text = extractUserText(block.text)
|
||||
if (text) return text
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Read last N lines of a JSONL session file and determine the agent's true working state.
|
||||
*
|
||||
* Logic:
|
||||
* - Last message role is 'toolResult' → working (parent about to process tool output)
|
||||
* - Last assistant stopReason is 'toolUse' → working (tool call in flight)
|
||||
* - Last assistant stopReason is 'stop' → idle (turn completed, waiting for next input)
|
||||
* - Fallback: time-based heuristic
|
||||
*/
|
||||
async function detectStateFromSession(
|
||||
sessionFilePath: string,
|
||||
now: number,
|
||||
lastActive: number,
|
||||
): Promise<'idle' | 'working' | 'offline'> {
|
||||
if (lastActive === 0) return 'offline'
|
||||
|
||||
const OFFLINE_MS = 10 * 60 * 1000 // idle > 10 min → offline
|
||||
const WORKING_MAX_MS = 10 * 60 * 1000 // working > 10 min → force idle
|
||||
const timeDiff = now - lastActive
|
||||
|
||||
// Last activity > 10 min ago → offline regardless of session content
|
||||
if (timeDiff > OFFLINE_MS) return 'offline'
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(sessionFilePath, 'utf8')
|
||||
const lines = content.split('\n').filter(l => l.trim()).slice(-30)
|
||||
|
||||
let lastRole: string | null = null
|
||||
let lastStopReason: string | null = null
|
||||
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
try {
|
||||
const record = JSON.parse(lines[i])
|
||||
if (record.type !== 'message' || !record.message) continue
|
||||
const { role, stopReason } = record.message
|
||||
if (!lastRole) lastRole = role ?? null
|
||||
if (role === 'assistant') {
|
||||
lastStopReason = stopReason ?? null
|
||||
break
|
||||
}
|
||||
} catch { /* skip malformed line */ }
|
||||
}
|
||||
|
||||
// Work completed → immediately idle
|
||||
if (lastStopReason === 'stop') return 'idle'
|
||||
// Still processing — but cap at 10 min, after that force idle
|
||||
if (lastRole === 'user' || lastRole === 'toolResult' || lastStopReason === 'toolUse') {
|
||||
return timeDiff <= WORKING_MAX_MS ? 'working' : 'idle'
|
||||
}
|
||||
} catch { /* file unreadable — fall through */ }
|
||||
|
||||
// Fallback within the 10-min window
|
||||
return timeDiff <= 2 * 60 * 1000 ? 'working' : 'idle'
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const configPath = OPENCLAW_CONFIG_PATH
|
||||
const agentsDir = OPENCLAW_AGENTS_DIR
|
||||
@@ -839,34 +978,99 @@ export async function GET() {
|
||||
|
||||
for (const agent of agentList) {
|
||||
let lastActive = 0
|
||||
let mostRecentSessionFile: string | null = null
|
||||
let agentSessionsDir = ''
|
||||
|
||||
// Resolve emoji: IDENTITY.md > agent.json > openclaw.json > default
|
||||
let agentJsonEmoji: string | undefined
|
||||
if (existsSync(agentsDir)) {
|
||||
agentSessionsDir = path.join(agentsDir, agent.id, 'sessions')
|
||||
if (existsSync(agentSessionsDir)) {
|
||||
try {
|
||||
const files = await fs.readdir(agentSessionsDir)
|
||||
for (const file of files) {
|
||||
const filePath = path.join(agentSessionsDir, file)
|
||||
const stat = await fs.stat(filePath)
|
||||
if (stat.mtimeMs > lastActive) {
|
||||
lastActive = stat.mtimeMs
|
||||
// 1. Read from workspace IDENTITY.md ("- **Emoji:** 🌸")
|
||||
const workspaceDir = typeof (agent as any).workspace === 'string' ? (agent as any).workspace : null
|
||||
if (workspaceDir) {
|
||||
const identityPath = path.join(workspaceDir, 'IDENTITY.md')
|
||||
if (existsSync(identityPath)) {
|
||||
try {
|
||||
const identityRaw = await fs.readFile(identityPath, 'utf8')
|
||||
const m = identityRaw.match(/\*\*Emoji:\*\*\s*(\S+)/)
|
||||
if (m?.[1]) agentJsonEmoji = m[1]
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
// 2. Fallback: read from agent's agent.json emoji field
|
||||
if (!agentJsonEmoji) {
|
||||
const agentJsonPath = path.join(agentsDir, agent.id, 'agent', 'agent.json')
|
||||
if (existsSync(agentJsonPath)) {
|
||||
try {
|
||||
const raw = await fs.readFile(agentJsonPath, 'utf8')
|
||||
const parsed = JSON.parse(raw)
|
||||
if (typeof parsed?.emoji === 'string' && parsed.emoji.trim()) {
|
||||
agentJsonEmoji = parsed.emoji.trim()
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (existsSync(agentsDir)) {
|
||||
agentSessionsDir = path.join(agentsDir, agent.id, 'sessions')
|
||||
if (existsSync(agentSessionsDir)) {
|
||||
// Use JSONL file mtime for lastActive (reliable, updates on every message write).
|
||||
// Also build a map from sessionId → filePath using sessions.json, so we can
|
||||
// pick the correct file for content-based state detection.
|
||||
const sessionIdToFile = new Map<string, string>()
|
||||
try {
|
||||
const sessionsIndexPath = path.join(agentSessionsDir, 'sessions.json')
|
||||
if (existsSync(sessionsIndexPath)) {
|
||||
const raw = await fs.readFile(sessionsIndexPath, 'utf8')
|
||||
const index = JSON.parse(raw) as Record<string, { sessionId?: string }>
|
||||
for (const [, meta] of Object.entries(index)) {
|
||||
if (typeof meta.sessionId === 'string') {
|
||||
sessionIdToFile.set(meta.sessionId, path.join(agentSessionsDir, `${meta.sessionId}.jsonl`))
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
try {
|
||||
const files = await fs.readdir(agentSessionsDir)
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.jsonl')) continue
|
||||
const filePath = path.join(agentSessionsDir, file)
|
||||
const stat = await fs.stat(filePath)
|
||||
if (stat.mtimeMs > lastActive) {
|
||||
lastActive = stat.mtimeMs
|
||||
mostRecentSessionFile = filePath
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// Prefer the sessions.json-mapped file over raw scan when available
|
||||
// (ensures we read a proper session, not a probe or temp file)
|
||||
if (mostRecentSessionFile) {
|
||||
const sessionId = path.basename(mostRecentSessionFile, '.jsonl')
|
||||
if (!sessionIdToFile.has(sessionId)) {
|
||||
// Most-recent file is not in sessions.json — find best known session by mtime
|
||||
let bestMtime = 0
|
||||
for (const [, fp] of sessionIdToFile) {
|
||||
try {
|
||||
const s = await fs.stat(fp)
|
||||
if (s.mtimeMs > bestMtime) { bestMtime = s.mtimeMs; mostRecentSessionFile = fp }
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine state from session content (falls back to time-based)
|
||||
let state: 'idle' | 'working' | 'waiting' | 'offline'
|
||||
const timeDiff = now - lastActive
|
||||
if (lastActive === 0 || timeDiff > 10 * 60 * 1000) {
|
||||
state = 'offline'
|
||||
} else if (timeDiff <= 2 * 60 * 1000) {
|
||||
state = 'working'
|
||||
if (mostRecentSessionFile && existsSync(mostRecentSessionFile)) {
|
||||
state = await detectStateFromSession(mostRecentSessionFile, now, lastActive)
|
||||
} else {
|
||||
state = 'idle'
|
||||
const timeDiff = now - lastActive
|
||||
if (lastActive === 0 || timeDiff > 10 * 60 * 1000) state = 'offline'
|
||||
else if (timeDiff <= 2 * 60 * 1000) state = 'working'
|
||||
else state = 'idle'
|
||||
}
|
||||
|
||||
// Parse subagents for online agents
|
||||
@@ -882,14 +1086,21 @@ export async function GET() {
|
||||
if (cronJobs.length === 0) cronJobs = undefined
|
||||
}
|
||||
|
||||
// Extract last user task for working agents
|
||||
let lastTask: string | undefined
|
||||
if (state === 'working' && mostRecentSessionFile && existsSync(mostRecentSessionFile)) {
|
||||
lastTask = (await extractLastUserTask(mostRecentSessionFile)) ?? undefined
|
||||
}
|
||||
|
||||
agents.push({
|
||||
agentId: agent.id,
|
||||
name: agent.name || agent.id,
|
||||
emoji: agent.identity?.emoji || agent.emoji || '🤖',
|
||||
emoji: agentJsonEmoji || agent.identity?.emoji || agent.emoji || '🤖',
|
||||
state,
|
||||
lastActive,
|
||||
subagents,
|
||||
cronJobs,
|
||||
lastTask,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+25
-4
@@ -3,6 +3,7 @@ import fs from "fs";
|
||||
import path from "path";
|
||||
import { getConfigCache, setConfigCache } from "@/lib/config-cache";
|
||||
import { OPENCLAW_CONFIG_PATH, OPENCLAW_HOME } from "@/lib/openclaw-paths";
|
||||
import { shouldHidePlatformChannel } from "@/lib/platforms";
|
||||
|
||||
// 配置文件路径:优先使用 OPENCLAW_HOME 环境变量,否则默认 ~/.openclaw
|
||||
const CONFIG_PATH = OPENCLAW_CONFIG_PATH;
|
||||
@@ -230,6 +231,25 @@ function getChannelDirectPeerIds(
|
||||
return map;
|
||||
}
|
||||
// 从 IDENTITY.md 读取机器人名字
|
||||
function readIdentityEmoji(agentId: string, agentDir?: string, workspace?: string): string | null {
|
||||
const candidates = [
|
||||
agentDir ? path.join(agentDir, "IDENTITY.md") : null,
|
||||
workspace ? path.join(workspace, "IDENTITY.md") : null,
|
||||
path.join(OPENCLAW_DIR, `agents/${agentId}/agent/IDENTITY.md`),
|
||||
path.join(OPENCLAW_DIR, `workspace-${agentId}/IDENTITY.md`),
|
||||
agentId === "main" ? path.join(OPENCLAW_DIR, `workspace/IDENTITY.md`) : null,
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
for (const p of candidates) {
|
||||
try {
|
||||
const content = fs.readFileSync(p, "utf-8");
|
||||
const match = content.match(/\*\*Emoji:\*\*\s*(\S+)/);
|
||||
if (match?.[1]) return match[1].trim();
|
||||
} catch {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readIdentityName(agentId: string, agentDir?: string, workspace?: string): string | null {
|
||||
const candidates = [
|
||||
agentDir ? path.join(agentDir, "IDENTITY.md") : null,
|
||||
@@ -329,12 +349,12 @@ export async function GET() {
|
||||
// 从预读的 sessions 数据获取飞书用户 open_id
|
||||
const feishuUserOpenIds = getFeishuUserOpenIds(agentIds, sessionsMap);
|
||||
const enabledChannelNames: string[] = Object.entries(channels)
|
||||
.filter(([, cfg]) => cfg && typeof cfg === "object" && (cfg as any).enabled !== false)
|
||||
.filter(([channelName, cfg]) => cfg && typeof cfg === "object" && (cfg as any).enabled !== false && !shouldHidePlatformChannel(channelName, channels))
|
||||
.map(([channelName]) => channelName);
|
||||
const boundChannelNames: string[] = Array.from(new Set(
|
||||
bindings
|
||||
.map((b: any) => b.match?.channel)
|
||||
.filter((v: any): v is string => typeof v === "string" && v.length > 0)
|
||||
.filter((v: any): v is string => typeof v === "string" && v.length > 0 && !shouldHidePlatformChannel(v, channels))
|
||||
));
|
||||
const discoverChannelNames: string[] = Array.from(new Set([...enabledChannelNames, ...boundChannelNames]));
|
||||
const directPeerIdsByChannel: Record<string, Record<string, string>> = {};
|
||||
@@ -349,7 +369,8 @@ export async function GET() {
|
||||
const id = agent.id;
|
||||
const identityName = readIdentityName(id, agent.agentDir, agent.workspace);
|
||||
const name = identityName || agent.name || id;
|
||||
const emoji = agent.identity?.emoji || "🤖";
|
||||
const identityEmoji = readIdentityEmoji(id, agent.agentDir, agent.workspace);
|
||||
const emoji = identityEmoji || agent.identity?.emoji || agent.emoji || "🤖";
|
||||
const model = normalizeModelRef(agent.model, defaultModel);
|
||||
|
||||
// 查找绑定的平台
|
||||
@@ -406,7 +427,7 @@ export async function GET() {
|
||||
for (const binding of bindings) {
|
||||
if (binding?.agentId !== id) continue;
|
||||
const channelName = binding?.match?.channel;
|
||||
if (!channelName || channelName === "feishu") continue;
|
||||
if (!channelName || channelName === "feishu" || shouldHidePlatformChannel(channelName, channels)) continue;
|
||||
if (seenBindingChannels.has(channelName)) continue;
|
||||
seenBindingChannels.add(channelName);
|
||||
const botUserId = directPeerIdsByChannel[channelName]?.[id] || null;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { readFileSync } from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
|
||||
const LOG_PATH = path.join(os.homedir(), ".openclaw/logs/gateway.err.log");
|
||||
const TAIL_LINES = 120;
|
||||
const STALL_RECENT_MS = 10 * 60 * 1000; // consider stall "active" if within last 10 min
|
||||
|
||||
const PATTERNS = [
|
||||
{ re: /Polling stall detected/, issue: "telegram_stall" },
|
||||
{ re: /sendChatAction failed: Network request/, issue: "telegram_network" },
|
||||
{ re: /gateway timeout after \d+ms/, issue: "subagent_timeout" },
|
||||
] as const;
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const content = readFileSync(LOG_PATH, "utf8");
|
||||
const lines = content.split("\n").filter(Boolean).slice(-TAIL_LINES);
|
||||
|
||||
const issues = new Set<string>();
|
||||
for (const line of lines) {
|
||||
for (const { re, issue } of PATTERNS) {
|
||||
if (re.test(line)) issues.add(issue);
|
||||
}
|
||||
}
|
||||
|
||||
// Find timestamp of most recent stall line
|
||||
let lastStallAt: string | null = null;
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
if (/Polling stall detected/.test(lines[i])) {
|
||||
const m = lines[i].match(/^(\d{4}-\d{2}-\d{2}T[\d:.+]+)/);
|
||||
if (m) { lastStallAt = m[1]; break; }
|
||||
}
|
||||
}
|
||||
|
||||
// Only treat stall as active if it happened recently
|
||||
const stallActive = lastStallAt
|
||||
? Date.now() - new Date(lastStallAt).getTime() < STALL_RECENT_MS
|
||||
: false;
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
issues: [...issues],
|
||||
lastStallAt,
|
||||
stallActive,
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ ok: false, issues: [], lastStallAt: null, stallActive: false });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { execFile, exec, spawn } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const execAsync = promisify(exec);
|
||||
const LAUNCHCTL = "/bin/launchctl";
|
||||
const PLIST = path.join(os.homedir(), "Library/LaunchAgents/ai.openclaw.gateway.plist");
|
||||
|
||||
const EXTRA_PATH = `${process.env.PATH || ""}:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin`;
|
||||
|
||||
/** Kill any running gateway process by name */
|
||||
async function killGatewayProcess(): Promise<void> {
|
||||
try {
|
||||
const { stdout } = await execAsync("pgrep -f 'openclaw.gateway\\|openclaw-gateway'");
|
||||
const pids = stdout.trim().split("\n").filter(Boolean);
|
||||
if (pids.length > 0) {
|
||||
await execAsync(`kill ${pids.join(" ")}`);
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
} catch { /* no process running — ok */ }
|
||||
}
|
||||
|
||||
/** Find openclaw binary in PATH */
|
||||
async function findOpenclawBin(): Promise<string> {
|
||||
try {
|
||||
const { stdout } = await execAsync("which openclaw", { env: { ...process.env, PATH: EXTRA_PATH } });
|
||||
return stdout.trim();
|
||||
} catch {
|
||||
return "openclaw";
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
const hasPlist = fs.existsSync(PLIST);
|
||||
|
||||
if (hasPlist) {
|
||||
// Plist exists — use launchctl to reload (works whether currently loaded or not)
|
||||
try { await execFileAsync(LAUNCHCTL, ["unload", PLIST]); } catch { /* ignore if already unloaded */ }
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
await execFileAsync(LAUNCHCTL, ["load", PLIST]);
|
||||
} else {
|
||||
// No plist — kill and restart directly
|
||||
await killGatewayProcess();
|
||||
const bin = await findOpenclawBin();
|
||||
const child = spawn(bin, ["gateway"], {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
env: { ...process.env, PATH: EXTRA_PATH },
|
||||
});
|
||||
child.unref();
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, method: hasPlist ? "launchd" : "direct" });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ ok: false, error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getOpenclawSkillContent } from "@/lib/openclaw-skills";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const source = (searchParams.get("source") || "").trim();
|
||||
const id = (searchParams.get("id") || "").trim();
|
||||
|
||||
if (!source || !id) {
|
||||
return NextResponse.json({ error: "Missing source or id" }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = getOpenclawSkillContent(source, id);
|
||||
if (!result) {
|
||||
return NextResponse.json({ error: "Skill not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
id: result.skill.id,
|
||||
name: result.skill.name,
|
||||
source: result.skill.source,
|
||||
content: result.content,
|
||||
});
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
+2
-156
@@ -1,163 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { getOpenclawPackageCandidates, OPENCLAW_CONFIG_PATH, OPENCLAW_HOME } from "@/lib/openclaw-paths";
|
||||
|
||||
// Find OpenClaw package directory
|
||||
function findOpenClawPkg(): string {
|
||||
// Check common locations
|
||||
const candidates = getOpenclawPackageCandidates();
|
||||
for (const c of candidates) {
|
||||
if (fs.existsSync(path.join(c, "package.json"))) return c;
|
||||
}
|
||||
// Fallback: try to find via which
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
const OPENCLAW_PKG = findOpenClawPkg();
|
||||
|
||||
interface SkillInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
emoji: string;
|
||||
source: string; // "builtin" | "extension" | "custom"
|
||||
location: string;
|
||||
usedBy: string[]; // agent ids
|
||||
}
|
||||
|
||||
function parseFrontmatter(content: string): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
if (!content.startsWith("---")) return result;
|
||||
const parts = content.split("---", 3);
|
||||
if (parts.length < 3) return result;
|
||||
const fm = parts[1];
|
||||
|
||||
const nameMatch = fm.match(/^name:\s*(.+)/m);
|
||||
if (nameMatch) result.name = nameMatch[1].trim().replace(/^["']|["']$/g, "");
|
||||
|
||||
const descMatch = fm.match(/^description:\s*["']?(.+?)["']?\s*$/m);
|
||||
if (descMatch) result.description = descMatch[1].trim().replace(/^["']|["']$/g, "");
|
||||
|
||||
const emojiMatch = fm.match(/"emoji":\s*"([^"]+)"/);
|
||||
if (emojiMatch) result.emoji = emojiMatch[1];
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function scanSkillsDir(dir: string, source: string): SkillInfo[] {
|
||||
const skills: SkillInfo[] = [];
|
||||
if (!fs.existsSync(dir)) return skills;
|
||||
for (const name of fs.readdirSync(dir).sort()) {
|
||||
const skillMd = path.join(dir, name, "SKILL.md");
|
||||
if (!fs.existsSync(skillMd)) continue;
|
||||
const content = fs.readFileSync(skillMd, "utf-8");
|
||||
const fm = parseFrontmatter(content);
|
||||
skills.push({
|
||||
id: name,
|
||||
name: fm.name || name,
|
||||
description: fm.description || "",
|
||||
emoji: fm.emoji || "🔧",
|
||||
source,
|
||||
location: skillMd,
|
||||
usedBy: [],
|
||||
});
|
||||
}
|
||||
return skills;
|
||||
}
|
||||
|
||||
function getAgentSkillsFromSessions(): Record<string, Set<string>> {
|
||||
// Parse skillsSnapshot from session JSONL files
|
||||
const agentsDir = path.join(OPENCLAW_HOME, "agents");
|
||||
const result: Record<string, Set<string>> = {};
|
||||
if (!fs.existsSync(agentsDir)) return result;
|
||||
|
||||
for (const agentId of fs.readdirSync(agentsDir)) {
|
||||
const sessionsDir = path.join(agentsDir, agentId, "sessions");
|
||||
if (!fs.existsSync(sessionsDir)) continue;
|
||||
|
||||
const jsonlFiles = fs.readdirSync(sessionsDir)
|
||||
.filter(f => f.endsWith(".jsonl"))
|
||||
.sort();
|
||||
const skillNames = new Set<string>();
|
||||
|
||||
// Check the most recent session files for skillsSnapshot
|
||||
for (const file of jsonlFiles.slice(-3)) {
|
||||
const content = fs.readFileSync(path.join(sessionsDir, file), "utf-8");
|
||||
const idx = content.indexOf("skillsSnapshot");
|
||||
if (idx < 0) continue;
|
||||
const chunk = content.slice(idx, idx + 5000);
|
||||
// Match skill names in escaped JSON: \"name\":\"xxx\" or "name":"xxx"
|
||||
const matches = chunk.matchAll(/\\?"name\\?":\s*\\?"([^"\\]+)\\?"/g);
|
||||
for (const m of matches) {
|
||||
const name = m[1];
|
||||
// Filter out tool names and other non-skill entries
|
||||
if (!["exec","read","edit","write","process","message","web_search","web_fetch",
|
||||
"browser","tts","gateway","memory_search","memory_get","cron","nodes",
|
||||
"canvas","session_status","sessions_list","sessions_history","sessions_send",
|
||||
"sessions_spawn","agents_list"].includes(name) && name.length > 1) {
|
||||
skillNames.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (skillNames.size > 0) {
|
||||
result[agentId] = skillNames;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
import { listOpenclawSkills } from "@/lib/openclaw-skills";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// 1. Scan builtin skills
|
||||
const builtinDir = path.join(OPENCLAW_PKG, "skills");
|
||||
const builtinSkills = scanSkillsDir(builtinDir, "builtin");
|
||||
|
||||
// 2. Scan extension skills
|
||||
const extDir = path.join(OPENCLAW_PKG, "extensions");
|
||||
const extSkills: SkillInfo[] = [];
|
||||
if (fs.existsSync(extDir)) {
|
||||
for (const ext of fs.readdirSync(extDir)) {
|
||||
const skillsDir = path.join(extDir, ext, "skills");
|
||||
if (fs.existsSync(skillsDir)) {
|
||||
const skills = scanSkillsDir(skillsDir, `extension:${ext}`);
|
||||
extSkills.push(...skills);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Scan custom skills (~/.openclaw/skills)
|
||||
const customDir = path.join(OPENCLAW_HOME, "skills");
|
||||
const customSkills = scanSkillsDir(customDir, "custom");
|
||||
|
||||
const allSkills = [...builtinSkills, ...extSkills, ...customSkills];
|
||||
|
||||
// 4. Map agent usage from session data
|
||||
const agentSkills = getAgentSkillsFromSessions();
|
||||
for (const skill of allSkills) {
|
||||
for (const [agentId, skills] of Object.entries(agentSkills)) {
|
||||
if (skills.has(skill.id) || skills.has(skill.name)) {
|
||||
skill.usedBy.push(agentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Get agent info for display
|
||||
const configPath = OPENCLAW_CONFIG_PATH;
|
||||
const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
||||
const agentList = config.agents?.list || [];
|
||||
const agentMap: Record<string, { name: string; emoji: string }> = {};
|
||||
for (const a of agentList) {
|
||||
const name = a.identity?.name || a.name || a.id;
|
||||
const emoji = a.identity?.emoji || "🤖";
|
||||
agentMap[a.id] = { name, emoji };
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
skills: allSkills,
|
||||
agents: agentMap,
|
||||
total: allSkills.length,
|
||||
});
|
||||
return NextResponse.json(listOpenclawSkills());
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import fs from "fs";
|
||||
import path from "path";
|
||||
import { OPENCLAW_CONFIG_PATH, OPENCLAW_HOME } from "@/lib/openclaw-paths";
|
||||
import { parseApiJsonSafely, shouldFallbackToCli, testSessionViaCli } from "@/lib/session-test-fallback";
|
||||
import { shouldHidePlatformChannel } from "@/lib/platforms";
|
||||
const CONFIG_PATH = OPENCLAW_CONFIG_PATH;
|
||||
|
||||
interface DmSessionResult {
|
||||
@@ -108,14 +109,20 @@ export async function POST() {
|
||||
}
|
||||
|
||||
const results: DmSessionResult[] = [];
|
||||
const platformsToTest = ["feishu", "discord", "telegram", "whatsapp", "qqbot"];
|
||||
const platformsToTest = Array.from(new Set([
|
||||
...Object.entries(channels)
|
||||
.filter(([name, cfg]) => cfg && typeof cfg === "object" && (cfg as any).enabled !== false && !shouldHidePlatformChannel(name, channels))
|
||||
.map(([name]) => name),
|
||||
...bindings
|
||||
.map((b: any) => b?.match?.channel)
|
||||
.filter((name: unknown): name is string => typeof name === "string" && name.length > 0 && !shouldHidePlatformChannel(name, channels)),
|
||||
]));
|
||||
|
||||
for (const agent of agentList) {
|
||||
const id = agent.id;
|
||||
for (const platform of platformsToTest) {
|
||||
// Check if this agent has this platform configured
|
||||
const ch = channels[platform];
|
||||
if (!ch || ch.enabled === false) continue;
|
||||
if (ch && ch.enabled === false) continue;
|
||||
|
||||
const isMain = id === "main";
|
||||
const hasBinding = bindings.some(
|
||||
|
||||
+628
-186
@@ -1,43 +1,17 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { execSync } from "child_process";
|
||||
import { execFileSync } from "child_process";
|
||||
import { pathToFileURL } from "url";
|
||||
import { OPENCLAW_CONFIG_PATH, OPENCLAW_HOME } from "@/lib/openclaw-paths";
|
||||
import { shouldHidePlatformChannel } from "@/lib/platforms";
|
||||
const CONFIG_PATH = OPENCLAW_CONFIG_PATH;
|
||||
|
||||
function resolveSecretRef(value: any): string | null {
|
||||
if (typeof value === "string") return value;
|
||||
if (!value || typeof value !== "object") return null;
|
||||
try {
|
||||
// env: read from environment variable
|
||||
if (value.source === "env" && value.id) {
|
||||
return process.env[value.id] || null;
|
||||
}
|
||||
// file: read from file path
|
||||
if (value.source === "file" && value.id) {
|
||||
return fs.readFileSync(value.id, "utf-8").trim() || null;
|
||||
}
|
||||
// exec + keychain: macOS Keychain (security command)
|
||||
if (value.source === "exec" && value.provider === "keychain" && value.id) {
|
||||
if (process.platform !== "darwin") return null;
|
||||
const result = execSync(`security find-generic-password -a ${JSON.stringify(value.id)} -w`, { encoding: "utf8" }).trim();
|
||||
return result || null;
|
||||
}
|
||||
// exec + 1password: op CLI
|
||||
if (value.source === "exec" && value.provider === "1password" && value.id) {
|
||||
const result = execSync(`op read ${JSON.stringify(value.id)}`, { encoding: "utf8" }).trim();
|
||||
return result || null;
|
||||
}
|
||||
// exec + pass: Unix pass store
|
||||
if (value.source === "exec" && value.provider === "pass" && value.id) {
|
||||
const result = execSync(`pass show ${JSON.stringify(value.id)}`, { encoding: "utf8" }).split("\n")[0].trim();
|
||||
return result || null;
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
const QQBOT_TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken";
|
||||
const QQBOT_API_BASE = "https://api.sgroup.qq.com";
|
||||
const YUANBAO_PLUGIN_DIST_DIR = path.join(OPENCLAW_HOME, "extensions/openclaw-plugin-yuanbao/dist/src");
|
||||
const DEFAULT_YUANBAO_API_DOMAIN = "bot.yuanbao.tencent.com";
|
||||
const DEFAULT_YUANBAO_WS_URL = "wss://bot-wss.yuanbao.tencent.com/wss/connection";
|
||||
const importExternalModule = new Function("modulePath", "return import(modulePath)") as (modulePath: string) => Promise<any>;
|
||||
|
||||
interface PlatformTestResult {
|
||||
agentId: string;
|
||||
@@ -49,6 +23,79 @@ interface PlatformTestResult {
|
||||
elapsed: number;
|
||||
}
|
||||
|
||||
interface YuanbaoDmContext {
|
||||
target: string;
|
||||
accountId: string | null;
|
||||
}
|
||||
|
||||
function runOpenClawMessageSend(channel: string, target: string, message: string, extraArgs: string[] = []): string {
|
||||
const args = [
|
||||
"message", "send",
|
||||
"--channel", channel,
|
||||
"-t", target,
|
||||
"--message", message,
|
||||
"--json",
|
||||
...extraArgs,
|
||||
];
|
||||
|
||||
return execFileSync("openclaw", args, {
|
||||
timeout: 30000,
|
||||
encoding: "utf-8",
|
||||
env: { ...process.env },
|
||||
});
|
||||
}
|
||||
|
||||
async function probeGatewayWebUi(port: number, token: string, timeoutMs = 5000): Promise<{ ok: boolean; error?: string }> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`http://localhost:${port}/chat${token ? `?token=${encodeURIComponent(token)}` : ""}`,
|
||||
{ signal: controller.signal, cache: "no-store", redirect: "manual" },
|
||||
);
|
||||
return resp.status >= 200 && resp.status < 400
|
||||
? { ok: true }
|
||||
: { ok: false, error: `HTTP ${resp.status}` };
|
||||
} catch (err: any) {
|
||||
return { ok: false, error: err?.message || "Failed to reach gateway web UI" };
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function runCurlJson(url: string, options: { method?: string; headers?: string[]; body?: string; timeoutSec?: number } = {}): { status: number; data: any; raw: string } {
|
||||
const args = [
|
||||
'-sS',
|
||||
'--connect-timeout', String(options.timeoutSec ?? 10),
|
||||
'--max-time', String(options.timeoutSec ?? 20),
|
||||
'-X', options.method || 'GET',
|
||||
];
|
||||
|
||||
for (const header of options.headers || []) {
|
||||
args.push('-H', header);
|
||||
}
|
||||
if (typeof options.body === 'string') {
|
||||
args.push('--data-raw', options.body);
|
||||
}
|
||||
args.push('-w', '\\n%{http_code}', url);
|
||||
|
||||
const raw = execFileSync('curl', args, {
|
||||
timeout: (options.timeoutSec ?? 20) * 1000 + 1000,
|
||||
encoding: 'utf-8',
|
||||
env: { ...process.env },
|
||||
});
|
||||
const cut = raw.lastIndexOf('\n');
|
||||
const body = cut >= 0 ? raw.slice(0, cut) : raw;
|
||||
const status = Number(cut >= 0 ? raw.slice(cut + 1).trim() : 0);
|
||||
let data: any = null;
|
||||
try {
|
||||
data = body ? JSON.parse(body) : null;
|
||||
} catch {
|
||||
data = null;
|
||||
}
|
||||
return { status, data, raw: body };
|
||||
}
|
||||
|
||||
// Find the most recent feishu DM user open_id for a given agent
|
||||
// Each feishu app has its own open_id namespace, so we must use per-agent open_ids
|
||||
function getFeishuDmUser(agentId: string): string | null {
|
||||
@@ -179,101 +226,277 @@ async function testFeishu(
|
||||
}
|
||||
}
|
||||
|
||||
// Discord: call /users/@me then send a DM to test user
|
||||
// Discord: use curl so the host proxy settings are honored consistently
|
||||
async function testDiscord(
|
||||
agentId: string,
|
||||
botToken: string,
|
||||
testUserId: string | null
|
||||
testUserId: string | null,
|
||||
recipientSource: "session" | "allowFrom" | "none"
|
||||
): Promise<PlatformTestResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const meResp = await fetch("https://discord.com/api/v10/users/@me", {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bot ${botToken}` },
|
||||
signal: AbortSignal.timeout(15000),
|
||||
const meResp = runCurlJson('https://discord.com/api/v10/users/@me', {
|
||||
headers: [`Authorization: Bot ${botToken}`],
|
||||
timeoutSec: 15,
|
||||
});
|
||||
|
||||
const meData = await meResp.json();
|
||||
if (!meResp.ok || !meData.id) {
|
||||
const meData = meResp.data;
|
||||
if (meResp.status < 200 || meResp.status >= 300 || !meData?.id) {
|
||||
return {
|
||||
agentId, platform: "discord", ok: false,
|
||||
error: `Discord API error: ${meData.message || JSON.stringify(meData)}`,
|
||||
agentId, platform: 'discord', ok: false,
|
||||
error: `Discord API error: ${meData?.message || meResp.raw || `HTTP ${meResp.status}`}`,
|
||||
elapsed: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
|
||||
const botName = `${meData.username}#${meData.discriminator || "0"}`;
|
||||
const botName = `${meData.username}#${meData.discriminator || '0'}`;
|
||||
|
||||
if (!testUserId) {
|
||||
return {
|
||||
agentId, platform: "discord", ok: true,
|
||||
agentId, platform: 'discord', ok: true,
|
||||
detail: `${botName} (bot reachable, no test user for DM)`,
|
||||
elapsed: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
|
||||
// Create DM channel
|
||||
const dmChanResp = await fetch("https://discord.com/api/v10/users/@me/channels", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bot ${botToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
const dmChanResp = runCurlJson('https://discord.com/api/v10/users/@me/channels', {
|
||||
method: 'POST',
|
||||
headers: [
|
||||
`Authorization: Bot ${botToken}`,
|
||||
'Content-Type: application/json',
|
||||
],
|
||||
body: JSON.stringify({ recipient_id: testUserId }),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
timeoutSec: 15,
|
||||
});
|
||||
|
||||
const dmChan = await dmChanResp.json();
|
||||
if (!dmChanResp.ok || !dmChan.id) {
|
||||
const dmChan = dmChanResp.data;
|
||||
if (dmChanResp.status < 200 || dmChanResp.status >= 300 || !dmChan?.id) {
|
||||
return {
|
||||
agentId, platform: "discord", ok: false,
|
||||
error: `Create DM channel failed: ${dmChan.message || JSON.stringify(dmChan)}`,
|
||||
agentId, platform: 'discord', ok: false,
|
||||
error: `Create DM channel failed: ${dmChan?.message || dmChanResp.raw || `HTTP ${dmChanResp.status}`}`,
|
||||
elapsed: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
|
||||
const now = new Date().toLocaleTimeString("zh-CN", { timeZone: "Asia/Shanghai" });
|
||||
const msgResp = await fetch(
|
||||
`https://discord.com/api/v10/channels/${dmChan.id}/messages`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bot ${botToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content: `[Platform Test] ${botName} connectivity test ✅ (${now})`,
|
||||
}),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
}
|
||||
);
|
||||
|
||||
const msgData = await msgResp.json();
|
||||
const now = new Date().toLocaleTimeString('zh-CN', { timeZone: 'Asia/Shanghai' });
|
||||
const msgResp = runCurlJson(`https://discord.com/api/v10/channels/${dmChan.id}/messages`, {
|
||||
method: 'POST',
|
||||
headers: [
|
||||
`Authorization: Bot ${botToken}`,
|
||||
'Content-Type: application/json',
|
||||
],
|
||||
body: JSON.stringify({
|
||||
content: `[Platform Test] ${botName} connectivity test ✅ (${now})`,
|
||||
flags: 4096,
|
||||
}),
|
||||
timeoutSec: 15,
|
||||
});
|
||||
const msgData = msgResp.data;
|
||||
const elapsed = Date.now() - startTime;
|
||||
|
||||
if (msgResp.ok && msgData.id) {
|
||||
if (msgResp.status >= 200 && msgResp.status < 300 && msgData?.id) {
|
||||
const sourceLabel = recipientSource === 'allowFrom' ? 'allowFrom' : 'session';
|
||||
return {
|
||||
agentId, platform: "discord", ok: true,
|
||||
detail: `${botName} → DM sent (${elapsed}ms)`,
|
||||
elapsed,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
agentId, platform: "discord", ok: false,
|
||||
error: `Send DM failed: ${msgData.message || JSON.stringify(msgData)}`,
|
||||
agentId, platform: 'discord', ok: true,
|
||||
detail: `${botName} → DM sent (${elapsed}ms, via ${sourceLabel})`,
|
||||
elapsed,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
agentId, platform: 'discord', ok: false,
|
||||
error: `Send DM failed: ${msgData?.message || msgResp.raw || `HTTP ${msgResp.status}`}`,
|
||||
elapsed,
|
||||
};
|
||||
} catch (err: any) {
|
||||
return {
|
||||
agentId, platform: "discord", ok: false,
|
||||
error: err.message,
|
||||
agentId, platform: 'discord', ok: false,
|
||||
error: err.stderr || err.message || 'Unknown error',
|
||||
elapsed: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getDiscordDmUser(agentId: string): string | null {
|
||||
try {
|
||||
const sessionsPath = path.join(OPENCLAW_HOME, `agents/${agentId}/sessions/sessions.json`);
|
||||
const raw = fs.readFileSync(sessionsPath, "utf-8");
|
||||
const sessions = JSON.parse(raw);
|
||||
let bestId: string | null = null;
|
||||
let bestTime = 0;
|
||||
for (const [key, val] of Object.entries(sessions)) {
|
||||
const m = key.match(/^agent:[^:]+:discord:direct:(.+)$/);
|
||||
if (m) {
|
||||
const updatedAt = (val as any).updatedAt || 0;
|
||||
if (updatedAt > bestTime) {
|
||||
bestTime = updatedAt;
|
||||
bestId = m[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
return bestId;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getDiscordAllowlistUser(discordConfig: any): string | null {
|
||||
const list = Array.isArray(discordConfig?.allowFrom)
|
||||
? discordConfig.allowFrom
|
||||
: Array.isArray(discordConfig?.dm?.allowFrom)
|
||||
? discordConfig.dm.allowFrom
|
||||
: [];
|
||||
const first = list.find((v: any) => typeof v === "string" && v.trim().length > 0);
|
||||
return first ? first.trim() : null;
|
||||
}
|
||||
|
||||
function getChannelDmUser(agentId: string, channel: string): string | null {
|
||||
try {
|
||||
const sessionsPath = path.join(OPENCLAW_HOME, `agents/${agentId}/sessions/sessions.json`);
|
||||
const raw = fs.readFileSync(sessionsPath, "utf-8");
|
||||
const sessions = JSON.parse(raw);
|
||||
let bestId: string | null = null;
|
||||
let bestTime = 0;
|
||||
const pattern = new RegExp(`^agent:[^:]+:${channel}:direct:(.+)$`);
|
||||
for (const [key, val] of Object.entries(sessions)) {
|
||||
const m = key.match(pattern);
|
||||
if (m) {
|
||||
const updatedAt = (val as any).updatedAt || 0;
|
||||
if (updatedAt > bestTime) {
|
||||
bestTime = updatedAt;
|
||||
bestId = m[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
return bestId;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function stripChannelTarget(value: string | null | undefined, channel: string): string | null {
|
||||
if (!value || typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
const prefix = `${channel}:`;
|
||||
return trimmed.startsWith(prefix) ? trimmed.slice(prefix.length) : trimmed;
|
||||
}
|
||||
|
||||
function getYuanbaoDmContext(agentId: string): YuanbaoDmContext | null {
|
||||
try {
|
||||
const sessionsPath = path.join(OPENCLAW_HOME, `agents/${agentId}/sessions/sessions.json`);
|
||||
const raw = fs.readFileSync(sessionsPath, "utf-8");
|
||||
const sessions = JSON.parse(raw);
|
||||
let best: YuanbaoDmContext | null = null;
|
||||
let bestTime = 0;
|
||||
|
||||
for (const [key, val] of Object.entries(sessions)) {
|
||||
const match = key.match(/^agent:[^:]+:yuanbao:direct:(.+)$/);
|
||||
if (!match) continue;
|
||||
const session = val as any;
|
||||
const updatedAt = session?.updatedAt || 0;
|
||||
if (updatedAt <= bestTime) continue;
|
||||
|
||||
const target = stripChannelTarget(session?.deliveryContext?.to, "yuanbao")
|
||||
|| stripChannelTarget(session?.origin?.to, "yuanbao")
|
||||
|| match[1];
|
||||
if (!target) continue;
|
||||
|
||||
bestTime = updatedAt;
|
||||
best = {
|
||||
target,
|
||||
accountId: typeof session?.deliveryContext?.accountId === "string" && session.deliveryContext.accountId.trim()
|
||||
? session.deliveryContext.accountId.trim()
|
||||
: (typeof session?.origin?.accountId === "string" && session.origin.accountId.trim()
|
||||
? session.origin.accountId.trim()
|
||||
: null),
|
||||
};
|
||||
}
|
||||
|
||||
return best;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveYuanbaoTestAccount(channelConfig: any, preferredAccountId?: string | null) {
|
||||
const accounts = channelConfig?.accounts && typeof channelConfig.accounts === "object"
|
||||
? channelConfig.accounts
|
||||
: {};
|
||||
const availableAccountIds = Object.keys(accounts).filter((value) => value.trim().length > 0);
|
||||
const defaultAccountId = typeof channelConfig?.defaultAccount === "string" && channelConfig.defaultAccount.trim()
|
||||
? channelConfig.defaultAccount.trim()
|
||||
: (availableAccountIds.includes("default") ? "default" : (availableAccountIds[0] ?? "default"));
|
||||
const accountId = preferredAccountId?.trim() || defaultAccountId;
|
||||
const scopedConfig = accounts?.[accountId] && typeof accounts[accountId] === "object"
|
||||
? accounts[accountId]
|
||||
: {};
|
||||
const merged = { ...channelConfig, ...scopedConfig };
|
||||
const { accounts: _accounts, defaultAccount: _defaultAccount, ...config } = merged;
|
||||
|
||||
const appKey = typeof config.appKey === "string" ? config.appKey.trim() : "";
|
||||
const appSecret = typeof config.appSecret === "string" ? config.appSecret.trim() : "";
|
||||
const identifier = typeof config.identifier === "string" ? config.identifier.trim() : "";
|
||||
const token = typeof config.token === "string" ? config.token.trim() : "";
|
||||
const apiDomain = typeof config.apiDomain === "string" && config.apiDomain.trim()
|
||||
? config.apiDomain.trim()
|
||||
: DEFAULT_YUANBAO_API_DOMAIN;
|
||||
const wsGatewayUrl = typeof config.wsUrl === "string" && config.wsUrl.trim()
|
||||
? config.wsUrl.trim()
|
||||
: DEFAULT_YUANBAO_WS_URL;
|
||||
|
||||
return {
|
||||
accountId,
|
||||
account: {
|
||||
accountId,
|
||||
enabled: config.enabled !== false,
|
||||
configured: Boolean(appKey && appSecret),
|
||||
appKey: appKey || undefined,
|
||||
appSecret: appSecret || undefined,
|
||||
identifier: identifier || undefined,
|
||||
botId: typeof config.botId === "string" && config.botId.trim() ? config.botId.trim() : undefined,
|
||||
apiDomain,
|
||||
token: token || undefined,
|
||||
wsGatewayUrl,
|
||||
wsHeartbeatInterval: undefined,
|
||||
wsMaxReconnectAttempts: 1,
|
||||
overflowPolicy: config.overflowPolicy === "split" ? "split" : "stop",
|
||||
mediaMaxMb: typeof config.mediaMaxMb === "number" && config.mediaMaxMb >= 1 ? config.mediaMaxMb : 20,
|
||||
historyLimit: typeof config.historyLimit === "number" && config.historyLimit >= 0 ? config.historyLimit : 100,
|
||||
config,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let yuanbaoRuntimePromise: Promise<{
|
||||
getSignToken: (account: any, log?: any) => Promise<any>;
|
||||
YuanbaoWsClient: any;
|
||||
sendYuanbaoMessage: (params: any) => Promise<any>;
|
||||
}> | null = null;
|
||||
|
||||
async function loadYuanbaoRuntime() {
|
||||
if (!yuanbaoRuntimePromise) {
|
||||
yuanbaoRuntimePromise = Promise.all([
|
||||
importExternalModule(pathToFileURL(path.join(YUANBAO_PLUGIN_DIST_DIR, "yuanbao-server/http/request.js")).href),
|
||||
importExternalModule(pathToFileURL(path.join(YUANBAO_PLUGIN_DIST_DIR, "yuanbao-server/ws/client.js")).href),
|
||||
importExternalModule(pathToFileURL(path.join(YUANBAO_PLUGIN_DIST_DIR, "message-handler/outbound.js")).href),
|
||||
]).then(([requestModule, clientModule, outboundModule]) => ({
|
||||
getSignToken: requestModule.getSignToken,
|
||||
YuanbaoWsClient: clientModule.YuanbaoWsClient,
|
||||
sendYuanbaoMessage: outboundModule.sendYuanbaoMessage,
|
||||
}));
|
||||
}
|
||||
return yuanbaoRuntimePromise;
|
||||
}
|
||||
|
||||
function getChannelAllowlistUser(channelConfig: any): string | null {
|
||||
const list = Array.isArray(channelConfig?.allowFrom)
|
||||
? channelConfig.allowFrom
|
||||
: Array.isArray(channelConfig?.dm?.allowFrom)
|
||||
? channelConfig.dm.allowFrom
|
||||
: [];
|
||||
const first = list.find((v: any) => typeof v === "string" && v.trim().length > 0);
|
||||
return first ? first.trim() : null;
|
||||
}
|
||||
|
||||
// Find the most recent telegram DM chat_id for a given agent
|
||||
function getTelegramDmUser(agentId: string): string | null {
|
||||
try {
|
||||
@@ -298,72 +521,233 @@ function getTelegramDmUser(agentId: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
// Telegram: call /getMe to verify bot, then send test DM
|
||||
// Telegram: send a real DM through local OpenClaw channel gateway
|
||||
async function testTelegram(
|
||||
agentId: string,
|
||||
botToken: string,
|
||||
testChatId: string | null
|
||||
): Promise<PlatformTestResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
if (!testChatId) {
|
||||
return {
|
||||
agentId, platform: "telegram", ok: false,
|
||||
error: "No Telegram recipient configured. Start one DM session first",
|
||||
elapsed: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const meResp = await fetch(`https://api.telegram.org/bot${botToken}/getMe`, {
|
||||
method: "GET",
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
|
||||
const meData = await meResp.json();
|
||||
|
||||
if (!meResp.ok || !meData.ok || !meData.result) {
|
||||
return {
|
||||
agentId, platform: "telegram", ok: false,
|
||||
error: `Telegram API error: ${meData.description || JSON.stringify(meData)}`,
|
||||
elapsed: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
|
||||
const botName = meData.result.username ? `@${meData.result.username}` : meData.result.first_name;
|
||||
|
||||
if (!testChatId) {
|
||||
return {
|
||||
agentId, platform: "telegram", ok: true,
|
||||
detail: `${botName} (bot reachable, no DM session found)`,
|
||||
elapsed: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
|
||||
// Send test message
|
||||
const now = new Date().toLocaleTimeString("zh-CN", { timeZone: "Asia/Shanghai" });
|
||||
const msgResp = await fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
chat_id: testChatId,
|
||||
text: `[Platform Test] ${botName} 联通测试 ✅ (${now})`,
|
||||
}),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
|
||||
const msgData = await msgResp.json();
|
||||
const result = runOpenClawMessageSend(
|
||||
"telegram",
|
||||
testChatId,
|
||||
`[Platform Test] Telegram 联通测试 ✅ (${now})`,
|
||||
["--silent"]
|
||||
);
|
||||
const elapsed = Date.now() - startTime;
|
||||
|
||||
if (msgData.ok) {
|
||||
return {
|
||||
agentId, platform: "telegram", ok: true,
|
||||
detail: `${botName} → DM sent (${elapsed}ms)`,
|
||||
elapsed,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
agentId, platform: "telegram", ok: false,
|
||||
error: `Send DM failed: ${msgData.description || JSON.stringify(msgData)}`,
|
||||
elapsed,
|
||||
};
|
||||
}
|
||||
const outputSummary = result.trim().slice(0, 120);
|
||||
return {
|
||||
agentId, platform: "telegram", ok: true,
|
||||
detail: `Telegram → DM sent to ${testChatId} (${elapsed}ms)${outputSummary ? ` · ${outputSummary}` : ""}`,
|
||||
elapsed,
|
||||
};
|
||||
} catch (err: any) {
|
||||
return {
|
||||
agentId, platform: "telegram", ok: false,
|
||||
error: err.message,
|
||||
error: (err.stderr || err.message || "Unknown error").slice(0, 300),
|
||||
elapsed: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function testYuanbao(
|
||||
agentId: string,
|
||||
channelConfig: any,
|
||||
testUserId: string | null,
|
||||
recipientSource: "session" | "allowFrom" | "none",
|
||||
preferredAccountId?: string | null,
|
||||
): Promise<PlatformTestResult> {
|
||||
const startTime = Date.now();
|
||||
const { accountId, account } = resolveYuanbaoTestAccount(channelConfig, preferredAccountId);
|
||||
|
||||
if (!account.appKey || !account.appSecret) {
|
||||
return {
|
||||
agentId,
|
||||
platform: "yuanbao",
|
||||
accountId,
|
||||
ok: false,
|
||||
error: "Yuanbao credentials missing. Configure channels.yuanbao.appKey and channels.yuanbao.appSecret",
|
||||
elapsed: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
|
||||
if (!testUserId) {
|
||||
return {
|
||||
agentId,
|
||||
platform: "yuanbao",
|
||||
accountId,
|
||||
ok: false,
|
||||
error: "No Yuanbao recipient configured. Set channels.yuanbao.allowFrom or start one DM session first",
|
||||
elapsed: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
|
||||
let wsClient: any = null;
|
||||
|
||||
try {
|
||||
const { getSignToken, YuanbaoWsClient, sendYuanbaoMessage } = await loadYuanbaoRuntime();
|
||||
const tokenData = await getSignToken(account);
|
||||
const botId = typeof tokenData?.bot_id === "string" && tokenData.bot_id.trim()
|
||||
? tokenData.bot_id.trim()
|
||||
: (typeof account.botId === "string" && account.botId.trim()
|
||||
? account.botId.trim()
|
||||
: (typeof account.identifier === "string" ? account.identifier : ""));
|
||||
|
||||
if (!botId) {
|
||||
throw new Error("Yuanbao sign token succeeded but bot_id is missing");
|
||||
}
|
||||
|
||||
account.botId = botId;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const timeout = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
wsClient?.disconnect?.();
|
||||
reject(new Error("Yuanbao WebSocket ready timeout"));
|
||||
}, 20000);
|
||||
|
||||
const finish = (cb: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
cb();
|
||||
};
|
||||
|
||||
wsClient = new YuanbaoWsClient({
|
||||
connection: {
|
||||
gatewayUrl: account.wsGatewayUrl,
|
||||
auth: {
|
||||
bizId: "ybBot",
|
||||
uid: botId,
|
||||
source: tokenData?.source || "bot",
|
||||
token: tokenData?.token,
|
||||
...(account.config?.routeEnv ? { routeEnv: account.config.routeEnv } : {}),
|
||||
},
|
||||
},
|
||||
config: {
|
||||
maxReconnectAttempts: account.wsMaxReconnectAttempts,
|
||||
},
|
||||
callbacks: {
|
||||
onReady: () => finish(resolve),
|
||||
onError: (error: Error) => finish(() => reject(error)),
|
||||
onClose: (code: number, reason: string) => finish(() => reject(new Error(`Yuanbao WebSocket closed before ready: ${code} ${reason || ""}`.trim()))),
|
||||
},
|
||||
log: {
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
debug: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
wsClient.connect();
|
||||
});
|
||||
|
||||
const now = new Date().toLocaleTimeString("zh-CN", { timeZone: "Asia/Shanghai" });
|
||||
const sendResult = await sendYuanbaoMessage({
|
||||
account,
|
||||
toAccount: testUserId,
|
||||
text: `[Platform Test] Yuanbao 联通测试 ✅ (${now})`,
|
||||
fromAccount: account.botId,
|
||||
ctx: {
|
||||
account,
|
||||
config: {},
|
||||
core: {},
|
||||
log: { info: () => {}, warn: () => {}, error: () => {}, verbose: () => {} },
|
||||
wsClient,
|
||||
},
|
||||
});
|
||||
|
||||
const elapsed = Date.now() - startTime;
|
||||
if (!sendResult?.ok) {
|
||||
return {
|
||||
agentId,
|
||||
platform: "yuanbao",
|
||||
accountId,
|
||||
ok: false,
|
||||
error: sendResult?.error || "Yuanbao real IM send failed",
|
||||
elapsed,
|
||||
};
|
||||
}
|
||||
|
||||
const sourceLabel = recipientSource === "allowFrom" ? "allowFrom" : "session";
|
||||
return {
|
||||
agentId,
|
||||
platform: "yuanbao",
|
||||
accountId,
|
||||
ok: true,
|
||||
detail: `Yuanbao → real IM sent to ${testUserId} (${elapsed}ms, via ${sourceLabel})${sendResult?.messageId ? ` · msgId=${sendResult.messageId}` : ""}`,
|
||||
elapsed,
|
||||
};
|
||||
} catch (err: any) {
|
||||
return {
|
||||
agentId,
|
||||
platform: "yuanbao",
|
||||
accountId,
|
||||
ok: false,
|
||||
error: err?.message || "Yuanbao real IM send failed",
|
||||
elapsed: Date.now() - startTime,
|
||||
};
|
||||
} finally {
|
||||
wsClient?.disconnect?.();
|
||||
}
|
||||
}
|
||||
|
||||
async function testGenericChannel(
|
||||
agentId: string,
|
||||
channel: string,
|
||||
testUserId: string | null,
|
||||
recipientSource: "session" | "allowFrom" | "none"
|
||||
): Promise<PlatformTestResult> {
|
||||
const startTime = Date.now();
|
||||
const displayName = channel.charAt(0).toUpperCase() + channel.slice(1);
|
||||
|
||||
if (!testUserId) {
|
||||
return {
|
||||
agentId,
|
||||
platform: channel,
|
||||
ok: false,
|
||||
error: `No ${displayName} recipient configured. Set channels.${channel}.allowFrom or start one DM session first`,
|
||||
elapsed: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const now = new Date().toLocaleTimeString("zh-CN", { timeZone: "Asia/Shanghai" });
|
||||
const result = runOpenClawMessageSend(
|
||||
channel,
|
||||
testUserId,
|
||||
`[Platform Test] ${displayName} 联通测试 ✅ (${now})`,
|
||||
["--silent"]
|
||||
);
|
||||
const elapsed = Date.now() - startTime;
|
||||
const sourceLabel = recipientSource === "allowFrom" ? "allowFrom" : "session";
|
||||
const outputSummary = result.trim().slice(0, 120);
|
||||
return {
|
||||
agentId,
|
||||
platform: channel,
|
||||
ok: true,
|
||||
detail: `${displayName} → DM sent to ${testUserId} (${elapsed}ms, via ${sourceLabel})${outputSummary ? ` · ${outputSummary}` : ""}`,
|
||||
elapsed,
|
||||
};
|
||||
} catch (err: any) {
|
||||
return {
|
||||
agentId,
|
||||
platform: channel,
|
||||
ok: false,
|
||||
error: (err.stderr || err.message || "Unknown error").slice(0, 300),
|
||||
elapsed: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
@@ -427,11 +811,14 @@ function getQqbotDmUser(agentId: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
function getQqbotAllowlistUser(qqbotConfig: any): string | null {
|
||||
const list = Array.isArray(qqbotConfig?.allowFrom)
|
||||
? qqbotConfig.allowFrom
|
||||
: Array.isArray(qqbotConfig?.dm?.allowFrom)
|
||||
? qqbotConfig.dm.allowFrom
|
||||
function getQqbotAllowlistUser(qqbotConfig: any, accountId?: string | null): string | null {
|
||||
const accountCfg = accountId && accountId !== "default"
|
||||
? qqbotConfig?.accounts?.[accountId]
|
||||
: qqbotConfig;
|
||||
const list = Array.isArray(accountCfg?.allowFrom)
|
||||
? accountCfg.allowFrom
|
||||
: Array.isArray(accountCfg?.dm?.allowFrom)
|
||||
? accountCfg.dm.allowFrom
|
||||
: [];
|
||||
const first = list.find((v: any) => typeof v === "string" && v.trim().length > 0);
|
||||
return first ? first.trim() : null;
|
||||
@@ -455,9 +842,34 @@ function normalizeQqbotTarget(target: string | null): string | null {
|
||||
return `qqbot:c2c:${raw.toUpperCase()}`;
|
||||
}
|
||||
|
||||
function resolveQqbotCredentials(qqbotConfig: any): { accountId: string; appId: string; clientSecret: string } | null {
|
||||
function resolveQqbotCredentials(
|
||||
qqbotConfig: any,
|
||||
preferredAccountId?: string | null
|
||||
): { accountId: string; appId: string; clientSecret: string } | null {
|
||||
if (!qqbotConfig || qqbotConfig.enabled === false) return null;
|
||||
|
||||
if (
|
||||
preferredAccountId &&
|
||||
preferredAccountId !== "default" &&
|
||||
qqbotConfig.accounts &&
|
||||
typeof qqbotConfig.accounts === "object"
|
||||
) {
|
||||
const account = qqbotConfig.accounts[preferredAccountId];
|
||||
if (
|
||||
account &&
|
||||
typeof account.appId === "string" &&
|
||||
account.appId.trim() &&
|
||||
typeof account.clientSecret === "string" &&
|
||||
account.clientSecret.trim()
|
||||
) {
|
||||
return {
|
||||
accountId: preferredAccountId,
|
||||
appId: account.appId.trim(),
|
||||
clientSecret: account.clientSecret.trim(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
typeof qqbotConfig.appId === "string" &&
|
||||
qqbotConfig.appId.trim() &&
|
||||
@@ -546,23 +958,12 @@ async function testWhatsapp(
|
||||
}
|
||||
|
||||
try {
|
||||
// WhatsApp has no public Bot API. Use `openclaw message send` CLI
|
||||
// to send a real message via the gateway's WhatsApp Web connection.
|
||||
const now = new Date().toLocaleTimeString("zh-CN", { timeZone: "Asia/Shanghai" });
|
||||
const { execFileSync } = await import("child_process");
|
||||
|
||||
const args = [
|
||||
"message", "send",
|
||||
"--channel", "whatsapp",
|
||||
"-t", testUserId,
|
||||
"--message", `[Platform Test] WhatsApp 联通测试 ✅ (${now})`,
|
||||
];
|
||||
|
||||
const result = execFileSync("openclaw", args, {
|
||||
timeout: 30000,
|
||||
encoding: "utf-8",
|
||||
env: { ...process.env },
|
||||
});
|
||||
const result = runOpenClawMessageSend(
|
||||
"whatsapp",
|
||||
testUserId,
|
||||
`[Platform Test] WhatsApp 联通测试 ✅ (${now})`
|
||||
);
|
||||
|
||||
const elapsed = Date.now() - startTime;
|
||||
const sourceLabel = recipientSource === "allowFrom" ? "allowFrom" : "session";
|
||||
@@ -584,11 +985,12 @@ async function testWhatsapp(
|
||||
async function testQqbot(
|
||||
agentId: string,
|
||||
qqbotConfig: any,
|
||||
qqbotAccountId: string | null,
|
||||
testUserId: string | null,
|
||||
recipientSource: "session" | "allowFrom" | "none"
|
||||
): Promise<PlatformTestResult> {
|
||||
const startTime = Date.now();
|
||||
const creds = resolveQqbotCredentials(qqbotConfig);
|
||||
const creds = resolveQqbotCredentials(qqbotConfig, qqbotAccountId);
|
||||
if (!creds) {
|
||||
return {
|
||||
agentId, platform: "qqbot", ok: false,
|
||||
@@ -684,11 +1086,10 @@ export async function POST() {
|
||||
const feishuAccounts = feishuConfig.accounts || {};
|
||||
const feishuDomain = feishuConfig.domain || "feishu";
|
||||
const discordConfig = channels.discord || {};
|
||||
const discordAllowFrom: string[] = discordConfig.dm?.allowFrom || [];
|
||||
const discordTestUser = discordAllowFrom[0] || null;
|
||||
const telegramConfig = channels.telegram || {};
|
||||
const whatsappConfig = channels.whatsapp || {};
|
||||
const qqbotConfig = channels.qqbot;
|
||||
const specialPlatformNames = new Set(["feishu", "discord", "telegram", "whatsapp", "qqbot"]);
|
||||
|
||||
// Read gateway config early (needed for WhatsApp test)
|
||||
const gatewayPort = config.gateway?.port || 18789;
|
||||
@@ -708,8 +1109,10 @@ export async function POST() {
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 1: Platform API tests (parallel)
|
||||
// Phase 1: Feishu API tests can run in parallel.
|
||||
// Local gateway / CLI-backed channel tests are run sequentially to avoid send-path contention.
|
||||
const platformTests: Promise<PlatformTestResult>[] = [];
|
||||
const sequentialPlatformTests: Array<() => Promise<PlatformTestResult>> = [];
|
||||
const testedFeishuAccounts = new Set<string>();
|
||||
|
||||
for (const agent of agentList) {
|
||||
@@ -734,21 +1137,20 @@ export async function POST() {
|
||||
}
|
||||
}
|
||||
|
||||
// Discord: only test once
|
||||
if (id === "main" && discordConfig.enabled && discordConfig.token) {
|
||||
const resolvedDiscordToken = resolveSecretRef(discordConfig.token);
|
||||
if (resolvedDiscordToken) {
|
||||
platformTests.push(testDiscord(id, resolvedDiscordToken, discordTestUser));
|
||||
}
|
||||
// Discord: only test once, via local OpenClaw channel gateway
|
||||
if (id === "main" && discordConfig.enabled) {
|
||||
const recentDmUser = getDiscordDmUser(id);
|
||||
const allowFromUser = getDiscordAllowlistUser(discordConfig);
|
||||
const discordTestUser = recentDmUser || allowFromUser || null;
|
||||
const source: "session" | "allowFrom" | "none" =
|
||||
recentDmUser ? "session" : (allowFromUser ? "allowFrom" : "none");
|
||||
sequentialPlatformTests.push(() => testDiscord(id, discordConfig.token, discordTestUser, source));
|
||||
}
|
||||
|
||||
// Telegram: only test once
|
||||
if (id === "main" && telegramConfig.enabled && telegramConfig.botToken) {
|
||||
const resolvedToken = resolveSecretRef(telegramConfig.botToken);
|
||||
if (resolvedToken) {
|
||||
const telegramTestUser = getTelegramDmUser(id);
|
||||
platformTests.push(testTelegram(id, resolvedToken, telegramTestUser));
|
||||
}
|
||||
// Telegram: only test once, via local OpenClaw channel gateway
|
||||
if (id === "main" && telegramConfig.enabled) {
|
||||
const telegramTestUser = getTelegramDmUser(id);
|
||||
sequentialPlatformTests.push(() => testTelegram(id, telegramTestUser));
|
||||
}
|
||||
|
||||
// WhatsApp: only test once, via gateway
|
||||
@@ -758,21 +1160,61 @@ export async function POST() {
|
||||
const whatsappTestUser = recentDmUser || allowFromUser || null;
|
||||
const source: "session" | "allowFrom" | "none" =
|
||||
recentDmUser ? "session" : (allowFromUser ? "allowFrom" : "none");
|
||||
platformTests.push(testWhatsapp(id, gatewayPort, gatewayToken, whatsappTestUser, source));
|
||||
sequentialPlatformTests.push(() => testWhatsapp(id, gatewayPort, gatewayToken, whatsappTestUser, source));
|
||||
}
|
||||
|
||||
// QQBot: only test once, via `openclaw message send`
|
||||
if (id === "main" && qqbotConfig && qqbotConfig.enabled !== false) {
|
||||
// QQBot: test the main agent plus any non-main agent explicitly bound to qqbot,
|
||||
// so the platform test results line up with the cards rendered on the home page.
|
||||
const hasQqbotBinding = bindings.some(
|
||||
(b: any) => b.agentId === id && b.match?.channel === "qqbot"
|
||||
);
|
||||
if (qqbotConfig && qqbotConfig.enabled !== false && (id === "main" || hasQqbotBinding)) {
|
||||
const qqbotBinding = bindings.find(
|
||||
(b: any) => b.agentId === id && b.match?.channel === "qqbot"
|
||||
);
|
||||
const qqbotAccountId = typeof qqbotBinding?.match?.accountId === "string" && qqbotBinding.match.accountId.trim()
|
||||
? qqbotBinding.match.accountId.trim()
|
||||
: (id === "main" ? "default" : id);
|
||||
const recentDmUser = normalizeQqbotTarget(getQqbotDmUser(id));
|
||||
const allowFromUser = normalizeQqbotTarget(getQqbotAllowlistUser(qqbotConfig));
|
||||
const allowFromUser = normalizeQqbotTarget(getQqbotAllowlistUser(qqbotConfig, qqbotAccountId));
|
||||
const qqbotTestUser = recentDmUser || allowFromUser || null;
|
||||
const source: "session" | "allowFrom" | "none" =
|
||||
recentDmUser ? "session" : (allowFromUser ? "allowFrom" : "none");
|
||||
platformTests.push(testQqbot(id, qqbotConfig, qqbotTestUser, source));
|
||||
sequentialPlatformTests.push(() => testQqbot(id, qqbotConfig, qqbotAccountId, qqbotTestUser, source));
|
||||
}
|
||||
|
||||
for (const [channelName, channelConfig] of Object.entries(channels)) {
|
||||
if (specialPlatformNames.has(channelName)) continue;
|
||||
if (shouldHidePlatformChannel(channelName, channels)) continue;
|
||||
if (!channelConfig || typeof channelConfig !== "object" || (channelConfig as any).enabled === false) continue;
|
||||
|
||||
const hasBinding = bindings.some(
|
||||
(b: any) => b.agentId === id && b.match?.channel === channelName
|
||||
);
|
||||
if (id !== "main" && !hasBinding) continue;
|
||||
|
||||
const yuanbaoDmContext = channelName === "yuanbao" ? getYuanbaoDmContext(id) : null;
|
||||
const recentDmUser = channelName === "yuanbao"
|
||||
? (yuanbaoDmContext?.target ?? null)
|
||||
: getChannelDmUser(id, channelName);
|
||||
const allowFromUser = channelName === "yuanbao"
|
||||
? stripChannelTarget(getChannelAllowlistUser(channelConfig), "yuanbao")
|
||||
: getChannelAllowlistUser(channelConfig);
|
||||
const testUserId = recentDmUser || allowFromUser || null;
|
||||
const source: "session" | "allowFrom" | "none" =
|
||||
recentDmUser ? "session" : (allowFromUser ? "allowFrom" : "none");
|
||||
if (channelName === "yuanbao") {
|
||||
sequentialPlatformTests.push(() => testYuanbao(id, channelConfig, testUserId, source, yuanbaoDmContext?.accountId ?? null));
|
||||
} else {
|
||||
sequentialPlatformTests.push(() => testGenericChannel(id, channelName, testUserId, source));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const platformResults = await Promise.all(platformTests);
|
||||
for (const runTest of sequentialPlatformTests) {
|
||||
platformResults.push(await runTest());
|
||||
}
|
||||
|
||||
return NextResponse.json({ results: platformResults });
|
||||
} catch (err: any) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { buildGatewayUrl } from "@/lib/gateway-url";
|
||||
import { getPlatformDisplayName } from "@/lib/platforms";
|
||||
|
||||
export interface AgentPlatform {
|
||||
name: string;
|
||||
@@ -160,6 +161,7 @@ function PlatformBadge({
|
||||
testResult?: PlatformTestResult | null;
|
||||
}) {
|
||||
const pName = platform.name;
|
||||
const displayName = getPlatformDisplayName(pName);
|
||||
const badgeWidthClass = "w-[8.25rem]";
|
||||
const knownMeta: Record<string, { remoteLogoSrc: string; logoFallbackSrc: string; badgeStyle: string; logoSizeClass?: string }> = {
|
||||
feishu: {
|
||||
@@ -168,6 +170,11 @@ function PlatformBadge({
|
||||
badgeStyle: "bg-blue-500/20 text-blue-300 border border-blue-500/30 hover:bg-blue-500/40 hover:border-blue-400",
|
||||
logoSizeClass: "w-[1.09375rem] h-[1.09375rem]",
|
||||
},
|
||||
yuanbao: {
|
||||
remoteLogoSrc: "https://cdn-hybrid-prod.hunyuan.tencent.com/manual/favicon.png",
|
||||
logoFallbackSrc: "/assets/platform-logos/yuanbao-favicon.png?v=1",
|
||||
badgeStyle: "bg-cyan-500/20 text-cyan-300 border border-cyan-500/30 hover:bg-cyan-500/40 hover:border-cyan-400",
|
||||
},
|
||||
discord: {
|
||||
remoteLogoSrc: "https://cdn.simpleicons.org/discord/5865F2",
|
||||
logoFallbackSrc: "/assets/platform-logos/discord.svg",
|
||||
@@ -188,8 +195,13 @@ function PlatformBadge({
|
||||
logoFallbackSrc: "/assets/platform-logos/qq-favicon.ico?v=1",
|
||||
badgeStyle: "bg-blue-500/20 text-blue-300 border border-blue-500/30 hover:bg-blue-500/40 hover:border-blue-400",
|
||||
},
|
||||
wecom: {
|
||||
remoteLogoSrc: "/assets/platform-logos/wecom.svg?v=1",
|
||||
logoFallbackSrc: "/assets/platform-logos/wecom.svg?v=1",
|
||||
badgeStyle: "bg-emerald-500/20 text-emerald-300 border border-emerald-500/30 hover:bg-emerald-500/40 hover:border-emerald-400",
|
||||
},
|
||||
};
|
||||
const meta = knownMeta[pName];
|
||||
const meta = knownMeta[displayName];
|
||||
const logoSizeClass = meta?.logoSizeClass || "w-3.5 h-3.5";
|
||||
|
||||
let sessionKey: string;
|
||||
@@ -204,9 +216,9 @@ function PlatformBadge({
|
||||
if (gatewayToken) sessionUrl = buildGatewayUrl(gatewayPort, "/chat", { session: sessionKey, token: gatewayToken }, gatewayHost);
|
||||
|
||||
const badgeStyle = meta?.badgeStyle || "bg-gray-500/20 text-gray-300 border border-gray-500/30 hover:bg-gray-500/40 hover:border-gray-400";
|
||||
const translated = t(`platform.${pName}`);
|
||||
const labelRaw = translated !== `platform.${pName}` ? translated : pName;
|
||||
const label = labelRaw.replace(/^[^\p{L}\p{N}]+/u, "").trim() || pName;
|
||||
const translated = t(`platform.${displayName}`);
|
||||
const labelRaw = translated !== `platform.${displayName}` ? translated : displayName;
|
||||
const label = labelRaw.replace(/^[^\p{L}\p{N}]+/u, "").trim() || displayName;
|
||||
|
||||
return (
|
||||
<div className="inline-flex items-center gap-1.5 max-w-full">
|
||||
|
||||
+157
-12
@@ -20,6 +20,13 @@ interface HealthResult {
|
||||
openclawVersion?: string;
|
||||
}
|
||||
|
||||
interface LogResult {
|
||||
ok: boolean;
|
||||
issues: string[];
|
||||
lastStallAt: string | null;
|
||||
stallActive: boolean;
|
||||
}
|
||||
|
||||
interface GatewayStatusProps {
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
@@ -29,30 +36,81 @@ interface GatewayStatusProps {
|
||||
export function GatewayStatus({ compact = false, className = "", hideIconOnMobile = false }: GatewayStatusProps) {
|
||||
const { t } = useI18n();
|
||||
const [health, setHealth] = useState<HealthResult | null>(null);
|
||||
const [showError, setShowError] = useState(false);
|
||||
const [logResult, setLogResult] = useState<LogResult | null>(null);
|
||||
const [showDetail, setShowDetail] = useState(false);
|
||||
const [showVersionTip, setShowVersionTip] = useState(false);
|
||||
const [restarting, setRestarting] = useState(false);
|
||||
const [restartMsg, setRestartMsg] = useState<string | null>(null);
|
||||
|
||||
const check = useCallback(() => {
|
||||
const checkHealth = useCallback(() => {
|
||||
fetch("/api/gateway-health")
|
||||
.then((r) => r.json())
|
||||
.then((d) => setHealth(d))
|
||||
.then((d: HealthResult) => {
|
||||
setHealth(d);
|
||||
// If health is down, also fetch logs for more context
|
||||
if (!d.ok) fetchLogs();
|
||||
})
|
||||
.catch(() => setHealth({ ok: false, error: t("gateway.fetchError") }));
|
||||
}, [t]);
|
||||
|
||||
const fetchLogs = useCallback(() => {
|
||||
fetch("/api/gateway-logs")
|
||||
.then((r) => r.json())
|
||||
.then((d: LogResult) => setLogResult(d))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
check();
|
||||
const timer = setInterval(check, 10000);
|
||||
checkHealth();
|
||||
const timer = setInterval(checkHealth, 10000);
|
||||
return () => clearInterval(timer);
|
||||
}, [check]);
|
||||
}, [checkHealth]);
|
||||
|
||||
const handleDetailClick = useCallback(() => {
|
||||
setShowDetail((v) => !v);
|
||||
// Fetch fresh logs whenever the user opens the detail panel
|
||||
fetchLogs();
|
||||
}, [fetchLogs]);
|
||||
|
||||
const handleRestart = useCallback(async () => {
|
||||
if (restarting) return;
|
||||
setRestarting(true);
|
||||
setRestartMsg(null);
|
||||
try {
|
||||
const res = await fetch("/api/gateway-restart", { method: "POST" });
|
||||
const data = await res.json();
|
||||
if (data.ok) {
|
||||
setRestartMsg("✅ 重啟指令已送出,稍後自動重新檢查…");
|
||||
setTimeout(() => {
|
||||
checkHealth();
|
||||
setRestartMsg(null);
|
||||
setShowDetail(false);
|
||||
}, 4000);
|
||||
} else {
|
||||
setRestartMsg(`❌ 重啟失敗:${data.error || "未知錯誤"}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setRestartMsg(`❌ 重啟失敗:${err.message}`);
|
||||
} finally {
|
||||
setRestarting(false);
|
||||
}
|
||||
}, [restarting, checkHealth]);
|
||||
|
||||
const gatewayTitle = health?.openclawVersion
|
||||
? `OpenClaw ${health.openclawVersion}`
|
||||
: "OpenClaw";
|
||||
|
||||
// Determine warning state: gateway alive but Telegram stalled
|
||||
const telegramStall = health?.ok && logResult?.stallActive === true;
|
||||
const showWarning = telegramStall;
|
||||
// Show restart button when: down, or Telegram stalled
|
||||
const showRestart = health !== null;
|
||||
|
||||
return (
|
||||
<div className={`relative inline-flex items-center gap-1.5 ${className}`.trim()}>
|
||||
{/* Gateway link badge */}
|
||||
<a
|
||||
href={health?.ok && health.webUrl ? resolveGatewayUrl(health.webUrl) : undefined}
|
||||
href={process.env.NEXT_PUBLIC_GATEWAY_CHAT_BASE_URL ?? "/"}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={gatewayTitle}
|
||||
@@ -76,25 +134,112 @@ export function GatewayStatus({ compact = false, className = "", hideIconOnMobil
|
||||
) : "🦞 Gateway"}
|
||||
<span className="opacity-50 text-[10px]">↗</span>
|
||||
</a>
|
||||
|
||||
{showVersionTip && (
|
||||
<div className="absolute top-full left-0 mt-1 z-50 px-2 py-1 rounded-md bg-black/80 border border-white/10 text-white text-[10px] whitespace-nowrap shadow-lg pointer-events-none">
|
||||
{gatewayTitle}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Health indicator */}
|
||||
{!health ? (
|
||||
<span className={compact ? "text-[10px] text-[var(--text-muted)]" : "text-xs text-[var(--text-muted)]"}>--</span>
|
||||
) : health.ok ? (
|
||||
) : health.ok && !showWarning ? (
|
||||
<span className={compact ? "text-green-400 text-xs cursor-help" : "text-green-400 text-sm cursor-help"} title={t("gateway.healthy")}>✅</span>
|
||||
) : showWarning ? (
|
||||
<span
|
||||
className={compact ? "text-yellow-400 text-xs cursor-pointer" : "text-yellow-400 text-sm cursor-pointer"}
|
||||
title="Telegram 連線異常,建議重啟"
|
||||
onClick={handleDetailClick}
|
||||
>⚠️</span>
|
||||
) : (
|
||||
<span
|
||||
className={compact ? "text-red-400 text-xs cursor-pointer" : "text-red-400 text-sm cursor-pointer"}
|
||||
title={health.error || t("gateway.unhealthy")}
|
||||
onClick={() => setShowError((v) => !v)}
|
||||
onClick={handleDetailClick}
|
||||
>❌</span>
|
||||
)}
|
||||
{showError && health && !health.ok && health.error && (
|
||||
<div className="absolute top-full left-0 mt-1 z-50 px-3 py-2 rounded-lg bg-red-500/15 border border-red-500/30 text-red-300 text-xs max-w-64 whitespace-pre-wrap shadow-lg">
|
||||
{health.error}
|
||||
|
||||
{/* Restart button — shown when there's a problem */}
|
||||
{showRestart && (
|
||||
<button
|
||||
onClick={handleDetailClick}
|
||||
className={`inline-flex items-center gap-1 rounded-full border font-medium transition-colors ${
|
||||
compact ? "px-1.5 py-0.5 text-[10px]" : "px-2 py-0.5 text-xs"
|
||||
} bg-orange-500/20 text-orange-300 border-orange-500/40 hover:bg-orange-500/35 cursor-pointer`}
|
||||
title="查看問題並重啟 Gateway"
|
||||
>
|
||||
🔄{!compact && " 重啟"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Detail panel */}
|
||||
{showDetail && (
|
||||
<div className="absolute top-full left-0 mt-1 z-50 rounded-lg bg-[var(--card)] border border-[var(--border)] shadow-xl text-xs w-72 overflow-hidden">
|
||||
<div className="px-3 py-2 border-b border-[var(--border)] flex items-center justify-between">
|
||||
<span className="font-semibold text-[var(--text)]">Gateway 狀態</span>
|
||||
<button onClick={() => setShowDetail(false)} className="text-[var(--text-muted)] hover:text-[var(--text)] cursor-pointer">✕</button>
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-2 space-y-2">
|
||||
{/* Health status */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[var(--text-muted)]">Process:</span>
|
||||
<span className={health?.ok ? "text-green-400" : "text-red-400"}>
|
||||
{health?.ok ? "✅ 運作中" : "❌ 無回應"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Telegram stall */}
|
||||
{logResult && logResult.issues.includes("telegram_stall") && (
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-[var(--text-muted)] shrink-0">Telegram:</span>
|
||||
<span className="text-yellow-400">
|
||||
⚠️ Polling 異常
|
||||
{logResult.lastStallAt && (
|
||||
<span className="text-[var(--text-muted)] ml-1">
|
||||
({new Date(logResult.lastStallAt).toLocaleTimeString("zh-TW", { hour: "2-digit", minute: "2-digit" })})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subagent timeout */}
|
||||
{logResult && logResult.issues.includes("subagent_timeout") && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[var(--text-muted)]">Subagent:</span>
|
||||
<span className="text-orange-400">⚠️ 有 timeout 記錄</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error message when down */}
|
||||
{health && !health.ok && health.error && (
|
||||
<div className="text-red-300 bg-red-500/10 rounded px-2 py-1.5 leading-relaxed">
|
||||
{health.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Restart result message */}
|
||||
{restartMsg && (
|
||||
<div className={`rounded px-2 py-1.5 leading-relaxed ${
|
||||
restartMsg.startsWith("✅") ? "text-green-300 bg-green-500/10" : "text-red-300 bg-red-500/10"
|
||||
}`}>
|
||||
{restartMsg}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Restart button */}
|
||||
<div className="px-3 py-2 border-t border-[var(--border)]">
|
||||
<button
|
||||
onClick={handleRestart}
|
||||
disabled={restarting}
|
||||
className="w-full py-1.5 rounded-lg bg-orange-500/20 text-orange-300 border border-orange-500/40 hover:bg-orange-500/35 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer font-medium"
|
||||
>
|
||||
{restarting ? "⏳ 重啟中…" : "🔄 重啟 Gateway"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -134,3 +134,6 @@ body {
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.no-scrollbar::-webkit-scrollbar { display: none; }
|
||||
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
||||
|
||||
+52
-25
@@ -462,8 +462,8 @@ export default function PixelOfficePage() {
|
||||
if (!officeReady || !office || cachedAgents.length === 0) return
|
||||
|
||||
for (const [agentId, charId] of agentIdMapRef.current) {
|
||||
office.removeAllSubagents(charId)
|
||||
office.removeAgent(charId)
|
||||
office.removeAllSubagentsImmediately(charId)
|
||||
office.removeAgentImmediately(charId)
|
||||
agentIdMapRef.current.delete(agentId)
|
||||
}
|
||||
nextIdRef.current.current = 1
|
||||
@@ -812,7 +812,8 @@ export default function PixelOfficePage() {
|
||||
}
|
||||
// Broadcast notification on meaningful state transitions
|
||||
if (prev && prev !== agent.state) {
|
||||
if (agent.state === 'working' && prev !== 'working') {
|
||||
// Only show "上班了" when agent comes back from offline (not from idle)
|
||||
if (agent.state === 'working' && prev === 'offline') {
|
||||
const bid = Date.now() + Math.random()
|
||||
setBroadcasts(b => [...b, { id: bid, emoji: agent.emoji, text: `${agent.emoji} ${agent.name} ${t('pixelOffice.broadcast.online')}` }])
|
||||
setTimeout(() => setBroadcasts(b => b.filter(x => x.id !== bid)), 5000)
|
||||
@@ -892,6 +893,40 @@ export default function PixelOfficePage() {
|
||||
return () => clearInterval(interval)
|
||||
}, [refreshGatewayHealthSnapshot])
|
||||
|
||||
// Debug helper: expose __pixelOffice on window for console testing
|
||||
const debugCounterRef = useRef(0)
|
||||
useEffect(() => {
|
||||
;(window as any).__pixelOffice = {
|
||||
addAgents(count = 1) {
|
||||
const office = officeRef.current
|
||||
if (!office) { console.warn('[pixelOffice] office not ready'); return }
|
||||
for (let i = 0; i < count; i++) {
|
||||
debugCounterRef.current++
|
||||
const id = 9000 + debugCounterRef.current
|
||||
office.addAgent(id, undefined, undefined, undefined, undefined, true)
|
||||
}
|
||||
console.log(`[pixelOffice] added ${count} agent(s), ids 9001–${9000 + debugCounterRef.current}`)
|
||||
},
|
||||
clearDebug() {
|
||||
const office = officeRef.current
|
||||
if (!office) return
|
||||
for (let i = 1; i <= debugCounterRef.current; i++) {
|
||||
office.removeAgent(9000 + i)
|
||||
}
|
||||
debugCounterRef.current = 0
|
||||
console.log('[pixelOffice] cleared debug agents')
|
||||
},
|
||||
list() {
|
||||
const office = officeRef.current
|
||||
if (!office) return
|
||||
const rows: any[] = []
|
||||
for (const [id, ch] of office.characters) rows.push({ id, state: ch.state, tile: `${ch.tileCol},${ch.tileRow}` })
|
||||
console.table(rows)
|
||||
},
|
||||
}
|
||||
return () => { delete (window as any).__pixelOffice }
|
||||
}, []) // mount once only
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedAgentId) return
|
||||
try {
|
||||
@@ -1180,12 +1215,8 @@ export default function PixelOfficePage() {
|
||||
return tileX >= f.col && tileX < f.col + entry.footprintW &&
|
||||
tileY >= f.row && tileY < f.row + entry.footprintH
|
||||
})) {
|
||||
// Click on PC — open gateway chat for main agent
|
||||
const gw = gatewayRef.current
|
||||
const sessionKey = 'agent:main:main'
|
||||
let chatUrl = buildGatewayUrl(gw.port, '/chat', { session: sessionKey }, gw.host)
|
||||
if (gw.token) chatUrl = buildGatewayUrl(gw.port, '/chat', { session: sessionKey, token: gw.token }, gw.host)
|
||||
window.open(chatUrl, '_blank')
|
||||
// Click on PC — navigate to dashboard settings
|
||||
window.location.href = '/'
|
||||
} else if (office.layout.furniture.some(f => {
|
||||
if (f.uid !== 'library-r') return false
|
||||
const entry = getCatalogEntry(f.type)
|
||||
@@ -1658,6 +1689,8 @@ export default function PixelOfficePage() {
|
||||
})
|
||||
}
|
||||
}
|
||||
const stateOrder: Record<string, number> = { working: 0, waiting: 1, idle: 2, offline: 3 }
|
||||
expanded.sort((a, b) => (stateOrder[a.state] ?? 9) - (stateOrder[b.state] ?? 9))
|
||||
return expanded
|
||||
}, [agents])
|
||||
|
||||
@@ -1781,22 +1814,7 @@ export default function PixelOfficePage() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="md:hidden overflow-x-auto pb-1">
|
||||
{displayAgents.length === 0 ? (
|
||||
<div className="text-[var(--text-muted)] text-sm">{t('common.noData')}</div>
|
||||
) : (
|
||||
<div className="flex gap-2 min-w-full snap-x snap-mandatory">
|
||||
{mobileAgentPages.map((page, pageIndex) => (
|
||||
<div key={`mobile-agent-page-${pageIndex}`} className="grid grid-cols-3 grid-rows-3 gap-2 min-w-full h-[8.4rem] shrink-0 snap-start">
|
||||
{page.map((agent) => renderAgentChip(agent, true))}
|
||||
{page.length < 9 && Array.from({ length: 9 - page.length }).map((_, i) => (
|
||||
<div key={`mobile-agent-page-${pageIndex}-placeholder-${i}`} className="rounded-lg border border-transparent" />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Mobile agent list moved to canvas overlay below */}
|
||||
<div className="hidden md:flex gap-2 flex-1 flex-wrap">
|
||||
{displayAgents.map((agent) => renderAgentChip(agent))}
|
||||
{displayAgents.length === 0 && (
|
||||
@@ -1825,6 +1843,15 @@ export default function PixelOfficePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile agent list overlay at bottom of canvas */}
|
||||
{isMobileViewport && (
|
||||
<div className="absolute bottom-0 left-0 right-0 z-10 px-2 pb-1 pt-1 bg-gradient-to-t from-black/60 to-transparent pointer-events-none">
|
||||
<div className="flex gap-1.5 overflow-x-auto no-scrollbar pointer-events-auto">
|
||||
{displayAgents.map((agent) => renderAgentChip(agent, true))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Broadcast notifications */}
|
||||
{broadcasts.length > 0 && (
|
||||
<div className="absolute top-3 left-1/2 -translate-x-1/2 z-10 flex flex-col gap-2 pointer-events-none">
|
||||
|
||||
+117
-4
@@ -67,6 +67,10 @@ export default function SkillsPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState<"all" | "builtin" | "extension" | "custom">("all");
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedSkill, setSelectedSkill] = useState<Skill | null>(null);
|
||||
const [skillContent, setSkillContent] = useState<Record<string, string>>({});
|
||||
const [contentLoading, setContentLoading] = useState(false);
|
||||
const [contentError, setContentError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -110,6 +114,62 @@ export default function SkillsPage() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedSkill) return;
|
||||
|
||||
const cacheKey = `${selectedSkill.source}:${selectedSkill.id}`;
|
||||
if (skillContent[cacheKey]) {
|
||||
setContentError(null);
|
||||
setContentLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
setContentLoading(true);
|
||||
setContentError(null);
|
||||
|
||||
fetch(`/api/skills/content?source=${encodeURIComponent(selectedSkill.source)}&id=${encodeURIComponent(selectedSkill.id)}`, {
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then(async (response) => {
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data?.error || `HTTP ${response.status}`);
|
||||
}
|
||||
return data;
|
||||
})
|
||||
.then((data) => {
|
||||
const content = typeof data?.content === "string" ? data.content : "";
|
||||
setSkillContent((prev) => ({ ...prev, [cacheKey]: content }));
|
||||
})
|
||||
.catch((err) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setContentError(err instanceof Error ? err.message : String(err));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) {
|
||||
setContentLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [selectedSkill, skillContent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedSkill) return;
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setSelectedSkill(null);
|
||||
setContentError(null);
|
||||
setContentLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [selectedSkill]);
|
||||
|
||||
const filtered = skills.filter((skill) => {
|
||||
if (filter === "builtin" && skill.source !== "builtin") return false;
|
||||
if (filter === "extension" && !skill.source.startsWith("extension:")) return false;
|
||||
@@ -137,6 +197,9 @@ export default function SkillsPage() {
|
||||
return "bg-green-500/20 text-green-400";
|
||||
};
|
||||
|
||||
const selectedSkillCacheKey = selectedSkill ? `${selectedSkill.source}:${selectedSkill.id}` : "";
|
||||
const selectedSkillContent = selectedSkill ? skillContent[selectedSkillCacheKey] : "";
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
@@ -217,9 +280,11 @@ export default function SkillsPage() {
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((skill) => (
|
||||
<div
|
||||
<button
|
||||
key={`${skill.source}-${skill.id}`}
|
||||
className="rounded-xl border border-[var(--border)] bg-[var(--card)] p-4 hover:border-[var(--accent)]/50 transition"
|
||||
type="button"
|
||||
onClick={() => setSelectedSkill(skill)}
|
||||
className="rounded-xl border border-[var(--border)] bg-[var(--card)] p-4 hover:border-[var(--accent)]/50 transition text-left cursor-pointer"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2 gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
@@ -233,6 +298,9 @@ export default function SkillsPage() {
|
||||
<p className="text-xs text-[var(--text-muted)] line-clamp-2 mb-3 min-h-[2.5em]">
|
||||
{skill.description || t("skills.noDesc")}
|
||||
</p>
|
||||
<div className="mb-3 text-[10px] text-[var(--accent)]">
|
||||
{t("skills.viewSource")}
|
||||
</div>
|
||||
{skill.usedBy.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{skill.usedBy.map((agentId) => {
|
||||
@@ -248,10 +316,55 @@ export default function SkillsPage() {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedSkill && (
|
||||
<div className="fixed inset-0 z-[70]">
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 bg-black/60"
|
||||
aria-label={t("common.close")}
|
||||
onClick={() => {
|
||||
setSelectedSkill(null);
|
||||
setContentError(null);
|
||||
setContentLoading(false);
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-x-4 inset-y-6 md:inset-x-10 lg:inset-x-24 xl:inset-x-40 rounded-2xl border border-[var(--border)] bg-[var(--card)] shadow-2xl flex flex-col overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-[var(--border)] flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold truncate">{selectedSkill.emoji} {selectedSkill.name}</div>
|
||||
<div className="text-xs text-[var(--text-muted)]">{t("skills.contentTitle")}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedSkill(null);
|
||||
setContentError(null);
|
||||
setContentLoading(false);
|
||||
}}
|
||||
className="px-3 py-1.5 rounded-lg border border-[var(--border)] bg-[var(--bg)] text-sm hover:border-[var(--accent)] transition"
|
||||
>
|
||||
{t("common.close")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto bg-[var(--bg)]/40">
|
||||
{contentLoading && !selectedSkillContent ? (
|
||||
<div className="p-4 text-sm text-[var(--text-muted)]">{t("skills.loadingContent")}</div>
|
||||
) : contentError ? (
|
||||
<div className="p-4 text-sm text-red-400">{t("skills.contentLoadFailed")}: {contentError}</div>
|
||||
) : (
|
||||
<pre className="p-4 text-xs md:text-sm leading-6 whitespace-pre-wrap break-words text-[var(--text)] font-mono">
|
||||
{selectedSkillContent}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
curl https://gpt.qt.cool/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer sk-qt-cool-rAesCG1982qKfefUDoKa2zREJMkMTDMY" \
|
||||
-d '{
|
||||
"model": "gpt-5.3-codex",
|
||||
"messages": [
|
||||
{"role": "user", "content": "hi"}
|
||||
],
|
||||
"max_tokens": 16
|
||||
}'
|
||||
+32
-1
@@ -136,10 +136,12 @@ const translations: Record<Locale, Record<string, string>> = {
|
||||
|
||||
// platform
|
||||
"platform.feishu": "📱 飛書",
|
||||
"platform.yuanbao": "🤖 元宝",
|
||||
"platform.discord": "🎮 Discord",
|
||||
"platform.telegram": "✈️ Telegram",
|
||||
"platform.whatsapp": "💬 WhatsApp",
|
||||
"platform.qqbot": "🐧 QQBot",
|
||||
"platform.wecom": "💼 企業微信",
|
||||
|
||||
// time range
|
||||
"range.daily": "按日",
|
||||
@@ -238,6 +240,10 @@ const translations: Record<Locale, Record<string, string>> = {
|
||||
"skills.noDesc": "無描述",
|
||||
"skills.source.builtin": "內建",
|
||||
"skills.source.custom": "自訂",
|
||||
"skills.viewSource": "查看 SKILL.md",
|
||||
"skills.contentTitle": "SKILL.md 內容",
|
||||
"skills.loadingContent": "正在載入技能內容...",
|
||||
"skills.contentLoadFailed": "技能內容載入失敗",
|
||||
|
||||
// gateway status
|
||||
"gateway.healthy": "Gateway 運作正常",
|
||||
@@ -418,10 +424,12 @@ const translations: Record<Locale, Record<string, string>> = {
|
||||
|
||||
// platform
|
||||
"platform.feishu": "📱 飞书",
|
||||
"platform.yuanbao": "🤖 元宝",
|
||||
"platform.discord": "🎮 Discord",
|
||||
"platform.telegram": "✈️ Telegram",
|
||||
"platform.whatsapp": "💬 WhatsApp",
|
||||
"platform.qqbot": "🐧 QQBot",
|
||||
"platform.wecom": "💼 企业微信",
|
||||
|
||||
// time range
|
||||
"range.daily": "按天",
|
||||
@@ -520,6 +528,10 @@ const translations: Record<Locale, Record<string, string>> = {
|
||||
"skills.noDesc": "无描述",
|
||||
"skills.source.builtin": "内置",
|
||||
"skills.source.custom": "自定义",
|
||||
"skills.viewSource": "查看 SKILL.md",
|
||||
"skills.contentTitle": "SKILL.md 内容",
|
||||
"skills.loadingContent": "正在加载技能内容...",
|
||||
"skills.contentLoadFailed": "技能内容加载失败",
|
||||
|
||||
// gateway status
|
||||
"gateway.healthy": "Gateway 运行正常",
|
||||
@@ -700,10 +712,12 @@ const translations: Record<Locale, Record<string, string>> = {
|
||||
|
||||
// platform
|
||||
"platform.feishu": "📱 Feishu",
|
||||
"platform.yuanbao": "🤖 Yuanbao",
|
||||
"platform.discord": "🎮 Discord",
|
||||
"platform.telegram": "✈️ Telegram",
|
||||
"platform.whatsapp": "💬 WhatsApp",
|
||||
"platform.qqbot": "🐧 QQBot",
|
||||
"platform.wecom": "💼 WeCom",
|
||||
|
||||
// time range
|
||||
"range.daily": "Daily",
|
||||
@@ -802,6 +816,10 @@ const translations: Record<Locale, Record<string, string>> = {
|
||||
"skills.noDesc": "No description",
|
||||
"skills.source.builtin": "Built-in",
|
||||
"skills.source.custom": "Custom",
|
||||
"skills.viewSource": "View SKILL.md",
|
||||
"skills.contentTitle": "SKILL.md",
|
||||
"skills.loadingContent": "Loading skill content...",
|
||||
"skills.contentLoadFailed": "Failed to load skill content",
|
||||
|
||||
// gateway status
|
||||
"gateway.healthy": "Gateway is running",
|
||||
@@ -865,13 +883,26 @@ const I18nContext = createContext<I18nContextType>({
|
||||
t: (key) => key,
|
||||
});
|
||||
|
||||
function detectBrowserLocale(): Locale {
|
||||
const langs = navigator.languages?.length ? navigator.languages : [navigator.language];
|
||||
for (const lang of langs) {
|
||||
const l = lang.toLowerCase();
|
||||
if (l.startsWith('zh-tw') || l.startsWith('zh-hant') || l.startsWith('zh-hk') || l.startsWith('zh-mo')) return 'zh-TW';
|
||||
if (l.startsWith('zh')) return 'zh';
|
||||
if (l.startsWith('en')) return 'en';
|
||||
}
|
||||
return 'zh';
|
||||
}
|
||||
|
||||
export function I18nProvider({ children }: { children: ReactNode }) {
|
||||
const [locale, setLocaleState] = useState<Locale>("zh-TW");
|
||||
const [locale, setLocaleState] = useState<Locale>("zh");
|
||||
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem("locale") as Locale;
|
||||
if (saved && (saved === "zh-TW" || saved === "zh" || saved === "en")) {
|
||||
setLocaleState(saved);
|
||||
} else {
|
||||
setLocaleState(detectBrowserLocale());
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
+6
-1
@@ -212,6 +212,10 @@ async function probeModelDirect(params: ProbeModelParams): Promise<DirectProbeRe
|
||||
if (!providerCfg.api) providerCfg.api = "openai-completions";
|
||||
|
||||
const timeoutMs = params.timeoutMs ?? DEFAULT_MODEL_PROBE_TIMEOUT_MS;
|
||||
// Kimi providers require temperature=1
|
||||
const isKimiProvider = params.providerId === "kimi-coding" || params.providerId === "moonshot";
|
||||
const temperature = isKimiProvider ? 1 : 0;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"content-type": "application/json",
|
||||
...(providerCfg.headers || {}),
|
||||
@@ -225,6 +229,7 @@ async function probeModelDirect(params: ProbeModelParams): Promise<DirectProbeRe
|
||||
model: params.modelId,
|
||||
max_tokens: 8,
|
||||
messages: [{ role: "user", content: "Reply with OK." }],
|
||||
temperature,
|
||||
};
|
||||
const start = Date.now();
|
||||
try {
|
||||
@@ -300,7 +305,7 @@ async function probeModelDirect(params: ProbeModelParams): Promise<DirectProbeRe
|
||||
model: params.modelId,
|
||||
messages: [{ role: "user", content: "Reply with OK." }],
|
||||
max_tokens: 8,
|
||||
temperature: 0,
|
||||
temperature,
|
||||
};
|
||||
const start = Date.now();
|
||||
try {
|
||||
|
||||
@@ -19,6 +19,7 @@ export function getOpenclawPackageCandidates(version = process.version): string[
|
||||
|
||||
return uniquePaths([
|
||||
process.env.OPENCLAW_PACKAGE_DIR,
|
||||
path.join(home, ".local", "lib", "node_modules", "openclaw"),
|
||||
npmPrefix ? path.join(npmPrefix, "node_modules", "openclaw") : undefined,
|
||||
path.join(home, ".nvm", "versions", "node", version, "lib", "node_modules", "openclaw"),
|
||||
path.join(home, ".fnm", "node-versions", version, "installation", "lib", "node_modules", "openclaw"),
|
||||
@@ -31,4 +32,4 @@ export function getOpenclawPackageCandidates(version = process.version): string[
|
||||
"/usr/local/lib/node_modules/openclaw",
|
||||
"/usr/lib/node_modules/openclaw",
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { getOpenclawPackageCandidates, OPENCLAW_CONFIG_PATH, OPENCLAW_HOME } from "@/lib/openclaw-paths";
|
||||
|
||||
export interface SkillInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
emoji: string;
|
||||
source: string;
|
||||
location: string;
|
||||
usedBy: string[];
|
||||
}
|
||||
|
||||
export interface SkillAgentInfo {
|
||||
name: string;
|
||||
emoji: string;
|
||||
}
|
||||
|
||||
function findOpenClawPkg(): string {
|
||||
const candidates = getOpenclawPackageCandidates();
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(path.join(candidate, "package.json"))) return candidate;
|
||||
}
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
const OPENCLAW_PKG = findOpenClawPkg();
|
||||
|
||||
function parseFrontmatter(content: string): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
if (!content.startsWith("---")) return result;
|
||||
const parts = content.split("---", 3);
|
||||
if (parts.length < 3) return result;
|
||||
const fm = parts[1];
|
||||
|
||||
const nameMatch = fm.match(/^name:\s*(.+)/m);
|
||||
if (nameMatch) result.name = nameMatch[1].trim().replace(/^["']|["']$/g, "");
|
||||
|
||||
const descMatch = fm.match(/^description:\s*["']?(.+?)["']?\s*$/m);
|
||||
if (descMatch) result.description = descMatch[1].trim().replace(/^["']|["']$/g, "");
|
||||
|
||||
const emojiMatch = fm.match(/"emoji":\s*"([^"]+)"/);
|
||||
if (emojiMatch) result.emoji = emojiMatch[1];
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function readSkillFile(skillMd: string, source: string, id = path.basename(path.dirname(skillMd))): SkillInfo | null {
|
||||
if (!fs.existsSync(skillMd)) return null;
|
||||
const content = fs.readFileSync(skillMd, "utf-8");
|
||||
const fm = parseFrontmatter(content);
|
||||
return {
|
||||
id,
|
||||
name: fm.name || id,
|
||||
description: fm.description || "",
|
||||
emoji: fm.emoji || "🔧",
|
||||
source,
|
||||
location: skillMd,
|
||||
usedBy: [],
|
||||
};
|
||||
}
|
||||
|
||||
function scanSkillsDir(dir: string, source: string): SkillInfo[] {
|
||||
const skills: SkillInfo[] = [];
|
||||
if (!fs.existsSync(dir)) return skills;
|
||||
for (const name of fs.readdirSync(dir).sort()) {
|
||||
const skill = readSkillFile(path.join(dir, name, "SKILL.md"), source, name);
|
||||
if (skill) skills.push(skill);
|
||||
}
|
||||
return skills;
|
||||
}
|
||||
|
||||
function getAgentSkillsFromSessions(): Record<string, Set<string>> {
|
||||
const agentsDir = path.join(OPENCLAW_HOME, "agents");
|
||||
const result: Record<string, Set<string>> = {};
|
||||
if (!fs.existsSync(agentsDir)) return result;
|
||||
|
||||
for (const agentId of fs.readdirSync(agentsDir)) {
|
||||
const sessionsDir = path.join(agentsDir, agentId, "sessions");
|
||||
if (!fs.existsSync(sessionsDir)) continue;
|
||||
|
||||
const jsonlFiles = fs.readdirSync(sessionsDir)
|
||||
.filter((file) => file.endsWith(".jsonl"))
|
||||
.sort();
|
||||
const skillNames = new Set<string>();
|
||||
|
||||
for (const file of jsonlFiles.slice(-3)) {
|
||||
const content = fs.readFileSync(path.join(sessionsDir, file), "utf-8");
|
||||
const idx = content.indexOf("skillsSnapshot");
|
||||
if (idx < 0) continue;
|
||||
const chunk = content.slice(idx, idx + 5000);
|
||||
const matches = chunk.matchAll(/\\?"name\\?":\s*\\?"([^"\\]+)\\?"/g);
|
||||
for (const match of matches) {
|
||||
const name = match[1];
|
||||
if (!["exec", "read", "edit", "write", "process", "message", "web_search", "web_fetch",
|
||||
"browser", "tts", "gateway", "memory_search", "memory_get", "cron", "nodes",
|
||||
"canvas", "session_status", "sessions_list", "sessions_history", "sessions_send",
|
||||
"sessions_spawn", "agents_list"].includes(name) && name.length > 1) {
|
||||
skillNames.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (skillNames.size > 0) result[agentId] = skillNames;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function listOpenclawSkills(): { skills: SkillInfo[]; agents: Record<string, SkillAgentInfo>; total: number } {
|
||||
const builtinSkills = scanSkillsDir(path.join(OPENCLAW_PKG, "skills"), "builtin");
|
||||
|
||||
const extDir = path.join(OPENCLAW_PKG, "extensions");
|
||||
const extSkills: SkillInfo[] = [];
|
||||
if (fs.existsSync(extDir)) {
|
||||
for (const ext of fs.readdirSync(extDir)) {
|
||||
const extSkill = readSkillFile(path.join(extDir, ext, "SKILL.md"), `extension:${ext}`, ext);
|
||||
if (extSkill) extSkills.push(extSkill);
|
||||
|
||||
const skillsDir = path.join(extDir, ext, "skills");
|
||||
if (fs.existsSync(skillsDir)) {
|
||||
extSkills.push(...scanSkillsDir(skillsDir, `extension:${ext}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const customSkills = scanSkillsDir(path.join(OPENCLAW_HOME, "skills"), "custom");
|
||||
const allSkills = [...builtinSkills, ...extSkills, ...customSkills];
|
||||
|
||||
const agentSkills = getAgentSkillsFromSessions();
|
||||
for (const skill of allSkills) {
|
||||
for (const [agentId, skills] of Object.entries(agentSkills)) {
|
||||
if (skills.has(skill.id) || skills.has(skill.name)) {
|
||||
skill.usedBy.push(agentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const config = JSON.parse(fs.readFileSync(OPENCLAW_CONFIG_PATH, "utf-8"));
|
||||
const agentList = config.agents?.list || [];
|
||||
const agents: Record<string, SkillAgentInfo> = {};
|
||||
for (const agent of agentList) {
|
||||
agents[agent.id] = {
|
||||
name: agent.identity?.name || agent.name || agent.id,
|
||||
emoji: agent.identity?.emoji || "🤖",
|
||||
};
|
||||
}
|
||||
|
||||
return { skills: allSkills, agents, total: allSkills.length };
|
||||
}
|
||||
|
||||
export function getOpenclawSkillContent(source: string, id: string): { skill: SkillInfo; content: string } | null {
|
||||
const { skills } = listOpenclawSkills();
|
||||
const skill = skills.find((entry) => entry.source === source && entry.id === id);
|
||||
if (!skill) return null;
|
||||
return {
|
||||
skill,
|
||||
content: fs.readFileSync(skill.location, "utf-8"),
|
||||
};
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export interface AgentActivity {
|
||||
toolStatus?: string
|
||||
lastActive: number
|
||||
subagents?: SubagentInfo[]
|
||||
lastTask?: string
|
||||
}
|
||||
|
||||
/** Track which subagent keys were active last sync, per parent agent */
|
||||
@@ -57,29 +58,38 @@ export function syncAgentsToOffice(
|
||||
}
|
||||
|
||||
let charId = agentIdMap.get(activity.agentId)
|
||||
if (charId !== undefined && !office.characters.has(charId)) {
|
||||
agentIdMap.delete(activity.agentId)
|
||||
charId = undefined
|
||||
}
|
||||
if (charId === undefined) {
|
||||
charId = nextIdRef.current++
|
||||
agentIdMap.set(activity.agentId, charId)
|
||||
// Spawn at door if agent was previously offline or is brand new
|
||||
// 只有從 offline 恢復時才從門口走進來;
|
||||
// 頁面初始載入(isNew)時直接放到座位,避免讓使用者以為角色剛去摸魚回來
|
||||
const wasOffline = prevAgentStates.get(activity.agentId) === 'offline'
|
||||
const isNew = !prevAgentStates.has(activity.agentId)
|
||||
office.addAgent(charId, undefined, undefined, undefined, undefined, wasOffline || isNew)
|
||||
office.addAgent(charId, undefined, undefined, undefined, undefined, wasOffline)
|
||||
}
|
||||
|
||||
// Set label (agent name with id in parentheses)
|
||||
// Set label, avoiding duplicated values like "main (main)"
|
||||
const ch = office.characters.get(charId)
|
||||
if (ch) {
|
||||
ch.label = activity.name ? `${activity.name} (${activity.agentId})` : activity.agentId
|
||||
const displayName = activity.name?.trim()
|
||||
ch.label = displayName && displayName !== activity.agentId
|
||||
? `${displayName} (${activity.agentId})`
|
||||
: activity.agentId
|
||||
}
|
||||
|
||||
switch (activity.state) {
|
||||
case 'working':
|
||||
office.setAgentActive(charId, true)
|
||||
office.setAgentTool(charId, activity.currentTool || null)
|
||||
office.setAgentTaskText(charId, activity.lastTask)
|
||||
break
|
||||
case 'idle':
|
||||
office.setAgentActive(charId, false)
|
||||
office.setAgentTool(charId, null)
|
||||
office.setAgentTaskText(charId, undefined)
|
||||
break
|
||||
case 'waiting':
|
||||
office.setAgentActive(charId, true)
|
||||
|
||||
@@ -103,6 +103,8 @@ export function createCharacter(
|
||||
codeSnippets: [],
|
||||
photoComments: [],
|
||||
isViewingPhoto: false,
|
||||
yieldTimer: 0,
|
||||
yieldDestination: null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,8 +368,8 @@ export function updateCharacter(
|
||||
ch.moveProgress = 0
|
||||
}
|
||||
|
||||
// If became active while wandering, repath to seat (skip if under greeting control)
|
||||
if (ch.isActive && ch.seatId && !ch.greetLocked) {
|
||||
// If became active while wandering, repath to seat (skip if under greeting control or yielding)
|
||||
if (ch.isActive && ch.seatId && !ch.greetLocked && !ch.yieldDestination) {
|
||||
const seat = seats.get(ch.seatId)
|
||||
if (seat) {
|
||||
const lastStep = ch.path[ch.path.length - 1]
|
||||
|
||||
@@ -390,6 +390,8 @@ interface GreetingSequence {
|
||||
childId: number
|
||||
parentId: number
|
||||
childTarget: { col: number; row: number } | null
|
||||
/** Where the parent walks to when they are NOT at their seat (midpoint meeting) */
|
||||
parentTarget: { col: number; row: number } | null
|
||||
/** Tile to wait at when MainAgent is busy with another greeter */
|
||||
waitTarget: { col: number; row: number } | null
|
||||
phase: 'waiting' | 'walk' | 'pause' | 'parent_smile' | 'child_smile' | 'final_pause' | 'complete'
|
||||
@@ -397,6 +399,8 @@ interface GreetingSequence {
|
||||
isExit: boolean
|
||||
/** Tile to walk back to after farewell, before despawning */
|
||||
exitReturnPos: { col: number; row: number } | null
|
||||
/** True when child arrived at greeting tile but MainAgent wasn't physically present */
|
||||
parentAbsent?: boolean
|
||||
}
|
||||
|
||||
export class OfficeState {
|
||||
@@ -429,7 +433,7 @@ export class OfficeState {
|
||||
private gatewaySreError: string | null = null
|
||||
private gatewaySreResponseMs: number | null = null
|
||||
private gatewaySreCheckedAt: number | null = null
|
||||
private locale: OfficeLocale = 'zh-TW'
|
||||
private locale: OfficeLocale = 'zh'
|
||||
private activeGreetings: Map<number, GreetingSequence> = new Map()
|
||||
/** The first regular agent added — all other agents greet this one on entry */
|
||||
private mainAgentId: number | null = null
|
||||
@@ -439,6 +443,8 @@ export class OfficeState {
|
||||
private lingerSubagents: Map<number, number> = new Map()
|
||||
/** Subagent IDs queued for farewell — when processGreetQueue starts their greeting, mark isExit=true */
|
||||
private exitOnGreetComplete: Set<number> = new Set()
|
||||
/** Agent IDs that were explicitly set to idle (下班) — only these should greet on next activation */
|
||||
private explicitlyIdledAgents: Set<number> = new Set()
|
||||
/** Stashed exit return positions for subagents waiting in greetQueue. Cleared when greeting starts. */
|
||||
private exitReturnStash: Map<number, { col: number; row: number }> = new Map()
|
||||
|
||||
@@ -470,7 +476,7 @@ export class OfficeState {
|
||||
return getBlockedTiles(furniture, nonBlockingSeatTiles)
|
||||
}
|
||||
|
||||
constructor(layout?: OfficeLayout, locale: OfficeLocale = 'zh-TW') {
|
||||
constructor(layout?: OfficeLayout, locale: OfficeLocale = 'zh') {
|
||||
this.locale = locale
|
||||
this.layout = layout || createDefaultLayout()
|
||||
this.tileMap = layoutToTileMap(this.layout)
|
||||
@@ -893,6 +899,41 @@ export class OfficeState {
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a midpoint meeting location when MainAgent is not at their seat.
|
||||
* Returns childTarget (where child walks) and parentTarget (where parent walks),
|
||||
* adjacent to each other near the midpoint between the two characters.
|
||||
*/
|
||||
private findMeetingTiles(
|
||||
child: Character,
|
||||
parent: Character,
|
||||
): { childTarget: { col: number; row: number }; parentTarget: { col: number; row: number } } | null {
|
||||
const midCol = Math.round((child.tileCol + parent.tileCol) / 2)
|
||||
const midRow = Math.round((child.tileRow + parent.tileRow) / 2)
|
||||
|
||||
// Find walkable tile closest to midpoint for the parent to walk to
|
||||
const parentTile = this.findClosestWalkable(midCol, midRow)
|
||||
if (!parentTile) return null
|
||||
|
||||
// Verify parent can reach that tile
|
||||
const parentPath = findPath(parent.tileCol, parent.tileRow, parentTile.col, parentTile.row, this.tileMap, this.blockedTiles)
|
||||
if (parentPath.length === 0 && !(parent.tileCol === parentTile.col && parent.tileRow === parentTile.row)) return null
|
||||
|
||||
// Child walks to a tile adjacent to parent's meeting tile
|
||||
const adjacents = [
|
||||
{ col: parentTile.col - 1, row: parentTile.row },
|
||||
{ col: parentTile.col + 1, row: parentTile.row },
|
||||
{ col: parentTile.col, row: parentTile.row - 1 },
|
||||
{ col: parentTile.col, row: parentTile.row + 1 },
|
||||
]
|
||||
for (const adj of adjacents) {
|
||||
if (!isWalkable(adj.col, adj.row, this.tileMap, this.blockedTiles)) continue
|
||||
const childPath = findPath(child.tileCol, child.tileRow, adj.col, adj.row, this.tileMap, this.blockedTiles)
|
||||
if (childPath.length > 0) return { childTarget: adj, parentTarget: parentTile }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Returns true if MainAgent is currently in an active greeting sequence */
|
||||
private isMainAgentBusy(): boolean {
|
||||
if (this.mainAgentId === null) return false
|
||||
@@ -935,7 +976,25 @@ export class OfficeState {
|
||||
this.greetQueue.length = 0
|
||||
return
|
||||
}
|
||||
const greetTile = this.findAdjacentWalkable(mainCh)
|
||||
// Determine meeting point based on whether MainAgent is at their seat
|
||||
let greetTile: { col: number; row: number } | null
|
||||
let parentTarget: { col: number; row: number } | null = null
|
||||
|
||||
if (mainCh.state === CharacterState.TYPE) {
|
||||
// MainAgent is at seat — child walks to adjacent tile, parent stays
|
||||
greetTile = this.findAdjacentWalkable(mainCh)
|
||||
} else {
|
||||
// MainAgent is not at seat — meet halfway
|
||||
const meeting = this.findMeetingTiles(ch, mainCh)
|
||||
if (meeting) {
|
||||
greetTile = meeting.childTarget
|
||||
parentTarget = meeting.parentTarget
|
||||
} else {
|
||||
// Fallback: walk to wherever MainAgent currently is
|
||||
greetTile = this.findAdjacentWalkable(mainCh)
|
||||
}
|
||||
}
|
||||
|
||||
if (!greetTile) { this.greetQueue.shift(); continue }
|
||||
const greetPath = findPath(ch.tileCol, ch.tileRow, greetTile.col, greetTile.row, this.tileMap, this.blockedTiles)
|
||||
if (greetPath.length === 0) { this.greetQueue.shift(); continue }
|
||||
@@ -944,6 +1003,20 @@ export class OfficeState {
|
||||
ch.state = CharacterState.WALK
|
||||
ch.moveProgress = 0
|
||||
ch.greetLocked = true
|
||||
|
||||
// If parent needs to walk to meeting point, lock and start them moving now
|
||||
if (parentTarget) {
|
||||
const parentPath = findPath(mainCh.tileCol, mainCh.tileRow, parentTarget.col, parentTarget.row, this.tileMap, this.blockedTiles)
|
||||
if (parentPath.length > 0) {
|
||||
mainCh.greetLocked = true
|
||||
mainCh.path = parentPath
|
||||
mainCh.state = CharacterState.WALK
|
||||
mainCh.moveProgress = 0
|
||||
} else {
|
||||
parentTarget = null // parent already there or unreachable
|
||||
}
|
||||
}
|
||||
|
||||
const isExitGreeting = this.exitOnGreetComplete.has(ch.id)
|
||||
if (isExitGreeting) this.exitOnGreetComplete.delete(ch.id)
|
||||
// Recover the stashed return position for this exit greeting
|
||||
@@ -953,6 +1026,7 @@ export class OfficeState {
|
||||
childId: ch.id,
|
||||
parentId: this.mainAgentId!,
|
||||
childTarget: greetTile,
|
||||
parentTarget,
|
||||
waitTarget: null,
|
||||
phase: 'walk',
|
||||
timer: 0,
|
||||
@@ -989,9 +1063,27 @@ export class OfficeState {
|
||||
const target = seq.childTarget
|
||||
if (!target) { seq.phase = 'complete'; break }
|
||||
|
||||
// Detected arrival: child tile matches target
|
||||
if (child.tileCol === target.col && child.tileRow === target.row) {
|
||||
// Override any FSM re-path
|
||||
// If parent has a meeting target, keep them walking toward it
|
||||
if (seq.parentTarget && parent) {
|
||||
const pt = seq.parentTarget
|
||||
const parentArrived = parent.tileCol === pt.col && parent.tileRow === pt.row
|
||||
if (!parentArrived && parent.path.length === 0 && parent.state !== CharacterState.WALK) {
|
||||
const repath = findPath(parent.tileCol, parent.tileRow, pt.col, pt.row, this.tileMap, this.blockedTiles)
|
||||
if (repath.length > 0) {
|
||||
parent.path = repath
|
||||
parent.state = CharacterState.WALK
|
||||
parent.moveProgress = 0
|
||||
parent.greetLocked = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const childArrived = child.tileCol === target.col && child.tileRow === target.row
|
||||
const parentArrived = !seq.parentTarget || !parent ||
|
||||
(parent.tileCol === seq.parentTarget.col && parent.tileRow === seq.parentTarget.row)
|
||||
|
||||
// Both have arrived at their respective meeting tiles
|
||||
if (childArrived && parentArrived) {
|
||||
child.path = []
|
||||
child.state = CharacterState.IDLE
|
||||
child.greetLocked = true
|
||||
@@ -1001,18 +1093,25 @@ export class OfficeState {
|
||||
parent.state = CharacterState.IDLE
|
||||
child.dir = this.directionToward(child.tileCol, child.tileRow, parent.tileCol, parent.tileRow)
|
||||
parent.dir = this.directionToward(parent.tileCol, parent.tileRow, child.tileCol, child.tileRow)
|
||||
// Detect if parent is not physically nearby (wandered away from greeting tile)
|
||||
const distToParent = Math.abs(parent.tileCol - child.tileCol) + Math.abs(parent.tileRow - child.tileRow)
|
||||
if (seq.isExit && distToParent > 3) {
|
||||
seq.parentAbsent = true
|
||||
}
|
||||
} else {
|
||||
// Parent character doesn't exist
|
||||
if (seq.isExit) seq.parentAbsent = true
|
||||
}
|
||||
seq.phase = 'pause'
|
||||
seq.timer = 1.0
|
||||
} else if (child.path.length === 0 && child.state !== CharacterState.WALK) {
|
||||
// Lost path mid-walk — try to re-path to target
|
||||
// Child lost path mid-walk — try to re-path to target
|
||||
const path = findPath(child.tileCol, child.tileRow, target.col, target.row, this.tileMap, this.blockedTiles)
|
||||
if (path.length > 0) {
|
||||
child.path = path
|
||||
child.state = CharacterState.WALK
|
||||
child.moveProgress = 0
|
||||
} else {
|
||||
// Can't reach — complete without greeting
|
||||
seq.phase = 'complete'
|
||||
}
|
||||
}
|
||||
@@ -1080,13 +1179,8 @@ export class OfficeState {
|
||||
|
||||
if (seq.isExit) {
|
||||
child.bubbleType = null
|
||||
const walkBack = this.findExitWalkPath(child, seq.exitReturnPos)
|
||||
if (walkBack) {
|
||||
child.path = walkBack.path
|
||||
child.state = CharacterState.WALK
|
||||
child.moveProgress = 0
|
||||
child.pendingDespawn = walkBack.target
|
||||
} else {
|
||||
// After farewell (whether MainAgent was present or not), walk to bottom-right corner
|
||||
if (!this.walkToBottomRightThenDespawn(child)) {
|
||||
child.matrixEffect = 'despawn'
|
||||
child.matrixEffectTimer = 0
|
||||
child.matrixEffectSeeds = matrixEffectSeeds()
|
||||
@@ -1142,6 +1236,38 @@ export class OfficeState {
|
||||
return this.findClosestWalkable(sofa.col, sofa.row)
|
||||
}
|
||||
|
||||
/** Find the walkable tile closest to the bottom-right corner of the map */
|
||||
private findBottomRightCornerTile(): { col: number; row: number } | null {
|
||||
if (this.walkableTiles.length === 0) return null
|
||||
// Bottom-right in tile space = max col + max row
|
||||
const maxCol = Math.max(...this.walkableTiles.map((t) => t.col))
|
||||
const maxRow = Math.max(...this.walkableTiles.map((t) => t.row))
|
||||
return this.findClosestWalkable(maxCol, maxRow)
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk character to the bottom-right corner then despawn.
|
||||
* Unlike findExitWalkPath, accepts any path length >= 1 so nearby agents still move.
|
||||
*/
|
||||
private walkToBottomRightThenDespawn(ch: Character): boolean {
|
||||
const corner = this.findBottomRightCornerTile()
|
||||
if (!corner) return false
|
||||
if (corner.col === ch.tileCol && corner.row === ch.tileRow) {
|
||||
// Already there — despawn directly
|
||||
ch.matrixEffect = 'despawn'
|
||||
ch.matrixEffectTimer = 0
|
||||
ch.matrixEffectSeeds = matrixEffectSeeds()
|
||||
return true
|
||||
}
|
||||
const path = findPath(ch.tileCol, ch.tileRow, corner.col, corner.row, this.tileMap, this.blockedTiles)
|
||||
if (path.length === 0) return false
|
||||
ch.path = path
|
||||
ch.state = CharacterState.WALK
|
||||
ch.moveProgress = 0
|
||||
ch.pendingDespawn = corner
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a walk-back path for a departing temp worker.
|
||||
* Ensures the path is long enough that the worker visibly walks away from MainAgent.
|
||||
@@ -1415,6 +1541,18 @@ export class OfficeState {
|
||||
ch.bubbleType = null
|
||||
}
|
||||
|
||||
removeAgentImmediately(id: number): void {
|
||||
const ch = this.characters.get(id)
|
||||
if (!ch) return
|
||||
if (ch.seatId) {
|
||||
const seat = this.seats.get(ch.seatId)
|
||||
if (seat) seat.assigned = false
|
||||
}
|
||||
if (this.selectedAgentId === id) this.selectedAgentId = null
|
||||
if (this.cameraFollowId === id) this.cameraFollowId = null
|
||||
this.characters.delete(id)
|
||||
}
|
||||
|
||||
/** Find seat uid at a given tile position, or null */
|
||||
getSeatAtTile(col: number, row: number): string | null {
|
||||
for (const [uid, seat] of this.seats) {
|
||||
@@ -1679,14 +1817,8 @@ export class OfficeState {
|
||||
// Stash the return position until the greeting actually starts
|
||||
if (exitReturnPos) this.exitReturnStash.set(id, exitReturnPos)
|
||||
} else {
|
||||
// No MainAgent reachable — walk back directly then despawn
|
||||
const walkBack = this.findExitWalkPath(ch, exitReturnPos)
|
||||
if (walkBack) {
|
||||
ch.pendingDespawn = walkBack.target
|
||||
ch.path = walkBack.path
|
||||
ch.state = CharacterState.WALK
|
||||
ch.moveProgress = 0
|
||||
} else {
|
||||
// No MainAgent reachable — walk to bottom-right corner then despawn
|
||||
if (!this.walkToBottomRightThenDespawn(ch)) {
|
||||
ch.matrixEffect = 'despawn'
|
||||
ch.matrixEffectTimer = 0
|
||||
ch.matrixEffectSeeds = matrixEffectSeeds()
|
||||
@@ -1739,6 +1871,7 @@ export class OfficeState {
|
||||
childId: id,
|
||||
parentId: parentAgentId,
|
||||
childTarget: greetTile,
|
||||
parentTarget: null,
|
||||
waitTarget: null,
|
||||
phase: 'walk',
|
||||
timer: 0,
|
||||
@@ -1752,9 +1885,12 @@ export class OfficeState {
|
||||
continue
|
||||
}
|
||||
}
|
||||
ch.matrixEffect = 'despawn'
|
||||
ch.matrixEffectTimer = 0
|
||||
ch.matrixEffectSeeds = matrixEffectSeeds()
|
||||
// No greeting path — walk to bottom-right corner then despawn
|
||||
if (!this.walkToBottomRightThenDespawn(ch)) {
|
||||
ch.matrixEffect = 'despawn'
|
||||
ch.matrixEffectTimer = 0
|
||||
ch.matrixEffectSeeds = matrixEffectSeeds()
|
||||
}
|
||||
}
|
||||
this.subagentMeta.delete(id)
|
||||
if (this.selectedAgentId === id) this.selectedAgentId = null
|
||||
@@ -1767,6 +1903,31 @@ export class OfficeState {
|
||||
}
|
||||
}
|
||||
|
||||
removeAllSubagentsImmediately(parentAgentId: number): void {
|
||||
const toRemove: string[] = []
|
||||
const toDeleteIds: number[] = []
|
||||
for (const [key, id] of this.subagentIdMap) {
|
||||
const meta = this.subagentMeta.get(id)
|
||||
if (!meta || meta.parentAgentId !== parentAgentId) continue
|
||||
const ch = this.characters.get(id)
|
||||
if (ch?.seatId) {
|
||||
const seat = this.seats.get(ch.seatId)
|
||||
if (seat) seat.assigned = false
|
||||
}
|
||||
if (this.selectedAgentId === id) this.selectedAgentId = null
|
||||
if (this.cameraFollowId === id) this.cameraFollowId = null
|
||||
this.subagentMeta.delete(id)
|
||||
toRemove.push(key)
|
||||
toDeleteIds.push(id)
|
||||
}
|
||||
for (const key of toRemove) {
|
||||
this.subagentIdMap.delete(key)
|
||||
}
|
||||
for (const id of toDeleteIds) {
|
||||
this.characters.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
/** Look up the sub-agent character ID for a given parent+toolId, or null */
|
||||
getSubagentId(parentAgentId: number, parentToolId: string): number | null {
|
||||
return this.subagentIdMap.get(`${parentAgentId}:${parentToolId}`) ?? null
|
||||
@@ -1859,6 +2020,13 @@ export class OfficeState {
|
||||
}
|
||||
}
|
||||
|
||||
setAgentTaskText(id: number, text: string | undefined): void {
|
||||
const ch = this.characters.get(id)
|
||||
if (ch) {
|
||||
ch.taskText = text
|
||||
}
|
||||
}
|
||||
|
||||
showPermissionBubble(id: number): void {
|
||||
const ch = this.characters.get(id)
|
||||
if (ch) {
|
||||
@@ -1913,9 +2081,170 @@ export class OfficeState {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collision avoidance: when two walking characters contest the same next tile,
|
||||
* the one farther from its destination steps aside (sideways first, then backwards)
|
||||
* and waits for the closer one to pass before resuming.
|
||||
*/
|
||||
/** Find a free adjacent tile for a character to dodge to, or null if none found */
|
||||
private findDodgeTile(
|
||||
ch: Character,
|
||||
dc: number, dr: number,
|
||||
walkableSet: Set<string>,
|
||||
occupiedKeys: Set<string>,
|
||||
claimedNext: Map<string, number>,
|
||||
): { col: number; row: number } | null {
|
||||
const candidates: Array<{ col: number; row: number }> = dc !== 0 || dr !== 0
|
||||
? [
|
||||
{ col: ch.tileCol + dr, row: ch.tileRow + dc }, // side A (perpendicular)
|
||||
{ col: ch.tileCol - dr, row: ch.tileRow - dc }, // side B (perpendicular)
|
||||
{ col: ch.tileCol - dc, row: ch.tileRow - dr }, // backwards
|
||||
]
|
||||
: [
|
||||
// No direction info — try all 4 neighbours
|
||||
{ col: ch.tileCol + 1, row: ch.tileRow },
|
||||
{ col: ch.tileCol - 1, row: ch.tileRow },
|
||||
{ col: ch.tileCol, row: ch.tileRow + 1 },
|
||||
{ col: ch.tileCol, row: ch.tileRow - 1 },
|
||||
]
|
||||
|
||||
for (const cand of candidates) {
|
||||
const key = `${cand.col},${cand.row}`
|
||||
if (!walkableSet.has(key)) continue
|
||||
if (this.blockedTiles.has(key)) continue
|
||||
if (occupiedKeys.has(key)) continue
|
||||
if (claimedNext.has(key)) continue
|
||||
return cand
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private resolveWalkConflicts(): void {
|
||||
const allHumanoids: Character[] = []
|
||||
for (const ch of this.characters.values()) {
|
||||
if (ch.matrixEffect || ch.isCat || ch.isLobster || ch.greetLocked) continue
|
||||
if (ch.yieldTimer > 0) continue
|
||||
allHumanoids.push(ch)
|
||||
}
|
||||
if (allHumanoids.length < 2) return
|
||||
|
||||
// Current tile positions of every character (for dodge-target exclusion)
|
||||
const occupiedKeys = new Set<string>()
|
||||
for (const ch of this.characters.values()) {
|
||||
occupiedKeys.add(`${ch.tileCol},${ch.tileRow}`)
|
||||
}
|
||||
|
||||
const walkableSet = new Set<string>(this.walkableTiles.map(t => `${t.col},${t.row}`))
|
||||
|
||||
// claimedNext: tiles that are "taken" — no other character may step into them.
|
||||
// Pre-populate with standing characters' current tiles only.
|
||||
// Walking characters claim their next tile dynamically in Pass 1 (sorted by priority).
|
||||
const claimedNext = new Map<string, number>()
|
||||
for (const ch of allHumanoids) {
|
||||
if (ch.state !== CharacterState.WALK) {
|
||||
claimedNext.set(`${ch.tileCol},${ch.tileRow}`, ch.id)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pass 1: next-tile conflicts ───────────────────────────────────────────
|
||||
// All walkers (including mid-step), sorted by remaining path length.
|
||||
// Shorter path = closer to destination = higher priority = claims the tile.
|
||||
// Loser is snapped back to current tile and must dodge.
|
||||
const walkers = allHumanoids.filter(
|
||||
ch => ch.state === CharacterState.WALK && !ch.yieldDestination && ch.path.length > 0,
|
||||
)
|
||||
walkers.sort((a, b) => a.path.length - b.path.length)
|
||||
|
||||
for (const ch of walkers) {
|
||||
const next = ch.path[0]
|
||||
const key = `${next.col},${next.row}`
|
||||
if (!claimedNext.has(key)) {
|
||||
// Also claim current tile so no one walks into us from behind
|
||||
claimedNext.set(`${ch.tileCol},${ch.tileRow}`, ch.id)
|
||||
claimedNext.set(key, ch.id)
|
||||
continue
|
||||
}
|
||||
// Tile is taken — snap back to current tile and dodge
|
||||
if (ch.moveProgress > 0) {
|
||||
// Abort mid-step: snap back to the tile we came from
|
||||
ch.x = ch.tileCol * TILE_SIZE + TILE_SIZE / 2
|
||||
ch.y = ch.tileRow * TILE_SIZE + TILE_SIZE / 2
|
||||
ch.moveProgress = 0
|
||||
}
|
||||
const dest = ch.path[ch.path.length - 1]
|
||||
const dc = next.col - ch.tileCol
|
||||
const dr = next.row - ch.tileRow
|
||||
const dodgeTile = this.findDodgeTile(ch, dc, dr, walkableSet, occupiedKeys, claimedNext)
|
||||
if (dodgeTile) {
|
||||
ch.path = [dodgeTile]
|
||||
ch.moveProgress = 0
|
||||
// Face the dodge direction immediately so the character doesn't appear to
|
||||
// move backward while still facing forward.
|
||||
ch.dir = this.directionToward(ch.tileCol, ch.tileRow, dodgeTile.col, dodgeTile.row)
|
||||
ch.yieldDestination = { col: dest.col, row: dest.row }
|
||||
claimedNext.set(`${ch.tileCol},${ch.tileRow}`, ch.id)
|
||||
claimedNext.set(`${dodgeTile.col},${dodgeTile.row}`, ch.id)
|
||||
} else {
|
||||
ch.path = []
|
||||
ch.yieldTimer = 0.5 + Math.random() * 0.4
|
||||
ch.yieldDestination = { col: dest.col, row: dest.row }
|
||||
claimedNext.set(`${ch.tileCol},${ch.tileRow}`, ch.id)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pass 2: hard overlap — same tileCol/tileRow right now ────────────────
|
||||
const tileGroups = new Map<string, Character[]>()
|
||||
for (const ch of allHumanoids) {
|
||||
const key = `${ch.tileCol},${ch.tileRow}`
|
||||
const g = tileGroups.get(key)
|
||||
if (g) g.push(ch)
|
||||
else tileGroups.set(key, [ch])
|
||||
}
|
||||
|
||||
for (const group of tileGroups.values()) {
|
||||
if (group.length < 2) continue
|
||||
|
||||
// Highest priority (index 0) stays; others dodge
|
||||
group.sort((a, b) => {
|
||||
const aScore = a.state === CharacterState.WALK ? a.path.length : 9999
|
||||
const bScore = b.state === CharacterState.WALK ? b.path.length : 9999
|
||||
return aScore - bScore
|
||||
})
|
||||
|
||||
for (let i = 1; i < group.length; i++) {
|
||||
const dodger = group[i]
|
||||
if (dodger.yieldTimer > 0 || dodger.yieldDestination) continue
|
||||
|
||||
const dc = dodger.dir === Direction.RIGHT ? 1 : dodger.dir === Direction.LEFT ? -1 : 0
|
||||
const dr = dodger.dir === Direction.DOWN ? 1 : dodger.dir === Direction.UP ? -1 : 0
|
||||
|
||||
const dodgeTile = this.findDodgeTile(dodger, dc, dr, walkableSet, occupiedKeys, claimedNext)
|
||||
if (dodgeTile) {
|
||||
if (dodger.state === CharacterState.WALK && dodger.path.length > 0) {
|
||||
const orig = dodger.path[dodger.path.length - 1]
|
||||
dodger.yieldDestination = { col: orig.col, row: orig.row }
|
||||
}
|
||||
dodger.path = [dodgeTile]
|
||||
dodger.moveProgress = 0
|
||||
dodger.state = CharacterState.WALK
|
||||
dodger.frame = 0
|
||||
dodger.frameTimer = 0
|
||||
// Face the dodge direction immediately
|
||||
dodger.dir = this.directionToward(dodger.tileCol, dodger.tileRow, dodgeTile.col, dodgeTile.row)
|
||||
claimedNext.set(`${dodgeTile.col},${dodgeTile.row}`, dodger.id)
|
||||
occupiedKeys.delete(`${dodger.tileCol},${dodger.tileRow}`)
|
||||
occupiedKeys.add(`${dodgeTile.col},${dodgeTile.row}`)
|
||||
} else {
|
||||
dodger.yieldTimer = 0.3 + Math.random() * 0.3
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
update(dt: number): void {
|
||||
this.ensureGatewaySre()
|
||||
this.bugSystem.update(dt, this.bugWorldWidth, this.bugWorldHeight)
|
||||
this.resolveWalkConflicts()
|
||||
const toDelete: number[] = []
|
||||
const firstIdleHumanoid = this.getFirstIdleHumanoid()
|
||||
for (const ch of this.characters.values()) {
|
||||
@@ -1941,6 +2270,24 @@ export class OfficeState {
|
||||
continue
|
||||
}
|
||||
|
||||
// Yield timer: character is waiting at dodge tile before resuming
|
||||
if (ch.yieldTimer > 0) {
|
||||
ch.yieldTimer = Math.max(0, ch.yieldTimer - dt)
|
||||
if (ch.yieldTimer === 0 && ch.yieldDestination) {
|
||||
const dest = ch.yieldDestination
|
||||
ch.yieldDestination = null
|
||||
const resumePath = findPath(ch.tileCol, ch.tileRow, dest.col, dest.row, this.tileMap, this.blockedTiles)
|
||||
if (resumePath.length > 0) {
|
||||
ch.path = resumePath
|
||||
ch.moveProgress = 0
|
||||
ch.state = CharacterState.WALK
|
||||
ch.frame = 0
|
||||
ch.frameTimer = 0
|
||||
}
|
||||
}
|
||||
continue // frozen while waiting
|
||||
}
|
||||
|
||||
if (ch.systemRoleType === 'gateway_sre' && !ch.greetLocked) {
|
||||
this.updateGatewaySreCharacter(ch, dt)
|
||||
} else {
|
||||
@@ -1951,6 +2298,14 @@ export class OfficeState {
|
||||
)
|
||||
}
|
||||
|
||||
// If character just finished walking to dodge tile, start wait timer
|
||||
if (ch.yieldDestination && ch.state !== CharacterState.WALK && ch.path.length === 0) {
|
||||
ch.yieldTimer = 0.6 + Math.random() * 0.5
|
||||
ch.state = CharacterState.IDLE
|
||||
ch.frame = 0
|
||||
ch.frameTimer = 0
|
||||
}
|
||||
|
||||
if (ch.isLobster) {
|
||||
if (ch.lobsterRageTimer > 0) {
|
||||
ch.lobsterRageTimer = Math.max(0, ch.lobsterRageTimer - dt)
|
||||
|
||||
@@ -218,6 +218,22 @@ interface ZDrawable {
|
||||
draw: (ctx: CanvasRenderingContext2D) => void
|
||||
}
|
||||
|
||||
/** Wrap task text at maxChars characters or at Chinese punctuation boundaries */
|
||||
function wrapTaskText(text: string, maxChars = 10): string[] {
|
||||
const punctuation = /[,。!?、;:,!?;:]/
|
||||
const lines: string[] = []
|
||||
let current = ''
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
current += text[i]
|
||||
if (punctuation.test(text[i]) || current.length >= maxChars) {
|
||||
lines.push(current)
|
||||
current = ''
|
||||
}
|
||||
}
|
||||
if (current) lines.push(current)
|
||||
return lines.slice(0, 6) // max 6 lines
|
||||
}
|
||||
|
||||
export function renderScene(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
furniture: FurnitureInstance[],
|
||||
@@ -578,6 +594,80 @@ export function renderScene(
|
||||
})
|
||||
}
|
||||
|
||||
// Task text bubble: scrolling marquee above (or below if near top) the agent's head
|
||||
if (ch.taskText && ch.isActive && ch.state === CharacterState.TYPE && !ch.isSubagent) {
|
||||
const taskX = Math.round(offsetX + ch.x * zoom)
|
||||
const labelFontSize = Math.max(12, Math.round(5.25 * zoom))
|
||||
const taskFontSize = Math.max(10, Math.round(4.5 * zoom))
|
||||
const padX = 5 * zoom
|
||||
const padY = 3 * zoom
|
||||
const bubbleH = taskFontSize + padY * 2
|
||||
const bubbleW = Math.round(72 * zoom)
|
||||
const boxX = Math.max(2, Math.min(taskX - bubbleW / 2, (ctx.canvas.width - bubbleW - 2)))
|
||||
const idealBoxY = drawY - 2 * zoom - labelFontSize - 4 * zoom - bubbleH - 4 * zoom
|
||||
// If bubble would be clipped by top edge, flip it below the character instead
|
||||
const belowY = drawY + cached.height + labelFontSize + 4 * zoom
|
||||
const tailUp = idealBoxY >= 4 // tail points down when bubble is above, up when below
|
||||
const boxY = idealBoxY >= 4 ? idealBoxY : belowY
|
||||
const fullText = ch.taskText
|
||||
drawables.push({
|
||||
zY: charZY + 0.2,
|
||||
draw: (c) => {
|
||||
c.save()
|
||||
c.font = `${taskFontSize}px sans-serif`
|
||||
const fullW = c.measureText(fullText).width
|
||||
const gap = bubbleW * 0.5
|
||||
const cycle = fullW + gap
|
||||
const speed = 30
|
||||
const scrollPx = ((Date.now() / 1000 * speed * zoom) % cycle)
|
||||
const textX = boxX + padX + (fullW > bubbleW - padX * 2 ? gap - scrollPx : 0)
|
||||
|
||||
const r = 3 * zoom
|
||||
const tailW = 5 * zoom
|
||||
c.beginPath()
|
||||
c.moveTo(boxX + r, boxY)
|
||||
c.lineTo(boxX + bubbleW - r, boxY)
|
||||
c.arcTo(boxX + bubbleW, boxY, boxX + bubbleW, boxY + r, r)
|
||||
c.lineTo(boxX + bubbleW, boxY + bubbleH - r)
|
||||
c.arcTo(boxX + bubbleW, boxY + bubbleH, boxX + bubbleW - r, boxY + bubbleH, r)
|
||||
if (tailUp) {
|
||||
// Tail at bottom pointing down toward label
|
||||
c.lineTo(taskX + tailW, boxY + bubbleH)
|
||||
c.lineTo(taskX, boxY + bubbleH + 4 * zoom)
|
||||
c.lineTo(taskX - tailW, boxY + bubbleH)
|
||||
}
|
||||
c.lineTo(boxX + r, boxY + bubbleH)
|
||||
c.arcTo(boxX, boxY + bubbleH, boxX, boxY + bubbleH - r, r)
|
||||
if (!tailUp) {
|
||||
// Tail at top pointing up toward character
|
||||
c.lineTo(boxX, boxY + r)
|
||||
c.arcTo(boxX, boxY, boxX + r, boxY, r)
|
||||
c.lineTo(taskX - tailW, boxY)
|
||||
c.lineTo(taskX, boxY - 4 * zoom)
|
||||
c.lineTo(taskX + tailW, boxY)
|
||||
} else {
|
||||
c.lineTo(boxX, boxY + r)
|
||||
c.arcTo(boxX, boxY, boxX + r, boxY, r)
|
||||
}
|
||||
c.closePath()
|
||||
c.fillStyle = 'rgba(15,23,42,0.88)'
|
||||
c.fill()
|
||||
c.strokeStyle = 'rgba(99,102,241,0.7)'
|
||||
c.lineWidth = zoom
|
||||
c.stroke()
|
||||
|
||||
c.beginPath()
|
||||
c.rect(boxX + padX, boxY, bubbleW - padX * 2, bubbleH)
|
||||
c.clip()
|
||||
c.fillStyle = '#e2e8f0'
|
||||
c.textAlign = 'left'
|
||||
c.textBaseline = 'middle'
|
||||
c.fillText(fullText, textX, boxY + bubbleH / 2)
|
||||
c.restore()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Code snippet particles are rendered as DOM overlays in app/pixel-office/page.tsx
|
||||
// so they can float beyond the canvas area and pass over the top agent list.
|
||||
}
|
||||
|
||||
@@ -212,6 +212,12 @@ export interface Character {
|
||||
isSystemRole?: boolean
|
||||
systemRoleType?: 'gateway_sre'
|
||||
systemStatus?: 'unknown' | 'healthy' | 'degraded' | 'down'
|
||||
/** Last assigned task text to display above the agent's head */
|
||||
taskText?: string
|
||||
/** Walk back to this tile after farewell greeting, then despawn */
|
||||
pendingDespawn?: { col: number; row: number } | true
|
||||
/** Seconds remaining while character waits at dodge tile before resuming path */
|
||||
yieldTimer: number
|
||||
/** Original destination to repath to after yield completes */
|
||||
yieldDestination: { col: number; row: number } | null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export function shouldHidePlatformChannel(
|
||||
channelName: string,
|
||||
channels: Record<string, any>
|
||||
): boolean {
|
||||
return channelName === "wechat-access" && !!channels.wecom && channels.wecom.enabled !== false;
|
||||
}
|
||||
|
||||
export function getPlatformDisplayName(channelName: string): string {
|
||||
return channelName === "wechat-access" ? "wecom" : channelName;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg fill="#07C160" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>WeChat</title><path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm5.34 2.867c-1.797-.052-3.746.512-5.28 1.786-1.72 1.428-2.687 3.72-1.78 6.22.942 2.453 3.666 4.229 6.884 4.229.826 0 1.622-.12 2.361-.336a.722.722 0 0 1 .598.082l1.584.926a.272.272 0 0 0 .14.047c.134 0 .24-.111.24-.247 0-.06-.023-.12-.038-.177l-.327-1.233a.582.582 0 0 1-.023-.156.49.49 0 0 1 .201-.398C23.024 18.48 24 16.82 24 14.98c0-3.21-2.931-5.837-6.656-6.088V8.89c-.135-.01-.27-.027-.407-.03zm-2.53 3.274c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.97-.982zm4.844 0c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 800 B |
Reference in New Issue
Block a user