merge: local changes

This commit is contained in:
Dan Lee
2026-03-20 11:20:55 +08:00
27 changed files with 2094 additions and 482 deletions
+5 -23
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+51
View File
@@ -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 });
}
}
+62
View File
@@ -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 });
}
}
+28
View File
@@ -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
View File
@@ -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 });
}
+10 -3
View File
@@ -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
View File
@@ -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) {
+16 -4
View File
@@ -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
View File
@@ -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>
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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>
);
}
}
-10
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+2 -1
View File
@@ -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",
]);
}
}
+161
View File
@@ -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"),
};
}
+15 -5
View File
@@ -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)
+4 -2
View File
@@ -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]
+381 -26
View File
@@ -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)
+90
View File
@@ -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.
}
+6
View File
@@ -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
}
+10
View File
@@ -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;
}
+1
View File
@@ -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