Compare commits
10 Commits
6a75b46453
..
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| feee5db446 | |||
| 14d840589c | |||
| 42fac626ce | |||
| 3313d3ac81 | |||
| 4811fef4d2 | |||
| ef79e67f0a | |||
| bf3bc2174e | |||
| 8535838fc4 | |||
| 77c4db36d8 | |||
| 227cb96a1d |
@@ -1,4 +1,5 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
|
*.log
|
||||||
|
build/
|
||||||
*.bak
|
*.bak
|
||||||
debug.log
|
debug.log
|
||||||
log/
|
|
||||||
|
|||||||
@@ -2,28 +2,20 @@
|
|||||||
|
|
||||||
在 StreamDock N4 硬件外接屏上实时显示 AI Agent 工作流状态。
|
在 StreamDock N4 硬件外接屏上实时显示 AI Agent 工作流状态。
|
||||||
|
|
||||||
## 支持的 Agent
|
## 插件列表
|
||||||
|
|
||||||
| Agent | 状态 |
|
| 插件 | UUID | 监控目标 |
|
||||||
|------|------|
|
|------|------|---------|
|
||||||
| Codex Desktop | ✅ 已支持 |
|
| AI Monitor - Codex Desktop | `com.streamdock.ai-monitor.codex` | `~/.codex/sessions/*.jsonl` |
|
||||||
| Claude Desktop | 🔜 计划中 |
|
| AI Monitor - Claude Desktop | `com.streamdock.ai-monitor.claude` | `~/.claude/projects/**/*.jsonl` |
|
||||||
| opencode | 🔜 计划中 |
|
| AI Monitor - opencode | `com.streamdock.ai-monitor.opencode` | `~/.opencode/sessions/*.jsonl` |
|
||||||
|
|
||||||
## 工作原理
|
|
||||||
|
|
||||||
插件启动后,以 1 秒间隔轮询 AI Agent 的本地日志/会话文件,将 Agent 活动映射为 N4 按钮上的图标和标题。
|
|
||||||
|
|
||||||
3 秒无活动自动回到 Ready 状态。
|
|
||||||
|
|
||||||
## 安装
|
## 安装
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
$dst = "$env:APPDATA\HotSpot\StreamDock\plugins\com.streamdock.codex.monitor.sdPlugin"
|
$agents = @("codex", "claude", "opencode")
|
||||||
Remove-Item -Recurse -Force $dst -ErrorAction SilentlyContinue
|
foreach ($a in $agents) {
|
||||||
Copy-Item "com.streamdock.codex.monitor.sdPlugin" $dst -Recurse
|
Copy-Item "$a\com.streamdock.ai-monitor.$a.sdPlugin" "$env:APPDATA\HotSpot\StreamDock\plugins\com.streamdock.ai-monitor.$a.sdPlugin" -Recurse -Force
|
||||||
|
}
|
||||||
|
# 重启 StreamDock
|
||||||
```
|
```
|
||||||
|
|
||||||
## 技术栈
|
|
||||||
|
|
||||||
Node.js V2 (StreamDock SDK)
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
*.log
|
||||||
|
build/
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"Name": "AI Monitor",
|
||||||
|
"Description": "Real-time AI agent workflow status on N4"
|
||||||
|
}
|
||||||
|
{
|
||||||
|
"Name": "AI Monitor",
|
||||||
|
"Description": "Real-time AI agent workflow status on N4",
|
||||||
|
"Ready": "Ready",
|
||||||
|
"Prompt": "Prompt",
|
||||||
|
"Thinking": "Thinking",
|
||||||
|
"Working": "Working",
|
||||||
|
"Typing": "Typing",
|
||||||
|
"Done": "Done",
|
||||||
|
"Error": "Error"
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
{
|
||||||
|
"Actions": [
|
||||||
|
{
|
||||||
|
"UUID": "com.streamdock.ai-monitor.codex",
|
||||||
|
"Icon": "static/img/codex.png",
|
||||||
|
"Name": "Codex Desktop",
|
||||||
|
"States": [
|
||||||
|
{
|
||||||
|
"Image": "static/img/codex.png",
|
||||||
|
"TitleAlignment": "center",
|
||||||
|
"FontSize": "11"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Settings": {
|
||||||
|
"pollInterval": 1000
|
||||||
|
},
|
||||||
|
"Controllers": [
|
||||||
|
"Keypad",
|
||||||
|
"Information"
|
||||||
|
],
|
||||||
|
"UserTitleEnabled": true,
|
||||||
|
"SupportedInMultiActions": true,
|
||||||
|
"Tooltip": "Codex Desktop 工作流状态监控",
|
||||||
|
"PropertyInspectorPath": "propertyInspector/monitor/index.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"UUID": "com.streamdock.ai-monitor.claude",
|
||||||
|
"Icon": "static/img/claude.png",
|
||||||
|
"Name": "Claude Desktop",
|
||||||
|
"States": [
|
||||||
|
{
|
||||||
|
"Image": "static/img/claude.png",
|
||||||
|
"TitleAlignment": "center",
|
||||||
|
"FontSize": "11"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Settings": {
|
||||||
|
"pollInterval": 1000
|
||||||
|
},
|
||||||
|
"Controllers": [
|
||||||
|
"Keypad",
|
||||||
|
"Information"
|
||||||
|
],
|
||||||
|
"UserTitleEnabled": true,
|
||||||
|
"SupportedInMultiActions": true,
|
||||||
|
"Tooltip": "Claude Desktop 工作流状态监控",
|
||||||
|
"PropertyInspectorPath": "propertyInspector/monitor/index.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"UUID": "com.streamdock.ai-monitor.opencode",
|
||||||
|
"Icon": "static/img/opencode.png",
|
||||||
|
"Name": "opencode",
|
||||||
|
"States": [
|
||||||
|
{
|
||||||
|
"Image": "static/img/opencode.png",
|
||||||
|
"TitleAlignment": "center",
|
||||||
|
"FontSize": "11"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Settings": {
|
||||||
|
"pollInterval": 1000
|
||||||
|
},
|
||||||
|
"Controllers": [
|
||||||
|
"Keypad",
|
||||||
|
"Information"
|
||||||
|
],
|
||||||
|
"UserTitleEnabled": true,
|
||||||
|
"SupportedInMultiActions": true,
|
||||||
|
"Tooltip": "opencode 工作流状态监控",
|
||||||
|
"PropertyInspectorPath": "propertyInspector/monitor/index.html"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"SDKVersion": 1,
|
||||||
|
"Author": "StreamDock",
|
||||||
|
"Name": "AI Monitor",
|
||||||
|
"Icon": "static/img/ai.png",
|
||||||
|
"Category": "AI Monitor",
|
||||||
|
"CategoryIcon": "static/img/ai.png",
|
||||||
|
"CodePathWin": "plugin/index.js",
|
||||||
|
"CodePathMac": "plugin/index.js",
|
||||||
|
"Description": "监控 Codex / Claude / opencode AI 工作流状态",
|
||||||
|
"Version": "1.0.0",
|
||||||
|
"URL": "http://10.10.10.14:18003/v6ole/streamdock-ai-monitor",
|
||||||
|
"OS": [
|
||||||
|
{
|
||||||
|
"Platform": "windows",
|
||||||
|
"MinimumVersion": "7"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Platform": "mac",
|
||||||
|
"MinimumVersion": "10.11"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Software": {
|
||||||
|
"MinimumVersion": "3.10.188.226"
|
||||||
|
},
|
||||||
|
"Nodejs": {
|
||||||
|
"Version": "20"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
const { Plugins, Actions, log } = require('./utils/plugin');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const os = require('os');
|
||||||
|
|
||||||
|
const plugin = new Plugins('ai-monitor');
|
||||||
|
const HOME = os.homedir();
|
||||||
|
const POLL_INTERVAL = 1000;
|
||||||
|
const IDLE_TIMEOUT_MS = 3000;
|
||||||
|
|
||||||
|
// Agent configs
|
||||||
|
var AGENTS = {
|
||||||
|
codex: {
|
||||||
|
label: 'Codex',
|
||||||
|
scanDir: path.join(HOME, '.codex', 'sessions'),
|
||||||
|
logoFile: 'codex.png',
|
||||||
|
normalize: function(obj) {
|
||||||
|
var t = obj.type || '', pt = (obj.payload || {}).type || '', it = ((obj.payload || {}).item || {}).type || '';
|
||||||
|
if (t === 'event_msg') {
|
||||||
|
if (pt === 'task_started') return 'thinking';
|
||||||
|
if (pt === 'task_complete') return 'done';
|
||||||
|
if (pt === 'turn_aborted') return 'error';
|
||||||
|
if (pt === 'agent_message') return 'typing';
|
||||||
|
if (pt === 'user_message') return 'prompt';
|
||||||
|
if (pt === 'web_search_end') return 'working';
|
||||||
|
if (pt === 'agent_reasoning') return 'thinking';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (t === 'response_item') {
|
||||||
|
var nt = pt || it;
|
||||||
|
if (nt === 'function_call' || nt === 'custom_tool_call' || nt === 'function_call_output' || nt === 'custom_tool_call_output') return 'working';
|
||||||
|
if (nt === 'web_search_call') return 'working';
|
||||||
|
if (nt === 'reasoning') return 'thinking';
|
||||||
|
if (nt === 'message') return 'typing';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
claude: {
|
||||||
|
label: 'Claude',
|
||||||
|
scanDir: path.join(HOME, '.claude', 'projects'),
|
||||||
|
logoFile: 'claude.png',
|
||||||
|
normalize: function(obj) {
|
||||||
|
var t = obj.type || '';
|
||||||
|
if (t === 'user') return 'prompt';
|
||||||
|
if (t === 'assistant') {
|
||||||
|
var content = (obj.message || {}).content || [];
|
||||||
|
for (var i = 0; i < content.length; i++) {
|
||||||
|
var ct = content[i].type || '';
|
||||||
|
if (ct === 'tool_use') return 'working';
|
||||||
|
if (ct === 'thinking') return 'thinking';
|
||||||
|
}
|
||||||
|
for (var j = 0; j < content.length; j++) { if ((content[j].type || '') === 'text') return 'typing'; }
|
||||||
|
return 'typing';
|
||||||
|
}
|
||||||
|
if (t === 'system') { if (obj.subtype === 'turn_duration') return 'done'; return null; }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
opencode: {
|
||||||
|
label: 'opencode',
|
||||||
|
scanDir: path.join(HOME, '.opencode', 'sessions'),
|
||||||
|
logoFile: 'opencode.png',
|
||||||
|
normalize: function(obj) {
|
||||||
|
var t = obj.type || '';
|
||||||
|
if (t === 'user') return 'prompt';
|
||||||
|
if (t === 'assistant') return 'typing';
|
||||||
|
if (t === 'tool' || t === 'tool_call') return 'working';
|
||||||
|
if (t === 'thinking' || t === 'reasoning') return 'thinking';
|
||||||
|
if (t === 'done' || t === 'complete') return 'done';
|
||||||
|
if (t === 'error') return 'error';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// Shared display machinery
|
||||||
|
var STATUS = {
|
||||||
|
idle: { title: 'Ready', fill: '#22c55e', blink: false, breathe: false },
|
||||||
|
prompt: { title: 'Prompt', fill: '#3b82f6', blink: false, breathe: false },
|
||||||
|
thinking: { title: 'Thinking', fill: '#f59e0b', blink: false, breathe: true },
|
||||||
|
working: { title: 'Working', fill: '#f59e0b', blink: true, breathe: false },
|
||||||
|
typing: { title: 'Typing', fill: '#f59e0b', blink: false, breathe: true },
|
||||||
|
done: { title: 'Done', fill: '#22c55e', blink: true, breathe: false },
|
||||||
|
error: { title: 'Error', fill: '#ef4444', blink: false, breathe: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Language support ─────────────────────────────────
|
||||||
|
var LANG = (function() { try { var info = JSON.parse(process.argv[9]); if (info && info.application && info.application.language) return info.application.language; } catch(_) {} return "en"; })();
|
||||||
|
function T(key) {
|
||||||
|
return key === "idle" ? (LANG === "zh_CN" ? "就绪" : "Ready") :
|
||||||
|
key === "prompt" ? (LANG === "zh_CN" ? "输入" : "Prompt") :
|
||||||
|
key === "thinking" ? (LANG === "zh_CN" ? "思考" : "Thinking") :
|
||||||
|
key === "working" ? (LANG === "zh_CN" ? "工作" : "Working") :
|
||||||
|
key === "typing" ? (LANG === "zh_CN" ? "回复" : "Typing") :
|
||||||
|
key === "done" ? (LANG === "zh_CN" ? "完成" : "Done") :
|
||||||
|
key === "error" ? (LANG === "zh_CN" ? "错误" : "Error") : key;
|
||||||
|
}
|
||||||
|
|
||||||
|
var PRIORITY = ['error','working','typing','thinking','prompt','done','idle'];
|
||||||
|
var states = {}; // per-context state: { agent, currentState, lastActivity, offsets, timer, logoUri }
|
||||||
|
|
||||||
|
function makeSvg(fill, label, blink, breathe) {
|
||||||
|
var anim = '';
|
||||||
|
if (blink) anim = '<animate attributeName="opacity" values="1;0.2;1" dur="0.8s" repeatCount="indefinite"/>';
|
||||||
|
else if (breathe) anim = '<animate attributeName="opacity" values="1;0.4;1" dur="2s" repeatCount="indefinite"/>';
|
||||||
|
return '<svg width="144" height="144" xmlns="http://www.w3.org/2000/svg"><rect width="144" height="144" rx="20" fill="#0d0d1a"/><circle cx="72" cy="58" r="32" fill="' + fill + '">' + anim + '</circle></svg>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDisplay(ctx, state) {
|
||||||
|
var d = STATUS[state] || STATUS['idle'];
|
||||||
|
var ss = states[ctx];
|
||||||
|
if (state === 'idle' && ss && ss.agent) {
|
||||||
|
// Use relative PNG path for idle
|
||||||
|
plugin.setTitle(ctx, '');
|
||||||
|
plugin.setImage(ctx, 'static/img/' + ss.agent.logoFile);
|
||||||
|
} else {
|
||||||
|
plugin.setTitle(ctx, d.title);
|
||||||
|
var svg = '<svg width="144" height="144" xmlns="http://www.w3.org/2000/svg"><rect width="144" height="144" rx="20" fill="none"/>';
|
||||||
|
var anim = '';
|
||||||
|
if (d.blink) anim = '<animate attributeName="opacity" values="1;0.2;1" dur="0.8s" repeatCount="indefinite"/>';
|
||||||
|
else if (d.breathe) anim = '<animate attributeName="opacity" values="1;0.4;1" dur="2s" repeatCount="indefinite"/>';
|
||||||
|
svg += '<circle cx="72" cy="58" r="32" fill="' + d.fill + '">' + anim + '</circle></svg>';
|
||||||
|
plugin.setImage(ctx, 'data:image/svg+xml;charset=utf-8,' + svg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setState(ctx, state) {
|
||||||
|
var ss = states[ctx]; if (!ss) return;
|
||||||
|
if (state === ss.currentState) return;
|
||||||
|
if (state === 'done') { setDisplay(ctx, 'done'); ss.currentState = 'done'; setTimeout(function() { if (states[ctx] && states[ctx].currentState === 'done') { states[ctx].currentState = 'idle'; setDisplay(ctx, 'idle'); } }, 800); }
|
||||||
|
else { ss.currentState = state; setDisplay(ctx, state); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function findFiles(rootDir, suffix) {
|
||||||
|
var results = [];
|
||||||
|
if (!fs.existsSync(rootDir)) return results;
|
||||||
|
(function walk(dir, depth) {
|
||||||
|
if (depth > 16) return;
|
||||||
|
var names; try { names = fs.readdirSync(dir); } catch (_) { return; }
|
||||||
|
for (var i = 0; i < names.length; i++) {
|
||||||
|
var full = path.join(dir, names[i]);
|
||||||
|
var st; try { st = fs.statSync(full); } catch (_) { continue; }
|
||||||
|
if (st.isDirectory()) walk(full, depth + 1);
|
||||||
|
else if (st.isFile() && names[i].endsWith(suffix)) results.push(full);
|
||||||
|
}
|
||||||
|
})(rootDir, 0);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tailFile(filePath, ctx, agent) {
|
||||||
|
var ss = states[ctx]; if (!ss) return false;
|
||||||
|
var detected = false;
|
||||||
|
try {
|
||||||
|
var stat = fs.statSync(filePath);
|
||||||
|
var off = ss.offsets[filePath] || 0;
|
||||||
|
if (stat.size < off) { ss.offsets[filePath] = 0; return false; }
|
||||||
|
if (stat.size === off) return false;
|
||||||
|
var fd = fs.openSync(filePath, 'r');
|
||||||
|
var grow = Math.min(stat.size - off, 65536);
|
||||||
|
var buf = Buffer.alloc(grow);
|
||||||
|
fs.readSync(fd, buf, 0, grow, off);
|
||||||
|
fs.closeSync(fd);
|
||||||
|
ss.offsets[filePath] = stat.size;
|
||||||
|
var lines = buf.toString('utf-8').split('\n').filter(function(l) { return l.trim(); });
|
||||||
|
var bestPri = PRIORITY.length, bestState = null;
|
||||||
|
for (var i = 0; i < lines.length; i++) {
|
||||||
|
try { var obj = JSON.parse(lines[i]); var s = agent.normalize(obj); if (s) { var pri = PRIORITY.indexOf(s); if (pri >= 0 && pri < bestPri) { bestPri = pri; bestState = s; } } } catch (_) {}
|
||||||
|
}
|
||||||
|
if (bestState) { setState(ctx, bestState); detected = true; }
|
||||||
|
} catch (_) { delete ss.offsets[filePath]; }
|
||||||
|
return detected;
|
||||||
|
}
|
||||||
|
|
||||||
|
function poll(ctx) {
|
||||||
|
var ss = states[ctx]; if (!ss || !ss.agent) return;
|
||||||
|
var detected = false;
|
||||||
|
if (ss.agent.type === "process") {
|
||||||
|
// Process-based monitoring: check if the named process exists
|
||||||
|
try {
|
||||||
|
var check = childProcess.execSync("cmd /c tasklist", { timeout: 500, encoding: "utf-8" });
|
||||||
|
if (check.indexOf(ss.agent.processName) >= 0) {
|
||||||
|
setState(ctx, "working");
|
||||||
|
detected = true;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
} else {
|
||||||
|
var files = findFiles(ss.agent.scanDir, ".jsonl");
|
||||||
|
files.sort(function(a,b) { return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs; });
|
||||||
|
for (var i = 0; i < Math.min(files.length, 3); i++) { if (tailFile(files[i], ctx, ss.agent)) detected = true; }
|
||||||
|
}
|
||||||
|
if (detected) ss.lastActivity = Date.now();
|
||||||
|
if (ss.currentState !== "idle" && ss.currentState !== "done" && Date.now() - ss.lastActivity > IDLE_TIMEOUT_MS) { setState(ctx, "idle"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeActionFn(agentKey) {
|
||||||
|
var agent = AGENTS[agentKey];
|
||||||
|
return {
|
||||||
|
default: { pollInterval: POLL_INTERVAL },
|
||||||
|
_willAppear: function(data) {
|
||||||
|
var ctx = data.context;
|
||||||
|
states[ctx] = { agent: agent, currentState: 'idle', lastActivity: Date.now(), offsets: {}, timer: null };
|
||||||
|
setDisplay(ctx, 'idle');
|
||||||
|
poll(ctx);
|
||||||
|
states[ctx].timer = setInterval(function() { poll(ctx); }, POLL_INTERVAL);
|
||||||
|
},
|
||||||
|
_willDisappear: function(data) {
|
||||||
|
var ctx = data.context;
|
||||||
|
if (states[ctx] && states[ctx].timer) { clearInterval(states[ctx].timer); }
|
||||||
|
delete states[ctx];
|
||||||
|
},
|
||||||
|
_didReceiveSettings: function(data) {
|
||||||
|
var ctx = data.context;
|
||||||
|
var ss = states[ctx]; if (!ss) return;
|
||||||
|
if (ss.timer) clearInterval(ss.timer);
|
||||||
|
var settings = plugin[agentKey].data[ctx];
|
||||||
|
var interval = (settings && settings.pollInterval) || POLL_INTERVAL;
|
||||||
|
ss.timer = setInterval(function() { poll(ctx); }, interval);
|
||||||
|
},
|
||||||
|
keyUp: function(data) { poll(data.context); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register each agent as a separate action
|
||||||
|
plugin.codex = new Actions(makeActionFn('codex'));
|
||||||
|
plugin.claude = new Actions(makeActionFn('claude'));
|
||||||
|
plugin.opencode = new Actions(makeActionFn('opencode'));
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "codex-monitor",
|
"name": "ai-monitor",
|
||||||
"version": "1.0.0",
|
"version": "2.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "codex-monitor",
|
"name": "ai-monitor",
|
||||||
"version": "1.0.0",
|
"version": "2.0.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fs-extra": "^11.3.0",
|
"fs-extra": "^11.3.0",
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "ai-monitor",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"author": "StreamDock",
|
||||||
|
"main": "index.js",
|
||||||
|
"description": "AI agent workflow status monitor",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fs-extra": "^11.3.0",
|
||||||
|
"log4js": "^6.9.1",
|
||||||
|
"ws": "^8.14.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,11 +31,13 @@ class Plugins {
|
|||||||
if (Plugins.instance) {
|
if (Plugins.instance) {
|
||||||
return Plugins.instance;
|
return Plugins.instance;
|
||||||
}
|
}
|
||||||
|
// log.info("process.argv", process.argv);
|
||||||
this.ws = new ws("ws://127.0.0.1:" + process.argv[3]);
|
this.ws = new ws("ws://127.0.0.1:" + process.argv[3]);
|
||||||
this.ws.on('open', () => this.ws.send(JSON.stringify({ uuid: process.argv[5], event: process.argv[7] })));
|
this.ws.on('open', () => this.ws.send(JSON.stringify({ uuid: process.argv[5], event: process.argv[7] })));
|
||||||
this.ws.on('close', process.exit);
|
this.ws.on('close', process.exit);
|
||||||
this.ws.on('message', e => {
|
this.ws.on('message', e => {
|
||||||
if (this.getGlobalSettingsFlag) {
|
if (this.getGlobalSettingsFlag) {
|
||||||
|
// 只获取一次
|
||||||
this.getGlobalSettingsFlag = false;
|
this.getGlobalSettingsFlag = false;
|
||||||
this.getGlobalSettings();
|
this.getGlobalSettings();
|
||||||
}
|
}
|
||||||
@@ -147,6 +149,7 @@ class Actions {
|
|||||||
this.default = {};
|
this.default = {};
|
||||||
Object.assign(this, data);
|
Object.assign(this, data);
|
||||||
}
|
}
|
||||||
|
// 属性检查器显示时
|
||||||
static currentAction = null;
|
static currentAction = null;
|
||||||
static currentContext = null;
|
static currentContext = null;
|
||||||
static actions = {};
|
static actions = {};
|
||||||
@@ -155,9 +158,10 @@ class Actions {
|
|||||||
Actions.currentContext = data.context;
|
Actions.currentContext = data.context;
|
||||||
this._propertyInspectorDidAppear?.(data);
|
this._propertyInspectorDidAppear?.(data);
|
||||||
}
|
}
|
||||||
|
// 初始化数据
|
||||||
willAppear(data) {
|
willAppear(data) {
|
||||||
Plugins.globalContext = data.context;
|
Plugins.globalContext = data.context;
|
||||||
Actions.actions[data.context] = data.action;
|
Actions.actions[data.context] = data.action
|
||||||
const { context, payload: { settings } } = data;
|
const { context, payload: { settings } } = data;
|
||||||
this.data[context] = Object.assign({ ...this.default }, settings);
|
this.data[context] = Object.assign({ ...this.default }, settings);
|
||||||
this._willAppear?.(data);
|
this._willAppear?.(data);
|
||||||
@@ -167,6 +171,7 @@ class Actions {
|
|||||||
this.data[data.context] = data.payload.settings;
|
this.data[data.context] = data.payload.settings;
|
||||||
this._didReceiveSettings?.(data);
|
this._didReceiveSettings?.(data);
|
||||||
}
|
}
|
||||||
|
// 行动销毁
|
||||||
willDisappear(data) {
|
willDisappear(data) {
|
||||||
this._willDisappear?.(data);
|
this._willDisappear?.(data);
|
||||||
delete this.data[data.context];
|
delete this.data[data.context];
|
||||||
@@ -178,6 +183,7 @@ class EventEmitter {
|
|||||||
this.events = {};
|
this.events = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 订阅事件
|
||||||
subscribe(event, listener) {
|
subscribe(event, listener) {
|
||||||
if (!this.events[event]) {
|
if (!this.events[event]) {
|
||||||
this.events[event] = [];
|
this.events[event] = [];
|
||||||
@@ -185,11 +191,14 @@ class EventEmitter {
|
|||||||
this.events[event].push(listener);
|
this.events[event].push(listener);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 取消订阅
|
||||||
unsubscribe(event, listenerToRemove) {
|
unsubscribe(event, listenerToRemove) {
|
||||||
if (!this.events[event]) return;
|
if (!this.events[event]) return;
|
||||||
|
|
||||||
this.events[event] = this.events[event].filter(listener => listener !== listenerToRemove);
|
this.events[event] = this.events[event].filter(listener => listener !== listenerToRemove);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 发布事件
|
||||||
emit(event, data) {
|
emit(event, data) {
|
||||||
if (!this.events[event]) return;
|
if (!this.events[event]) return;
|
||||||
this.events[event].forEach(listener => listener(data));
|
this.events[event].forEach(listener => listener(data));
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>AI Monitor Settings</title>
|
||||||
|
<link rel="stylesheet" href="../utils/css/sdpi.css">
|
||||||
|
<style>
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: transparent; color: #e0e0e0; padding: 16px; margin: 0; }
|
||||||
|
label { display: block; font-size: 12px; color: #888; margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
.row { display: flex; align-items: center; gap: 8px; }
|
||||||
|
input[type="range"] { flex: 1; accent-color: #f59e0b; }
|
||||||
|
.row span { font-size: 12px; color: #f59e0b; min-width: 50px; text-align: right; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<label>轮询间隔 (ms)</label>
|
||||||
|
<div class="row">
|
||||||
|
<input type="range" id="pollInterval" min="500" max="5000" step="100" value="1000">
|
||||||
|
<span id="pollVal">1000ms</span>
|
||||||
|
</div>
|
||||||
|
<script src="../utils/action.js"></script>
|
||||||
|
<script>
|
||||||
|
var slider = document.getElementById("pollInterval");
|
||||||
|
var val = document.getElementById("pollVal");
|
||||||
|
slider.addEventListener("input", function () { val.textContent = this.value + "ms"; $settings.pollInterval = parseInt(this.value, 10); });
|
||||||
|
if ($settings.pollInterval) slider.value = $settings.pollInterval;
|
||||||
|
else $settings.pollInterval = 1000;
|
||||||
|
$local = true;
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" height="2500" viewBox="0 -.01 39.5 39.53" width="2500" xmlns="http://www.w3.org/2000/svg"><path d="m7.75 26.27 7.77-4.36.13-.38-.13-.21h-.38l-1.3-.08-4.44-.12-3.85-.16-3.73-.2-.94-.2-.88-1.16.09-.58.79-.53 1.13.1 2.5.17 3.75.26 2.72.16 4.03.42h.64l.09-.26-.22-.16-.17-.16-3.88-2.63-4.2-2.78-2.2-1.6-1.19-.81-.6-.76-.26-1.66 1.08-1.19 1.45.1.37.1 1.47 1.13 3.14 2.43 4.1 3.02.6.5.24-.17.03-.12-.27-.45-2.23-4.03-2.38-4.1-1.06-1.7-.28-1.02c-.1-.42-.17-.77-.17-1.2l1.23-1.67.68-.22 1.64.22.69.6 1.02 2.33 1.65 3.67 2.56 4.99.75 1.48.4 1.37.15.42h.26v-.24l.21-2.81.39-3.45.38-4.44.13-1.25.62-1.5 1.23-.81.96.46.79 1.13-.11.73-.47 3.05-.92 4.78-.6 3.2h.35l.4-.4 1.62-2.15 2.72-3.4 1.2-1.35 1.4-1.49.9-.71h1.7l1.25 1.86-.56 1.92-1.75 2.22-1.45 1.88-2.08 2.8-1.3 2.24.12.18.31-.03 4.7-1 2.54-.46 3.03-.52 1.37.64.15.65-.54 1.33-3.24.8-3.8.76-5.66 1.34-.07.05.08.1 2.55.24 1.09.06h2.67l4.97.37 1.3.86.78 1.05-.13.8-2 1.02-2.7-.64-6.3-1.5-2.16-.54h-.3v.18l1.8 1.76 3.3 2.98 4.13 3.84.21.95-.53.75-.56-.08-3.63-2.73-1.4-1.23-3.17-2.67h-.21v.28l.73 1.07 3.86 5.8.2 1.78-.28.58-1 .35-1.1-.2-2.26-3.17-2.33-3.57-1.88-3.2-.23.13-1.11 11.95-.52.61-1.2.46-1-.76-.53-1.23.53-2.43.64-3.17.52-2.52.47-3.13.28-1.04-.02-.07-.23.03-2.36 3.24-3.59 4.85-2.84 3.04-.68.27-1.18-.61.11-1.09.66-.97 3.93-5 2.37-3.1 1.53-1.79-.01-.26h-.09l-10.44 6.78-1.86.24-.8-.75.1-1.23.38-.4 3.14-2.16z" fill="#d97757"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg fill="none" height="144" viewBox="0 0 144 144" width="144" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<title>Codex</title>
|
||||||
|
<polygon points="72,12 130,48 130,120 72,156 14,120 14,48" fill="none" stroke="#6366f1" stroke-width="6"/>
|
||||||
|
<text x="72" y="80" text-anchor="middle" font-family="Arial,sans-serif" font-weight="bold" font-size="52" fill="#6366f1">CX</text>
|
||||||
|
<text x="72" y="118" text-anchor="middle" font-family="Arial,sans-serif" font-size="16" fill="#818cf8">Codex</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 490 B |
|
After Width: | Height: | Size: 4.4 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg fill="none" height="144" viewBox="0 0 144 144" width="144" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<title>opencode</title>
|
||||||
|
<rect x="24" y="24" width="96" height="96" rx="16" fill="none" stroke="#06b6d4" stroke-width="5"/>
|
||||||
|
<text x="52" y="92" font-family="monospace" font-size="64" fill="#06b6d4">>_</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 323 B |
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"Name": "AI 监控",
|
||||||
|
"Description": "在 N4 上实时显示 AI Agent 工作流状态"
|
||||||
|
}
|
||||||
|
{
|
||||||
|
"Name": "AI 监控",
|
||||||
|
"Description": "在 N4 上实时显示 AI Agent 工作流状态",
|
||||||
|
"Ready": "就绪",
|
||||||
|
"Prompt": "输入",
|
||||||
|
"Thinking": "思考",
|
||||||
|
"Working": "工作",
|
||||||
|
"Typing": "回复",
|
||||||
|
"Done": "完成",
|
||||||
|
"Error": "错误"
|
||||||
|
}
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
plugin/node_modules/**
|
|
||||||
plugin/log/
|
|
||||||
plugin/build/
|
|
||||||
plugin/data/
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"Name": "Codex Monitor",
|
|
||||||
"Description": "Monitor Codex Desktop AI workflow status on N4 buttons in real-time",
|
|
||||||
"PollInterval": "Poll Interval (ms)",
|
|
||||||
"ShowHealth": "Show Connection Health",
|
|
||||||
"StatusIdle": "Ready",
|
|
||||||
"StatusPrompt": "Prompt",
|
|
||||||
"StatusThinking": "Thinking",
|
|
||||||
"StatusWorking": "Working",
|
|
||||||
"StatusTyping": "Typing",
|
|
||||||
"StatusDone": "Done",
|
|
||||||
"StatusError": "Error",
|
|
||||||
"StatusWaiting": "Waiting",
|
|
||||||
"StatusReconnecting": "Reconnecting",
|
|
||||||
"StatusRecovered": "Recovered"
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
{
|
|
||||||
"Actions": [
|
|
||||||
{
|
|
||||||
"Icon": "static/img/codex-idle.svg",
|
|
||||||
"Name": "Codex Monitor",
|
|
||||||
"DisableAutomaticStates": true,
|
|
||||||
"States": [
|
|
||||||
{
|
|
||||||
"Image": "static/img/codex-idle.svg",
|
|
||||||
"TitleAlignment": "center",
|
|
||||||
"FontSize": "11"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"Settings": {
|
|
||||||
"pollInterval": 1000,
|
|
||||||
"showHealth": true
|
|
||||||
},
|
|
||||||
"Controllers": ["Keypad", "Information"],
|
|
||||||
"UserTitleEnabled": true,
|
|
||||||
"SupportedInMultiActions": true,
|
|
||||||
"Tooltip": "Codex Desktop 工作流状态监控",
|
|
||||||
"UUID": "com.streamdock.codex.monitor",
|
|
||||||
"PropertyInspectorPath": "propertyInspector/codex-monitor/index.html"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"SDKVersion": 1,
|
|
||||||
"Author": "StreamDock",
|
|
||||||
"Name": "Codex Monitor",
|
|
||||||
"Icon": "static/img/codex-icon.svg",
|
|
||||||
"Category": "Codex",
|
|
||||||
"CategoryIcon": "static/img/codex-icon.svg",
|
|
||||||
"CodePathWin": "plugin/index.js",
|
|
||||||
"CodePathMac": "plugin/index.js",
|
|
||||||
"Description": "监控 Codex Desktop AI 工作流状态,在 N4 按钮上实时显示运行/思考/完成/错误等状态",
|
|
||||||
"Version": "1.0.0",
|
|
||||||
"URL": "https://github.com/openai/codex",
|
|
||||||
"OS": [
|
|
||||||
{ "Platform": "windows", "MinimumVersion": "7" },
|
|
||||||
{ "Platform": "mac", "MinimumVersion": "10.11" }
|
|
||||||
],
|
|
||||||
"Software": { "MinimumVersion": "3.10.188.226" },
|
|
||||||
"Nodejs": { "Version": "20" }
|
|
||||||
}
|
|
||||||
@@ -1,320 +0,0 @@
|
|||||||
const { Plugins, Actions, log } = require('./utils/plugin');
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const os = require('os');
|
|
||||||
|
|
||||||
const plugin = new Plugins('codex-monitor');
|
|
||||||
const timers = {};
|
|
||||||
const sessionOffsets = {};
|
|
||||||
const healthOffsets = {};
|
|
||||||
|
|
||||||
// ─── 直接调试日志 ──────────────────────────────────────
|
|
||||||
const DEBUG_LOG = path.join(__dirname, '..', 'debug.log');
|
|
||||||
function dlog(msg) {
|
|
||||||
try { fs.appendFileSync(DEBUG_LOG, '[' + new Date().toISOString() + '] ' + msg + '\n'); } catch (_) {}
|
|
||||||
}
|
|
||||||
dlog('=== Plugin started v6 ===');
|
|
||||||
|
|
||||||
// ─── 路径配置 ───────────────────────────────────────────
|
|
||||||
const HOME = os.homedir();
|
|
||||||
const HEALTH_MARKER = 'app_server_connection.state_changed';
|
|
||||||
const DEFAULT_POLL = 1000;
|
|
||||||
const DONE_CUE_MS = 800;
|
|
||||||
const COOLDOWN_MS = 300;
|
|
||||||
const IDLE_TIMEOUT_MS = 3000;
|
|
||||||
|
|
||||||
// ─── 简单递归文件查找 ──────────────────────────────────
|
|
||||||
function findFiles(rootDir, suffix, maxDepth) {
|
|
||||||
var results = [];
|
|
||||||
maxDepth = maxDepth || 16;
|
|
||||||
if (!fs.existsSync(rootDir)) return results;
|
|
||||||
(function walk(dir, depth) {
|
|
||||||
if (depth > maxDepth) return;
|
|
||||||
var names;
|
|
||||||
try { names = fs.readdirSync(dir); } catch (_) { return; }
|
|
||||||
for (var i = 0; i < names.length; i++) {
|
|
||||||
var name = names[i];
|
|
||||||
var full = path.join(dir, name);
|
|
||||||
var st;
|
|
||||||
try { st = fs.statSync(full); } catch (_) { continue; }
|
|
||||||
if (st.isDirectory()) walk(full, depth + 1);
|
|
||||||
else if (st.isFile() && name.endsWith(suffix)) results.push(full);
|
|
||||||
}
|
|
||||||
})(rootDir, 0);
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 状态映射 ───────────────────────────────────────────
|
|
||||||
var STATE = {
|
|
||||||
IDLE: 'idle', PROMPT: 'prompt', THINK: 'thinking', WORK: 'working',
|
|
||||||
TYPING: 'typing', DONE: 'done', ERROR: 'error', WAITING: 'waiting',
|
|
||||||
RECONNECTING: 'reconnecting', RECOVERED: 'recovered'
|
|
||||||
};
|
|
||||||
|
|
||||||
var STATUS_DISPLAY = {};
|
|
||||||
STATUS_DISPLAY['idle'] = { title: 'Ready', fill: '#22c55e', blink: false, breathe: false };
|
|
||||||
STATUS_DISPLAY['prompt'] = { title: 'Prompt', fill: '#3b82f6', blink: false, breathe: false };
|
|
||||||
STATUS_DISPLAY['thinking'] = { title: 'Thinking', fill: '#f59e0b', blink: false, breathe: true };
|
|
||||||
STATUS_DISPLAY['working'] = { title: 'Working', fill: '#f59e0b', blink: true, breathe: false };
|
|
||||||
STATUS_DISPLAY['typing'] = { title: 'Typing', fill: '#f59e0b', blink: false, breathe: true };
|
|
||||||
STATUS_DISPLAY['done'] = { title: 'Done', fill: '#22c55e', blink: true, breathe: false };
|
|
||||||
STATUS_DISPLAY['error'] = { title: 'Error', fill: '#ef4444', blink: false, breathe: false };
|
|
||||||
STATUS_DISPLAY['waiting'] = { title: 'Waiting', fill: '#ef4444', blink: true, breathe: false };
|
|
||||||
STATUS_DISPLAY['reconnecting'] = { title: 'Reconnect', fill: '#ef4444', blink: true, breathe: false };
|
|
||||||
STATUS_DISPLAY['recovered'] = { title: 'Online', fill: '#22c55e', blink: false, breathe: false };
|
|
||||||
|
|
||||||
// 优先级: ERROR > WORK > TYPING > THINK > PROMPT > DONE > IDLE
|
|
||||||
var STATE_PRIORITY = ['error', 'waiting', 'reconnecting', 'working', 'typing', 'thinking', 'prompt', 'done', 'idle'];
|
|
||||||
|
|
||||||
function makeSvg(fill, label, blink, breathe) {
|
|
||||||
var anim = '';
|
|
||||||
if (blink) anim = '<animate attributeName="opacity" values="1;0.2;1" dur="0.8s" repeatCount="indefinite"/>';
|
|
||||||
else if (breathe) anim = '<animate attributeName="opacity" values="1;0.4;1" dur="2s" repeatCount="indefinite"/>';
|
|
||||||
return '<svg width="144" height="144" xmlns="http://www.w3.org/2000/svg"><rect width="144" height="144" rx="20" fill="#1a1a2e"/><circle cx="72" cy="58" r="32" fill="' + fill + '">' + anim + '</circle><text x="72" y="118" font-family="Arial" font-weight="bold" font-size="13" fill="white" text-anchor="middle">' + label + '</text></svg>';
|
|
||||||
}
|
|
||||||
|
|
||||||
function setDisplay(context, state) {
|
|
||||||
var d = STATUS_DISPLAY[state] || STATUS_DISPLAY['idle'];
|
|
||||||
plugin.setTitle(context, d.title);
|
|
||||||
plugin.setImage(context, 'data:image/svg+xml;charset=utf-8,' + makeSvg(d.fill, d.title, d.blink, d.breathe));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 状态管理 ──────────────────────────────────────────
|
|
||||||
var currentCtx = null, currentState = 'idle', lastHwSet = 0, lastActivityTime = 0;
|
|
||||||
var animationHandle = null;
|
|
||||||
|
|
||||||
function animateTick() {
|
|
||||||
if (!currentCtx) return;
|
|
||||||
var d = STATUS_DISPLAY[currentState] || STATUS_DISPLAY['idle'];
|
|
||||||
if (d.blink || d.breathe) {
|
|
||||||
var now = Date.now();
|
|
||||||
var period = d.blink ? 800 : 2000;
|
|
||||||
var phase = (now % period) / period;
|
|
||||||
var alpha = d.blink ? (phase < 0.5 ? 1 : 0.2) : 0.4 + 0.6 * Math.sin(phase * Math.PI);
|
|
||||||
var r = parseInt(d.fill.slice(1,3), 16), g = parseInt(d.fill.slice(3,5), 16), b = parseInt(d.fill.slice(5,7), 16);
|
|
||||||
var color = 'rgb(' + Math.round(16+(r-16)*alpha) + ',' + Math.round(16+(g-16)*alpha) + ',' + Math.round(16+(b-16)*alpha) + ')';
|
|
||||||
var svg = '<svg width="144" height="144" xmlns="http://www.w3.org/2000/svg"><rect width="144" height="144" rx="20" fill="#1a1a2e"/><circle cx="72" cy="58" r="32" fill="' + color + '"/><text x="72" y="118" font-family="Arial" font-weight="bold" font-size="13" fill="white" text-anchor="middle">' + d.title + '</text></svg>';
|
|
||||||
plugin.setImage(currentCtx, 'data:image/svg+xml;charset=utf-8,' + svg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setState(context, state) {
|
|
||||||
if (state === currentState && context === currentCtx && Date.now() - lastHwSet < COOLDOWN_MS) return;
|
|
||||||
if (state === 'done') {
|
|
||||||
setDisplay(context, 'done');
|
|
||||||
currentState = 'done';
|
|
||||||
setTimeout(function () {
|
|
||||||
if (currentState === 'done') { currentState = 'idle'; setDisplay(context, 'idle'); }
|
|
||||||
}, DONE_CUE_MS);
|
|
||||||
} else if (state === 'recovered') {
|
|
||||||
currentState = 'idle'; setDisplay(context, 'idle');
|
|
||||||
} else {
|
|
||||||
currentState = state; setDisplay(context, state);
|
|
||||||
}
|
|
||||||
lastHwSet = Date.now();
|
|
||||||
dlog('State: ' + state);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Session JSONL 解析 ────────────────────────────────
|
|
||||||
function normalizeEvent(obj) {
|
|
||||||
var type = obj.type || '';
|
|
||||||
var pt = (obj.payload || {}).type || '';
|
|
||||||
var it = ((obj.payload || {}).item || {}).type || '';
|
|
||||||
if (type === 'event_msg') {
|
|
||||||
if (pt === 'task_started') return 'thinking';
|
|
||||||
if (pt === 'task_complete') return 'done';
|
|
||||||
if (pt === 'turn_aborted') return 'error';
|
|
||||||
if (pt === 'agent_message') return 'typing';
|
|
||||||
if (pt === 'user_message') return 'prompt';
|
|
||||||
if (pt === 'web_search_end') return 'working';
|
|
||||||
if (pt === 'agent_reasoning') return 'thinking';
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (type === 'response_item') {
|
|
||||||
var nt = pt || it;
|
|
||||||
if (nt === 'function_call' || nt === 'custom_tool_call' || nt === 'function_call_output' || nt === 'custom_tool_call_output') return 'working';
|
|
||||||
if (nt === 'web_search_call') return 'working';
|
|
||||||
if (nt === 'reasoning') return 'thinking';
|
|
||||||
if (nt === 'message') return 'typing';
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Session tails ─────────────────────────────────────
|
|
||||||
function latestSessionFiles() {
|
|
||||||
try {
|
|
||||||
var dir = path.join(HOME, '.codex', 'sessions');
|
|
||||||
var files = findFiles(dir, '.jsonl');
|
|
||||||
files.sort(function (a, b) { return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs; });
|
|
||||||
return files.slice(0, 3);
|
|
||||||
} catch (_) { return []; }
|
|
||||||
}
|
|
||||||
|
|
||||||
function tailSession(filePath, context) {
|
|
||||||
var detected = false;
|
|
||||||
try {
|
|
||||||
var stat = fs.statSync(filePath);
|
|
||||||
var off = sessionOffsets[filePath] || 0;
|
|
||||||
if (stat.size < off) { sessionOffsets[filePath] = 0; return false; }
|
|
||||||
if (stat.size === off) return false;
|
|
||||||
var fd = fs.openSync(filePath, 'r');
|
|
||||||
var grow = Math.min(stat.size - off, 65536);
|
|
||||||
var buf = Buffer.alloc(grow);
|
|
||||||
fs.readSync(fd, buf, 0, grow, off);
|
|
||||||
fs.closeSync(fd);
|
|
||||||
sessionOffsets[filePath] = stat.size;
|
|
||||||
var lines = buf.toString('utf-8').split('\n').filter(function (l) { return l.trim(); });
|
|
||||||
var bestPri = STATE_PRIORITY.length, bestState = null;
|
|
||||||
for (var i = 0; i < lines.length; i++) {
|
|
||||||
try {
|
|
||||||
var obj = JSON.parse(lines[i]);
|
|
||||||
var s = normalizeEvent(obj);
|
|
||||||
if (s) {
|
|
||||||
var pri = STATE_PRIORITY.indexOf(s);
|
|
||||||
if (pri >= 0 && pri < bestPri) { bestPri = pri; bestState = s; }
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
if (bestState) {
|
|
||||||
dlog('Detected: ' + bestState + ' from ' + path.basename(filePath));
|
|
||||||
setState(context, bestState);
|
|
||||||
detected = true;
|
|
||||||
}
|
|
||||||
} catch (_) { delete sessionOffsets[filePath]; }
|
|
||||||
return detected;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Health monitoring ─────────────────────────────────
|
|
||||||
var healthState = { reconnecting: false };
|
|
||||||
|
|
||||||
function parseHealthLine(line) {
|
|
||||||
if (line.indexOf(HEALTH_MARKER) === -1) return null;
|
|
||||||
var fieldRe = /([A-Za-z][A-Za-z0-9_]*)=("[^"]*"|\S+)/g;
|
|
||||||
var fields = {}, m;
|
|
||||||
while ((m = fieldRe.exec(line)) !== null) {
|
|
||||||
var val = m[2];
|
|
||||||
if (val.charAt(0) === '"' && val.charAt(val.length - 1) === '"') val = val.slice(1, -1);
|
|
||||||
fields[m[1]] = val;
|
|
||||||
}
|
|
||||||
var next = fields.next || '';
|
|
||||||
var prev = fields.previous || '';
|
|
||||||
var attempt = parseInt(fields.reconnectAttempt || '0', 10);
|
|
||||||
var scheduled = (fields.reconnectTimerScheduled || '').toLowerCase() === 'true';
|
|
||||||
var err = fields.connectionError || '';
|
|
||||||
var cause = fields.cause || '';
|
|
||||||
if (next === 'connected' && healthState.reconnecting) { healthState.reconnecting = false; return 'recovered'; }
|
|
||||||
if ((next === 'connecting' || next === 'disconnected') && (attempt > 0 || scheduled || (err && err.toLowerCase() !== 'null') || (prev === 'connected' && cause !== 'stop_process' && cause !== 'shutdown'))) {
|
|
||||||
healthState.reconnecting = true; return 'reconnecting';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function latestHealthLogs() {
|
|
||||||
try {
|
|
||||||
var dir = process.platform === 'win32' ? path.join(HOME, 'AppData', 'Local', 'Codex', 'Logs') : path.join(HOME, 'Library', 'Logs', 'com.openai.codex');
|
|
||||||
var files = findFiles(dir, '.log');
|
|
||||||
files.sort(function (a, b) { return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs; });
|
|
||||||
return files.slice(0, 3);
|
|
||||||
} catch (_) { return []; }
|
|
||||||
}
|
|
||||||
|
|
||||||
function tailHealth(filePath, context) {
|
|
||||||
try {
|
|
||||||
var stat = fs.statSync(filePath);
|
|
||||||
var off = healthOffsets[filePath] || 0;
|
|
||||||
if (stat.size < off) { healthOffsets[filePath] = 0; return; }
|
|
||||||
if (stat.size === off) return;
|
|
||||||
var fd = fs.openSync(filePath, 'r');
|
|
||||||
var grow = Math.min(stat.size - off, 65536);
|
|
||||||
var buf = Buffer.alloc(grow);
|
|
||||||
fs.readSync(fd, buf, 0, grow, off);
|
|
||||||
fs.closeSync(fd);
|
|
||||||
healthOffsets[filePath] = stat.size;
|
|
||||||
var lines = buf.toString('utf-8').split('\n').filter(function (l) { return l.trim(); });
|
|
||||||
var lastHealth = null;
|
|
||||||
for (var i = 0; i < lines.length; i++) {
|
|
||||||
var s = parseHealthLine(lines[i]);
|
|
||||||
if (s) lastHealth = s;
|
|
||||||
}
|
|
||||||
if (lastHealth) { dlog('Health: ' + lastHealth); setState(context, lastHealth); }
|
|
||||||
} catch (_) { delete healthOffsets[filePath]; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 主轮询 ────────────────────────────────────────────
|
|
||||||
var pollCount = 0;
|
|
||||||
function poll(context, interval, showHealth) {
|
|
||||||
pollCount++;
|
|
||||||
if (pollCount % 10 === 1) dlog('Poll #' + pollCount);
|
|
||||||
|
|
||||||
var detected = false;
|
|
||||||
var files = latestSessionFiles();
|
|
||||||
for (var i = 0; i < files.length; i++) {
|
|
||||||
if (tailSession(files[i], context)) detected = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (detected) lastActivityTime = Date.now();
|
|
||||||
|
|
||||||
// Idle timeout: return to IDLE if nothing happened for IDLE_TIMEOUT_MS
|
|
||||||
if (currentState !== 'idle' && currentState !== 'done' && Date.now() - lastActivityTime > IDLE_TIMEOUT_MS) {
|
|
||||||
dlog('Idle timeout - returning to IDLE');
|
|
||||||
setState(context, 'idle');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showHealth) {
|
|
||||||
var hfiles = latestHealthLogs();
|
|
||||||
for (var j = 0; j < hfiles.length; j++) tailHealth(hfiles[j], context);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Action 定义 ───────────────────────────────────────
|
|
||||||
plugin.monitor = new Actions({
|
|
||||||
default: { pollInterval: DEFAULT_POLL, showHealth: true },
|
|
||||||
|
|
||||||
_willAppear: function (data) {
|
|
||||||
var ctx = data.context;
|
|
||||||
dlog('WillAppear: ' + ctx);
|
|
||||||
currentCtx = ctx;
|
|
||||||
currentState = 'idle';
|
|
||||||
lastHwSet = 0;
|
|
||||||
lastActivityTime = Date.now();
|
|
||||||
setDisplay(ctx, 'idle');
|
|
||||||
|
|
||||||
var s = plugin.monitor.data[ctx];
|
|
||||||
var interval = s.pollInterval || DEFAULT_POLL;
|
|
||||||
var showHealth = s.showHealth !== false;
|
|
||||||
poll(ctx, interval, showHealth);
|
|
||||||
timers[ctx] = setInterval(function () { poll(ctx, interval, showHealth); }, interval);
|
|
||||||
if (!animationHandle) animationHandle = setInterval(animateTick, 200);
|
|
||||||
},
|
|
||||||
|
|
||||||
_willDisappear: function (data) {
|
|
||||||
var ctx = data.context;
|
|
||||||
dlog('WillDisappear: ' + ctx);
|
|
||||||
if (timers[ctx]) { clearInterval(timers[ctx]); delete timers[ctx]; }
|
|
||||||
if (currentCtx === ctx) { currentCtx = null; currentState = 'idle'; }
|
|
||||||
if (Object.keys(timers).length === 0 && animationHandle) { clearInterval(animationHandle); animationHandle = null; }
|
|
||||||
},
|
|
||||||
|
|
||||||
_didReceiveSettings: function (data) {
|
|
||||||
var ctx = data.context;
|
|
||||||
if (timers[ctx]) clearInterval(timers[ctx]);
|
|
||||||
var s = plugin.monitor.data[ctx];
|
|
||||||
var interval = s.pollInterval || DEFAULT_POLL;
|
|
||||||
var showHealth = s.showHealth !== false;
|
|
||||||
poll(ctx, interval, showHealth);
|
|
||||||
timers[ctx] = setInterval(function () { poll(ctx, interval, showHealth); }, interval);
|
|
||||||
},
|
|
||||||
|
|
||||||
keyUp: function (data) {
|
|
||||||
var s = plugin.monitor.data[data.context];
|
|
||||||
poll(data.context, s.pollInterval || DEFAULT_POLL, s.showHealth !== false);
|
|
||||||
},
|
|
||||||
|
|
||||||
sendToPlugin: function (data) {
|
|
||||||
if (data.payload && data.payload.command === 'refresh') {
|
|
||||||
var s = plugin.monitor.data[data.context];
|
|
||||||
poll(data.context, s.pollInterval || DEFAULT_POLL, s.showHealth !== false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "codex-monitor",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"author": "StreamDock",
|
|
||||||
"main": "index.js",
|
|
||||||
"description": "Codex Desktop 工作流状态监控插件",
|
|
||||||
"scripts": {
|
|
||||||
"build": "ncc build index.js -m -o ./build && node autofile.js"
|
|
||||||
},
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"fs-extra": "^11.3.0",
|
|
||||||
"log4js": "^6.9.1",
|
|
||||||
"ws": "^8.14.2"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Codex Monitor</title>
|
|
||||||
<link rel="stylesheet" href="../utils/css/sdpi.css">
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
||||||
background: #1a1a2e;
|
|
||||||
color: #e0e0e0;
|
|
||||||
padding: 16px;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.section {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
.section label {
|
|
||||||
display: block;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #888;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
.section input[type="range"] {
|
|
||||||
width: 100%;
|
|
||||||
accent-color: #f59e0b;
|
|
||||||
}
|
|
||||||
.section input[type="number"] {
|
|
||||||
width: 100%;
|
|
||||||
padding: 6px 8px;
|
|
||||||
background: #16213e;
|
|
||||||
border: 1px solid #333;
|
|
||||||
border-radius: 6px;
|
|
||||||
color: #e0e0e0;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
.section .range-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.section .range-row span {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #f59e0b;
|
|
||||||
min-width: 50px;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
.toggle {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.toggle input { display: none; }
|
|
||||||
.toggle .switch {
|
|
||||||
width: 40px; height: 22px;
|
|
||||||
background: #444;
|
|
||||||
border-radius: 11px;
|
|
||||||
position: relative;
|
|
||||||
transition: background 0.2s;
|
|
||||||
}
|
|
||||||
.toggle .switch::after {
|
|
||||||
content: '';
|
|
||||||
width: 18px; height: 18px;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 50%;
|
|
||||||
position: absolute;
|
|
||||||
top: 2px; left: 2px;
|
|
||||||
transition: transform 0.2s;
|
|
||||||
}
|
|
||||||
.toggle input:checked + .switch {
|
|
||||||
background: #f59e0b;
|
|
||||||
}
|
|
||||||
.toggle input:checked + .switch::after {
|
|
||||||
transform: translateX(18px);
|
|
||||||
}
|
|
||||||
.status-preview {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 12px;
|
|
||||||
background: #16213e;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
.status-dot {
|
|
||||||
width: 12px; height: 12px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #22c55e;
|
|
||||||
}
|
|
||||||
.status-dot.thinking { background: #f59e0b; }
|
|
||||||
.status-dot.working { background: #f59e0b; animation: pulse 0.8s infinite; }
|
|
||||||
.status-dot.error { background: #ef4444; }
|
|
||||||
@keyframes pulse {
|
|
||||||
0%, 100% { opacity: 1; }
|
|
||||||
50% { opacity: 0.3; }
|
|
||||||
}
|
|
||||||
.status-text {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="section">
|
|
||||||
<label data-localize="PollInterval">轮询间隔 (ms)</label>
|
|
||||||
<div class="range-row">
|
|
||||||
<input type="range" id="pollInterval" min="500" max="5000" step="100" value="1000">
|
|
||||||
<span id="pollVal">1000ms</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="section">
|
|
||||||
<label class="toggle">
|
|
||||||
<input type="checkbox" id="showHealth" checked>
|
|
||||||
<div class="switch"></div>
|
|
||||||
<span data-localize="ShowHealth">显示连接健康状态</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="section">
|
|
||||||
<label>状态预览</label>
|
|
||||||
<div class="status-preview">
|
|
||||||
<div class="status-dot" id="statusDot"></div>
|
|
||||||
<div class="status-text" id="statusText">就绪</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="../utils/action.js"></script>
|
|
||||||
<script src="index.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const pollSlider = document.getElementById('pollInterval');
|
|
||||||
const pollVal = document.getElementById('pollVal');
|
|
||||||
const showHealthToggle = document.getElementById('showHealth');
|
|
||||||
const statusDot = document.getElementById('statusDot');
|
|
||||||
const statusText = document.getElementById('statusText');
|
|
||||||
|
|
||||||
// $settings is the SDK-provided settings proxy (auto-persists)
|
|
||||||
$settings = $settings || {};
|
|
||||||
|
|
||||||
function applySettings(settings) {
|
|
||||||
if (settings.pollInterval !== undefined) {
|
|
||||||
pollSlider.value = settings.pollInterval;
|
|
||||||
pollVal.textContent = settings.pollInterval + 'ms';
|
|
||||||
}
|
|
||||||
if (settings.showHealth !== undefined) {
|
|
||||||
showHealthToggle.checked = settings.showHealth;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pollSlider.addEventListener('input', function () {
|
|
||||||
const val = parseInt(this.value, 10);
|
|
||||||
pollVal.textContent = val + 'ms';
|
|
||||||
$settings.pollInterval = val;
|
|
||||||
});
|
|
||||||
|
|
||||||
showHealthToggle.addEventListener('change', function () {
|
|
||||||
$settings.showHealth = this.checked;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 监听插件传来的状态更新
|
|
||||||
$websocket.on('sendToPropertyInspector', function (data) {
|
|
||||||
if (data.payload && data.payload.status) {
|
|
||||||
const st = data.payload.status;
|
|
||||||
statusText.textContent = st;
|
|
||||||
statusDot.className = 'status-dot';
|
|
||||||
if (st.indexOf('Think') >= 0 || st.indexOf('思考') >= 0) statusDot.classList.add('thinking');
|
|
||||||
else if (st.indexOf('Work') >= 0 || st.indexOf('执行') >= 0) statusDot.classList.add('working');
|
|
||||||
else if (st.indexOf('Error') >= 0 || st.indexOf('错误') >= 0) statusDot.classList.add('error');
|
|
||||||
else if (st.indexOf('Recon') >= 0 || st.indexOf('重连') >= 0) statusDot.classList.add('error');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 初始化时加载已有设置
|
|
||||||
if ($settings.pollInterval) applySettings($settings);
|
|
||||||
else {
|
|
||||||
$settings.pollInterval = 1000;
|
|
||||||
$settings.showHealth = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$websocket.on('didReceiveSettings', function (data) {
|
|
||||||
applySettings(data.payload.settings);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 启用自动翻译
|
|
||||||
$local = true;
|
|
||||||
})();
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<svg width="144" height="144" xmlns="http://www.w3.org/2000/svg"><rect width="144" height="144" rx="20" fill="#1a1a2e"/><polygon points="72,24 112,60 112,108 72,144 32,108 32,60" fill="none" stroke="#f59e0b" stroke-width="3"/><text x="72" y="84" text-anchor="middle" fill="#f59e0b" font-family="Arial,sans-serif" font-size="28" font-weight="bold">CX</text></svg>
|
|
||||||
|
Before Width: | Height: | Size: 363 B |
@@ -1 +0,0 @@
|
|||||||
<svg width="144" height="144" xmlns="http://www.w3.org/2000/svg"><rect width="144" height="144" rx="20" fill="#1a1a2e"/><circle cx="72" cy="58" r="32" fill="#22c55e"/><text x="72" y="118" text-anchor="middle" fill="white" font-family="Arial,sans-serif" font-size="14" font-weight="bold">🟢 Ready</text></svg>
|
|
||||||
|
Before Width: | Height: | Size: 311 B |
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"Name": "Codex 监控",
|
|
||||||
"Description": "监控 Codex Desktop AI 工作流状态,在 N4 按钮上实时显示",
|
|
||||||
"PollInterval": "轮询间隔 (ms)",
|
|
||||||
"ShowHealth": "显示连接健康状态",
|
|
||||||
"StatusIdle": "就绪",
|
|
||||||
"StatusPrompt": "收到提示",
|
|
||||||
"StatusThinking": "思考中",
|
|
||||||
"StatusWorking": "执行中",
|
|
||||||
"StatusTyping": "回复中",
|
|
||||||
"StatusDone": "完成",
|
|
||||||
"StatusError": "错误",
|
|
||||||
"StatusWaiting": "等待中",
|
|
||||||
"StatusReconnecting": "重连中",
|
|
||||||
"StatusRecovered": "已恢复"
|
|
||||||
}
|
|
||||||