flowmind 1.5.4 → 1.5.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -1
- package/README.md +2 -0
- package/README_CN.md +2 -0
- package/bin/flowmind.js +19 -110
- package/core/cli-ink.js +79 -0
- package/core/log-query-parser.js +324 -0
- package/core/update-notifier.js +74 -0
- package/dashboard/app.jsx +69 -10
- package/dashboard/components/ActivityFeed.jsx +5 -4
- package/dashboard/components/DragonPanel.jsx +17 -1
- package/dashboard/components/McpStatusBar.jsx +19 -1
- package/dashboard/components/StatsRow.jsx +27 -2
- package/package.json +2 -1
- package/scripts/check-update.js +52 -0
- package/skills/auto-flow/index.js +58 -82
- package/skills/log-audit/index.js +55 -27
- package/skills/sls-log-audit/index.js +7 -30
- package/skills/yuque-sync-design/index.js +2 -2
- package/tui/app.jsx +73 -5
- package/tui/components/ChatPanel.jsx +9 -6
- package/tui/components/DragonTotem.jsx +12 -1
- package/tui/components/Sidebar.jsx +19 -7
- package/tui/components/StatusBar.jsx +28 -1
- package/tui/format-result.js +60 -0
- package/tui/layout.js +60 -0
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* Analyzes application logs, traces requests, debugs performance issues
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
const { parseLogQueryParams } = require('../../core/log-query-parser');
|
|
7
|
+
|
|
6
8
|
module.exports = {
|
|
7
9
|
canHandle(input, context) {
|
|
8
10
|
if (!input) return false;
|
|
@@ -11,7 +13,11 @@ module.exports = {
|
|
|
11
13
|
|
|
12
14
|
async execute(input, context) {
|
|
13
15
|
const logService = context.componentRegistry?.getAdapter('logService');
|
|
14
|
-
const params =
|
|
16
|
+
const params = parseLogQueryParams(input, {
|
|
17
|
+
allowNaturalKeyword: true,
|
|
18
|
+
allowNaturalLevel: true,
|
|
19
|
+
allowQuotedKeyword: true
|
|
20
|
+
});
|
|
15
21
|
|
|
16
22
|
if (!logService && !params.mock) {
|
|
17
23
|
return {
|
|
@@ -30,18 +36,22 @@ module.exports = {
|
|
|
30
36
|
: queryInput;
|
|
31
37
|
const execution = await logService.queryLogs(queryParams);
|
|
32
38
|
const summary = summarizeLogExecution(execution);
|
|
39
|
+
const noRecordsMessage = summary.empty
|
|
40
|
+
? buildNoRecordsMessage(params)
|
|
41
|
+
: null;
|
|
33
42
|
|
|
34
43
|
return {
|
|
35
44
|
type: 'result',
|
|
36
45
|
skill: 'log-audit',
|
|
37
|
-
message: params.traceId
|
|
38
|
-
?
|
|
39
|
-
:
|
|
46
|
+
message: noRecordsMessage || (params.traceId
|
|
47
|
+
? `已執行 trace 查詢:${params.traceId}`
|
|
48
|
+
: `已執行日誌查詢:${params.service || '全部服務'},${params.level || '全部級別'},${params.timeRange || '最近 1 小時'}`),
|
|
40
49
|
data: {
|
|
41
50
|
action: params.traceId ? 'trace' : 'query',
|
|
42
51
|
provider: logService.providerName,
|
|
43
52
|
queryParams,
|
|
44
|
-
execution: summary
|
|
53
|
+
execution: summary,
|
|
54
|
+
noRecords: summary.empty
|
|
45
55
|
},
|
|
46
56
|
input,
|
|
47
57
|
timestamp: new Date().toISOString()
|
|
@@ -49,26 +59,6 @@ module.exports = {
|
|
|
49
59
|
}
|
|
50
60
|
};
|
|
51
61
|
|
|
52
|
-
function parseLogParams(input) {
|
|
53
|
-
const params = {};
|
|
54
|
-
const traceMatch = input.match(/(?:trace[_-]?id|调用链)\s*[:=]?\s*(\S+)/i);
|
|
55
|
-
if (traceMatch) params.traceId = traceMatch[1];
|
|
56
|
-
|
|
57
|
-
const serviceMatch = input.match(/(?:服务|service)\s*[:=]?\s*(\S+)/i);
|
|
58
|
-
if (serviceMatch) params.service = serviceMatch[1];
|
|
59
|
-
|
|
60
|
-
const levelMatch = input.match(/(?:级别|level)\s*[:=]?\s*(ERROR|WARN|INFO|DEBUG)/i);
|
|
61
|
-
if (levelMatch) params.level = levelMatch[1].toUpperCase();
|
|
62
|
-
|
|
63
|
-
const timeMatch = input.match(/(?:最近|last)\s*(\d+)\s*(分钟|小时|分钟|min|hour)/i);
|
|
64
|
-
if (timeMatch) params.timeRange = `last ${timeMatch[1]} ${timeMatch[2]}`;
|
|
65
|
-
|
|
66
|
-
const keywordMatch = input.match(/(?:关键词|keyword|搜索|search)\s*[:=]?\s*(.+?)(?:\s*$)/i);
|
|
67
|
-
if (keywordMatch) params.keyword = keywordMatch[1].trim();
|
|
68
|
-
|
|
69
|
-
return params;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
62
|
function buildSLSQuery(params) {
|
|
73
63
|
const parts = [];
|
|
74
64
|
if (params.service) parts.push(`service: ${params.service}`);
|
|
@@ -93,12 +83,15 @@ function summarizeLogExecution(execution) {
|
|
|
93
83
|
const payload = unwrapPayload(execution);
|
|
94
84
|
const data = payload && typeof payload === 'object' ? payload : {};
|
|
95
85
|
const records = extractRecords(data);
|
|
86
|
+
const empty = (extractStatus(data) || 'ok') === 'ok' && records.length === 0;
|
|
96
87
|
|
|
97
88
|
return {
|
|
98
89
|
status: extractStatus(data) || 'ok',
|
|
99
90
|
total: extractTotal(data, records),
|
|
100
91
|
records: records.slice(0, 5).map(summarizeRecord),
|
|
101
|
-
requestId: data.requestId || data.traceId || data.id || null
|
|
92
|
+
requestId: data.requestId || data.traceId || data.id || null,
|
|
93
|
+
empty,
|
|
94
|
+
rawSummary: summarizeRawExecution(execution, data)
|
|
102
95
|
};
|
|
103
96
|
}
|
|
104
97
|
|
|
@@ -147,7 +140,9 @@ function extractStatus(data) {
|
|
|
147
140
|
}
|
|
148
141
|
|
|
149
142
|
function extractTotal(data, records) {
|
|
150
|
-
|
|
143
|
+
if (typeof data.total === 'number') return data.total;
|
|
144
|
+
if (typeof data.count === 'number') return data.count;
|
|
145
|
+
return records.length;
|
|
151
146
|
}
|
|
152
147
|
|
|
153
148
|
function summarizeRecord(record) {
|
|
@@ -180,3 +175,36 @@ function parseMaybeJson(value) {
|
|
|
180
175
|
return { text: value };
|
|
181
176
|
}
|
|
182
177
|
}
|
|
178
|
+
|
|
179
|
+
function buildNoRecordsMessage(params) {
|
|
180
|
+
if (params.traceId) {
|
|
181
|
+
return `未找到符合 traceId:${params.traceId} 的日誌。請補充服務、關鍵字或時間範圍。`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (params.keyword) {
|
|
185
|
+
return `未找到符合關鍵字:${params.keyword} 的日誌。請補充服務或時間範圍。`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return '未找到符合條件的日誌。請補充關鍵字、服務或時間範圍。';
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function summarizeRawExecution(execution, data) {
|
|
192
|
+
const topLevelKeys = data && typeof data === 'object'
|
|
193
|
+
? Object.keys(data).slice(0, 12)
|
|
194
|
+
: [];
|
|
195
|
+
const contentText = extractContentText(execution);
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
topLevelKeys,
|
|
199
|
+
hasContent: Array.isArray(execution?.content) && execution.content.length > 0,
|
|
200
|
+
contentPreview: contentText ? truncateText(contentText, 300) : null
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function truncateText(value, maxLength) {
|
|
205
|
+
const text = String(value);
|
|
206
|
+
if (text.length <= maxLength) {
|
|
207
|
+
return text;
|
|
208
|
+
}
|
|
209
|
+
return `${text.slice(0, maxLength - 1)}…`;
|
|
210
|
+
}
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* Query Alibaba Cloud SLS logs, trace ID chain analysis, performance analysis
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
const { parseLogQueryParams } = require('../../core/log-query-parser');
|
|
7
|
+
|
|
6
8
|
module.exports = {
|
|
7
9
|
canHandle(input, context) {
|
|
8
10
|
if (!input) return false;
|
|
@@ -10,7 +12,11 @@ module.exports = {
|
|
|
10
12
|
},
|
|
11
13
|
|
|
12
14
|
async execute(input, context) {
|
|
13
|
-
const params =
|
|
15
|
+
const params = parseLogQueryParams(input, {
|
|
16
|
+
allowNaturalKeyword: true,
|
|
17
|
+
allowNaturalLevel: true,
|
|
18
|
+
allowQuotedKeyword: true
|
|
19
|
+
});
|
|
14
20
|
|
|
15
21
|
// Determine default endpoint based on environment
|
|
16
22
|
const env = params.env || 'test';
|
|
@@ -78,35 +84,6 @@ module.exports = {
|
|
|
78
84
|
}
|
|
79
85
|
};
|
|
80
86
|
|
|
81
|
-
function parseSLSParams(input) {
|
|
82
|
-
const params = {};
|
|
83
|
-
const traceMatch = input.match(/trace[_-]?id\s*[:=]?\s*(\S+)/i);
|
|
84
|
-
if (traceMatch) params.traceId = traceMatch[1];
|
|
85
|
-
|
|
86
|
-
const serviceMatch = input.match(/(?:服务|service)\s*[:=]?\s*(\S+)/i);
|
|
87
|
-
if (serviceMatch) params.service = serviceMatch[1];
|
|
88
|
-
|
|
89
|
-
const levelMatch = input.match(/(?:级别|level)\s*[:=]?\s*(ERROR|WARN|INFO|DEBUG)/i);
|
|
90
|
-
if (levelMatch) params.level = levelMatch[1].toUpperCase();
|
|
91
|
-
|
|
92
|
-
const projectMatch = input.match(/(?:项目|project)\s*[:=]?\s*(\S+)/i);
|
|
93
|
-
if (projectMatch) params.project = projectMatch[1];
|
|
94
|
-
|
|
95
|
-
const logstoreMatch = input.match(/(?:logstore|日志库)\s*[:=]?\s*(\S+)/i);
|
|
96
|
-
if (logstoreMatch) params.logstore = logstoreMatch[1];
|
|
97
|
-
|
|
98
|
-
const envMatch = input.match(/(?:环境|env)\s*[:=]?\s*(test|uat|gray|prod)/i);
|
|
99
|
-
if (envMatch) params.env = envMatch[1].toLowerCase();
|
|
100
|
-
|
|
101
|
-
const timeMatch = input.match(/(?:最近|last)\s*(\d+)\s*(分钟|小时|min|hour)/i);
|
|
102
|
-
if (timeMatch) params.timeRange = `last ${timeMatch[1]} ${timeMatch[2]}`;
|
|
103
|
-
|
|
104
|
-
const keywordMatch = input.match(/(?:关键词|keyword|搜索)\s*[:=]?\s*(.+?)(?:\s*$)/i);
|
|
105
|
-
if (keywordMatch) params.keyword = keywordMatch[1].trim();
|
|
106
|
-
|
|
107
|
-
return params;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
87
|
function buildSLSQuery(params) {
|
|
111
88
|
const parts = [];
|
|
112
89
|
if (params.service) parts.push(`service: ${params.service}`);
|
|
@@ -102,9 +102,9 @@ module.exports = {
|
|
|
102
102
|
|
|
103
103
|
function parseYuqueParams(input) {
|
|
104
104
|
const params = {};
|
|
105
|
-
if (/搜索|search|查找/i.test(input)) params.action = 'search';
|
|
106
|
-
if (/列表|list|仓库|repo/i.test(input)) params.action = 'list';
|
|
107
105
|
if (/同步|sync|上传|push/i.test(input)) params.action = 'sync';
|
|
106
|
+
else if (/搜索|search|查找/i.test(input)) params.action = 'search';
|
|
107
|
+
else if (/列表|list|仓库|repo/i.test(input)) params.action = 'list';
|
|
108
108
|
|
|
109
109
|
const pathMatch = input.match(/(?:路径|path|文件|file)\s*[:=]?\s*(\S+)/i);
|
|
110
110
|
if (pathMatch) params.path = pathMatch[1];
|
package/tui/app.jsx
CHANGED
|
@@ -1,15 +1,51 @@
|
|
|
1
1
|
const React = require('react');
|
|
2
|
-
const { Box, useApp, useInput } = require('ink');
|
|
2
|
+
const { Box, Text, useApp, useInput } = require('ink');
|
|
3
3
|
const Sidebar = require('./components/Sidebar.jsx');
|
|
4
4
|
const ChatPanel = require('./components/ChatPanel.jsx');
|
|
5
5
|
const StatusBar = require('./components/StatusBar.jsx');
|
|
6
6
|
const { formatResultText } = require('./format-result');
|
|
7
|
+
const { getTuiLayout, MIN_COLUMNS, MIN_ROWS } = require('./layout');
|
|
8
|
+
|
|
9
|
+
class TuiErrorBoundary extends React.Component {
|
|
10
|
+
constructor(props) {
|
|
11
|
+
super(props);
|
|
12
|
+
this.state = { error: null };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
static getDerivedStateFromError(error) {
|
|
16
|
+
return { error };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
componentDidCatch(error) {
|
|
20
|
+
if (this.props.onError) {
|
|
21
|
+
this.props.onError(error);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
render() {
|
|
26
|
+
if (this.state.error) {
|
|
27
|
+
return React.createElement(
|
|
28
|
+
Box,
|
|
29
|
+
{ flexDirection: 'column', borderStyle: 'single', borderColor: 'red', paddingX: 1, paddingY: 1 },
|
|
30
|
+
React.createElement(Text, { color: 'red', bold: true }, 'TUI render error'),
|
|
31
|
+
React.createElement(Text, { color: 'gray' }, this.state.error.message || 'Unknown render failure'),
|
|
32
|
+
React.createElement(Text, { color: 'gray' }, 'Resize the terminal or restart FlowMind.')
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return this.props.children;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
7
39
|
|
|
8
40
|
function App({ flowmind, asciiMode = false }) {
|
|
9
41
|
const MAX_MESSAGES = 60;
|
|
10
42
|
const [messages, setMessages] = React.useState([]);
|
|
11
43
|
const [isProcessing, setIsProcessing] = React.useState(false);
|
|
12
44
|
const [focusPanel, setFocusPanel] = React.useState('chat'); // 'chat' | 'sidebar'
|
|
45
|
+
const [terminalSize, setTerminalSize] = React.useState(() => ({
|
|
46
|
+
columns: Number(process.stdout?.columns) || 0,
|
|
47
|
+
rows: Number(process.stdout?.rows) || 0
|
|
48
|
+
}));
|
|
13
49
|
const mountedRef = React.useRef(true);
|
|
14
50
|
const messageIdRef = React.useRef(0);
|
|
15
51
|
const commandIdRef = React.useRef(0);
|
|
@@ -20,6 +56,23 @@ function App({ flowmind, asciiMode = false }) {
|
|
|
20
56
|
return () => { mountedRef.current = false; };
|
|
21
57
|
}, []);
|
|
22
58
|
|
|
59
|
+
React.useEffect(() => {
|
|
60
|
+
const updateSize = () => {
|
|
61
|
+
if (!mountedRef.current) return;
|
|
62
|
+
setTerminalSize({
|
|
63
|
+
columns: Number(process.stdout?.columns) || 0,
|
|
64
|
+
rows: Number(process.stdout?.rows) || 0
|
|
65
|
+
});
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
updateSize();
|
|
69
|
+
process.stdout?.on?.('resize', updateSize);
|
|
70
|
+
|
|
71
|
+
return () => {
|
|
72
|
+
process.stdout?.removeListener?.('resize', updateSize);
|
|
73
|
+
};
|
|
74
|
+
}, []);
|
|
75
|
+
|
|
23
76
|
const appendMessage = React.useCallback((role, text, metadata = {}) => {
|
|
24
77
|
const id = ++messageIdRef.current;
|
|
25
78
|
setMessages(prev => [...prev, { id, role, text, metadata }].slice(-MAX_MESSAGES));
|
|
@@ -81,20 +134,35 @@ function App({ flowmind, asciiMode = false }) {
|
|
|
81
134
|
}
|
|
82
135
|
}, [appendMessage]);
|
|
83
136
|
|
|
137
|
+
const layout = getTuiLayout(terminalSize.columns, terminalSize.rows);
|
|
138
|
+
|
|
139
|
+
if (layout.tooSmall) {
|
|
140
|
+
return (
|
|
141
|
+
React.createElement(Box, { flexDirection: 'column', borderStyle: 'single', borderColor: 'red', paddingX: 1, paddingY: 1 },
|
|
142
|
+
React.createElement(Text, { bold: true, color: 'red' }, 'Terminal too small'),
|
|
143
|
+
React.createElement(Text, { color: 'gray' }, `Need at least ${MIN_COLUMNS}x${MIN_ROWS}. Current size: ${layout.columns}x${layout.rows}.`),
|
|
144
|
+
React.createElement(Text, { color: 'gray' }, 'Resize the terminal to keep the TUI stable.')
|
|
145
|
+
)
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
84
149
|
return (
|
|
85
|
-
|
|
150
|
+
React.createElement(TuiErrorBoundary, null,
|
|
151
|
+
React.createElement(Box, { flexDirection: 'column', width: '100%', height: '100%' },
|
|
86
152
|
React.createElement(Box, { flexDirection: 'row', flexGrow: 1 },
|
|
87
|
-
React.createElement(Sidebar, { flowmind: flowmind, width:
|
|
153
|
+
React.createElement(Sidebar, { flowmind: flowmind, width: layout.sidebarWidth, onSkillSelect: handleSkillSelect, focused: focusPanel === 'sidebar', asciiMode: asciiMode }),
|
|
88
154
|
React.createElement(ChatPanel, {
|
|
89
155
|
messages,
|
|
90
156
|
onSubmit: handleCommand,
|
|
91
157
|
isProcessing: isProcessing,
|
|
92
158
|
onExit: exit,
|
|
93
159
|
focused: focusPanel === 'chat',
|
|
94
|
-
asciiMode: asciiMode
|
|
160
|
+
asciiMode: asciiMode,
|
|
161
|
+
width: layout.chatWidth
|
|
95
162
|
})
|
|
96
163
|
),
|
|
97
|
-
React.createElement(StatusBar, { flowmind: flowmind, focusPanel: focusPanel, asciiMode: asciiMode })
|
|
164
|
+
React.createElement(StatusBar, { flowmind: flowmind, focusPanel: focusPanel, asciiMode: asciiMode, width: layout.columns })
|
|
165
|
+
)
|
|
98
166
|
)
|
|
99
167
|
);
|
|
100
168
|
}
|
|
@@ -4,11 +4,12 @@ const TextInput = require('ink-text-input').default || require('ink-text-input')
|
|
|
4
4
|
const Spinner = require('ink-spinner').default || require('ink-spinner');
|
|
5
5
|
const { getBorderStyle, isExitCommand } = require('../ui');
|
|
6
6
|
|
|
7
|
-
function ChatPanel({ messages, onSubmit, isProcessing, onExit, focused, asciiMode = false }) {
|
|
7
|
+
function ChatPanel({ messages, onSubmit, isProcessing, onExit, focused, asciiMode = false, width = 0 }) {
|
|
8
8
|
const [input, setInput] = React.useState('');
|
|
9
9
|
const [cmdHistory, setCmdHistory] = React.useState([]);
|
|
10
10
|
const [historyIndex, setHistoryIndex] = React.useState(-1);
|
|
11
11
|
const [savedInput, setSavedInput] = React.useState('');
|
|
12
|
+
const isCompact = width < 60;
|
|
12
13
|
|
|
13
14
|
// Handle Up/Down arrow for command history (only when focused)
|
|
14
15
|
useInput((ch, key) => {
|
|
@@ -51,7 +52,7 @@ function ChatPanel({ messages, onSubmit, isProcessing, onExit, focused, asciiMod
|
|
|
51
52
|
onSubmit(normalized);
|
|
52
53
|
};
|
|
53
54
|
|
|
54
|
-
const displayMessages = messages.slice(-18);
|
|
55
|
+
const displayMessages = messages.slice(-(isCompact ? 8 : 18));
|
|
55
56
|
|
|
56
57
|
const renderMeta = (message) => {
|
|
57
58
|
const metaParts = [];
|
|
@@ -89,17 +90,17 @@ function ChatPanel({ messages, onSubmit, isProcessing, onExit, focused, asciiMod
|
|
|
89
90
|
return (
|
|
90
91
|
React.createElement(Box, { flexDirection: 'column', flexGrow: 1, borderStyle: getBorderStyle(asciiMode), borderColor: focused ? 'green' : 'gray', paddingX: 1 },
|
|
91
92
|
React.createElement(Text, { bold: true, color: focused ? 'green' : 'gray' }, focused ? 'FlowMind Chat [Focused]' : 'FlowMind Chat'),
|
|
92
|
-
React.createElement(Text, { color: 'gray' }, 'Single-pane conversation. Output stays in the same view.'),
|
|
93
|
+
React.createElement(Text, { color: 'gray' }, isCompact ? 'Compact chat mode.' : 'Single-pane conversation. Output stays in the same view.'),
|
|
93
94
|
React.createElement(Box, { flexDirection: 'column', flexGrow: 1, marginTop: 1, overflow: 'hidden' },
|
|
94
95
|
displayMessages.length === 0 && React.createElement(Box, { flexDirection: 'column' },
|
|
95
96
|
React.createElement(Text, { color: 'gray' }, 'Describe what you want FlowMind to do.'),
|
|
96
|
-
React.createElement(Text, { color: 'gray' }, 'The dialog history stays in this panel, with the input fixed at the bottom.'),
|
|
97
|
+
!isCompact && React.createElement(Text, { color: 'gray' }, 'The dialog history stays in this panel, with the input fixed at the bottom.'),
|
|
97
98
|
React.createElement(Text, { color: 'gray' }, 'Type `exit` or `/exit` to quit, or press Ctrl+C.')
|
|
98
99
|
),
|
|
99
100
|
displayMessages.map(renderMessage)
|
|
100
101
|
),
|
|
101
102
|
React.createElement(Box, { flexDirection: 'column', borderStyle: getBorderStyle(asciiMode), borderColor: focused ? 'green' : 'gray', paddingX: 1, marginTop: 1 },
|
|
102
|
-
React.createElement(Text, { color: 'gray' }, 'Enter send Up/Down history Tab sidebar exit /exit :q Ctrl+C'),
|
|
103
|
+
React.createElement(Text, { color: 'gray' }, isCompact ? 'Enter send | Tab switch | Ctrl+C' : 'Enter send Up/Down history Tab sidebar exit /exit :q Ctrl+C'),
|
|
103
104
|
isProcessing
|
|
104
105
|
? React.createElement(Text, { color: 'yellow' },
|
|
105
106
|
React.createElement(Spinner, { type: 'dots' }),
|
|
@@ -113,7 +114,9 @@ function ChatPanel({ messages, onSubmit, isProcessing, onExit, focused, asciiMod
|
|
|
113
114
|
onSubmit: handleSubmit,
|
|
114
115
|
focus: focused,
|
|
115
116
|
showCursor: focused,
|
|
116
|
-
placeholder: focused
|
|
117
|
+
placeholder: focused
|
|
118
|
+
? (isCompact ? 'Ask FlowMind...' : 'Ask FlowMind to do something...')
|
|
119
|
+
: 'Press Tab to focus input'
|
|
117
120
|
})
|
|
118
121
|
)
|
|
119
122
|
)
|
|
@@ -2,7 +2,7 @@ const React = require('react');
|
|
|
2
2
|
const { Box, Text } = require('ink');
|
|
3
3
|
const { LEVEL_COLORS, LEVEL_NAMES, LEVEL_STATES, getDragonArt } = require('../ui');
|
|
4
4
|
|
|
5
|
-
function DragonTotem({ honorData, compact, asciiMode = false }) {
|
|
5
|
+
function DragonTotem({ honorData, compact, asciiMode = false, width = 0 }) {
|
|
6
6
|
const points = honorData?.points || 0;
|
|
7
7
|
const level = honorData?.level || 0;
|
|
8
8
|
const art = getDragonArt(level, { asciiMode, compact });
|
|
@@ -12,6 +12,17 @@ function DragonTotem({ honorData, compact, asciiMode = false }) {
|
|
|
12
12
|
const nextLevelPoints = [1, 10, 30, 60, 100];
|
|
13
13
|
const nextPoints = nextLevelPoints[level] || null;
|
|
14
14
|
const pointsToNext = nextPoints !== null ? nextPoints - points : 0;
|
|
15
|
+
const superCompact = width > 0 && width < 26;
|
|
16
|
+
|
|
17
|
+
if (superCompact) {
|
|
18
|
+
return (
|
|
19
|
+
React.createElement(Box, { flexDirection: 'column', paddingX: 1 },
|
|
20
|
+
React.createElement(Text, { bold: true, color: 'cyan' }, 'Dragon'),
|
|
21
|
+
React.createElement(Text, { color: color }, 'Lv' + level + ' ' + levelName),
|
|
22
|
+
React.createElement(Text, { color: 'gray' }, points + ' pts')
|
|
23
|
+
)
|
|
24
|
+
);
|
|
25
|
+
}
|
|
15
26
|
|
|
16
27
|
return (
|
|
17
28
|
React.createElement(Box, { flexDirection: 'column', paddingX: 1 },
|
|
@@ -7,6 +7,7 @@ function Sidebar({ flowmind, width, onSkillSelect, focused, asciiMode = false })
|
|
|
7
7
|
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
|
8
8
|
const [skills, setSkills] = React.useState([]);
|
|
9
9
|
const [honorData, setHonorData] = React.useState({ points: 0, level: 0, stats: {} });
|
|
10
|
+
const isCompact = width < 30;
|
|
10
11
|
|
|
11
12
|
React.useEffect(() => {
|
|
12
13
|
if (flowmind) {
|
|
@@ -24,27 +25,38 @@ function Sidebar({ flowmind, width, onSkillSelect, focused, asciiMode = false })
|
|
|
24
25
|
}
|
|
25
26
|
}, [flowmind]);
|
|
26
27
|
|
|
28
|
+
React.useEffect(() => {
|
|
29
|
+
if (skills.length === 0) {
|
|
30
|
+
setSelectedIndex(0);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (selectedIndex >= skills.length) {
|
|
34
|
+
setSelectedIndex(skills.length - 1);
|
|
35
|
+
}
|
|
36
|
+
}, [selectedIndex, skills.length]);
|
|
37
|
+
|
|
27
38
|
useInput((input, key) => {
|
|
28
39
|
if (!focused) return; // Ignore input when sidebar is not focused
|
|
40
|
+
if (skills.length === 0) return;
|
|
29
41
|
if (key.upArrow) setSelectedIndex(prev => Math.max(0, prev - 1));
|
|
30
42
|
else if (key.downArrow) setSelectedIndex(prev => Math.min(skills.length - 1, prev + 1));
|
|
31
43
|
else if (key.return && skills[selectedIndex] && onSkillSelect) onSkillSelect(skills[selectedIndex]);
|
|
32
44
|
});
|
|
33
45
|
|
|
34
|
-
const barWidth = Math.max(
|
|
46
|
+
const barWidth = Math.max(8, width - 4);
|
|
35
47
|
const progress = honorData.points > 0 ? Math.min(1, honorData.points / 100) : 0;
|
|
36
48
|
const progressBar = getProgressBar(barWidth, progress, asciiMode);
|
|
37
49
|
|
|
38
50
|
return (
|
|
39
51
|
React.createElement(Box, { flexDirection: 'column', width: width, borderStyle: getBorderStyle(asciiMode), borderColor: focused ? 'cyan' : 'gray', paddingX: 1 },
|
|
40
52
|
React.createElement(Text, { color: focused ? 'cyan' : 'gray' }, focused ? 'Sidebar [Focused]' : 'Sidebar'),
|
|
41
|
-
React.createElement(DragonTotem, { honorData: honorData, compact: true, asciiMode: asciiMode }),
|
|
53
|
+
!isCompact && React.createElement(DragonTotem, { honorData: honorData, compact: true, asciiMode: asciiMode, width: width }),
|
|
42
54
|
React.createElement(Box, { flexDirection: 'column', marginTop: 1 },
|
|
43
55
|
React.createElement(Text, { bold: true, color: 'cyan' }, 'Progress'),
|
|
44
56
|
React.createElement(Text, { color: 'green' }, progressBar),
|
|
45
57
|
React.createElement(Text, { color: 'gray' }, honorData.points + '/100 pts')
|
|
46
58
|
),
|
|
47
|
-
React.createElement(Box, { flexDirection: 'column', marginTop: 1 },
|
|
59
|
+
!isCompact && React.createElement(Box, { flexDirection: 'column', marginTop: 1 },
|
|
48
60
|
React.createElement(Text, { bold: true, color: 'cyan' }, 'Skills (' + skills.length + ')'),
|
|
49
61
|
React.createElement(Box, { flexDirection: 'column', marginTop: 1 },
|
|
50
62
|
skills.length === 0 && React.createElement(Text, { color: 'gray' }, 'No skills loaded'),
|
|
@@ -60,14 +72,14 @@ function Sidebar({ flowmind, width, onSkillSelect, focused, asciiMode = false })
|
|
|
60
72
|
)
|
|
61
73
|
),
|
|
62
74
|
React.createElement(Box, { flexDirection: 'column', marginTop: 1 },
|
|
63
|
-
React.createElement(Text, { color: 'gray' }, 'Tab: switch focus'),
|
|
64
|
-
React.createElement(Text, { color: 'gray' }, 'Enter: inspect in chat')
|
|
75
|
+
React.createElement(Text, { color: 'gray' }, isCompact ? 'Tab: switch focus' : 'Tab: switch focus'),
|
|
76
|
+
!isCompact && React.createElement(Text, { color: 'gray' }, 'Enter: inspect in chat')
|
|
65
77
|
),
|
|
66
78
|
React.createElement(Box, { flexDirection: 'column', marginTop: 1 },
|
|
67
79
|
React.createElement(Text, { bold: true, color: 'cyan' }, 'Stats'),
|
|
68
80
|
React.createElement(Text, { color: 'gray' }, 'Skills used: ' + (honorData.stats?.skillUseCount || 0)),
|
|
69
|
-
React.createElement(Text, { color: 'gray' }, 'Learnings: ' + (honorData.stats?.learningCount || 0)),
|
|
70
|
-
React.createElement(Text, { color: 'gray' }, 'New skills: ' + (honorData.stats?.newSkillCount || 0))
|
|
81
|
+
!isCompact && React.createElement(Text, { color: 'gray' }, 'Learnings: ' + (honorData.stats?.learningCount || 0)),
|
|
82
|
+
!isCompact && React.createElement(Text, { color: 'gray' }, 'New skills: ' + (honorData.stats?.newSkillCount || 0))
|
|
71
83
|
)
|
|
72
84
|
)
|
|
73
85
|
);
|
|
@@ -2,10 +2,11 @@ const React = require('react');
|
|
|
2
2
|
const { Box, Text } = require('ink');
|
|
3
3
|
const { LEVEL_NAMES, getBorderStyle, getStatusDot } = require('../ui');
|
|
4
4
|
|
|
5
|
-
function StatusBar({ flowmind, focusPanel, asciiMode = false }) {
|
|
5
|
+
function StatusBar({ flowmind, focusPanel, asciiMode = false, width = 0 }) {
|
|
6
6
|
const [aiStatus, setAiStatus] = React.useState(null);
|
|
7
7
|
const [componentStatus, setComponentStatus] = React.useState(null);
|
|
8
8
|
const [honorData, setHonorData] = React.useState(null);
|
|
9
|
+
const isCompact = width < 100;
|
|
9
10
|
|
|
10
11
|
React.useEffect(() => {
|
|
11
12
|
if (flowmind) {
|
|
@@ -24,6 +25,32 @@ function StatusBar({ flowmind, focusPanel, asciiMode = false }) {
|
|
|
24
25
|
const level = honorData?.level || 0;
|
|
25
26
|
const points = honorData?.points || 0;
|
|
26
27
|
|
|
28
|
+
if (isCompact) {
|
|
29
|
+
return (
|
|
30
|
+
React.createElement(Box, { flexDirection: 'column', borderStyle: getBorderStyle(asciiMode), borderColor: 'gray', paddingX: 1 },
|
|
31
|
+
React.createElement(Text, null,
|
|
32
|
+
React.createElement(Text, { color: 'gray' }, 'AI: '),
|
|
33
|
+
React.createElement(Text, { color: aiOk ? 'green' : 'red' }, aiName),
|
|
34
|
+
React.createElement(Text, { color: aiOk ? 'green' : 'red' }, getStatusDot(aiOk, asciiMode))
|
|
35
|
+
),
|
|
36
|
+
React.createElement(Text, null,
|
|
37
|
+
React.createElement(Text, { color: 'gray' }, 'Components: '),
|
|
38
|
+
React.createElement(Text, { color: 'white' }, activeCount + '/' + componentCount),
|
|
39
|
+
React.createElement(Text, { color: 'green' }, ' active')
|
|
40
|
+
),
|
|
41
|
+
React.createElement(Text, null,
|
|
42
|
+
React.createElement(Text, { color: 'gray' }, 'Honor: '),
|
|
43
|
+
React.createElement(Text, { color: 'yellow' }, LEVEL_NAMES[level]),
|
|
44
|
+
React.createElement(Text, { color: 'gray' }, ' (' + points + ' pts)')
|
|
45
|
+
),
|
|
46
|
+
React.createElement(Text, null,
|
|
47
|
+
React.createElement(Text, { color: 'gray' }, 'Focus: '),
|
|
48
|
+
React.createElement(Text, { color: focusPanel === 'chat' ? 'green' : 'cyan' }, focusPanel || 'chat')
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
27
54
|
return (
|
|
28
55
|
React.createElement(Box, { borderStyle: getBorderStyle(asciiMode), borderColor: 'gray', paddingX: 1, justifyContent: 'space-between' },
|
|
29
56
|
React.createElement(Text, null,
|
package/tui/format-result.js
CHANGED
|
@@ -176,6 +176,10 @@ function formatResultText(result) {
|
|
|
176
176
|
return payload === undefined ? '' : formatScalar(payload);
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
+
if (getSkillName(payload) === 'log-audit') {
|
|
180
|
+
return formatLogAuditResult(payload);
|
|
181
|
+
}
|
|
182
|
+
|
|
179
183
|
const message = payload.message;
|
|
180
184
|
const details = pruneDisplayValue(payload.data);
|
|
181
185
|
const detailLines = renderValueLines(details);
|
|
@@ -195,6 +199,62 @@ function formatResultText(result) {
|
|
|
195
199
|
return truncateRenderedText(JSON.stringify(payload, null, 2));
|
|
196
200
|
}
|
|
197
201
|
|
|
202
|
+
function getSkillName(payload) {
|
|
203
|
+
return payload?.skill || payload?.data?.skill || null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function formatLogAuditResult(payload) {
|
|
207
|
+
const data = isPlainObject(payload.data) ? payload.data : {};
|
|
208
|
+
const execution = isPlainObject(data.execution) ? data.execution : {};
|
|
209
|
+
const rawSummary = isPlainObject(execution.rawSummary) ? execution.rawSummary : null;
|
|
210
|
+
const executionDisplay = rawSummary
|
|
211
|
+
? Object.fromEntries(Object.entries(execution).filter(([key]) => key !== 'rawSummary'))
|
|
212
|
+
: execution;
|
|
213
|
+
|
|
214
|
+
const sections = [];
|
|
215
|
+
if (payload.message) {
|
|
216
|
+
sections.push(payload.message);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (data.noRecords === true) {
|
|
220
|
+
sections.push('狀態:未命中日誌');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (isPlainObject(data.queryParams)) {
|
|
224
|
+
const queryLines = renderValueLines(data.queryParams);
|
|
225
|
+
if (queryLines.length > 0) {
|
|
226
|
+
sections.push(['查詢條件', ...queryLines].join('\n'));
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (isPlainObject(executionDisplay) && Object.keys(executionDisplay).length > 0) {
|
|
231
|
+
const executionLines = renderValueLines(executionDisplay);
|
|
232
|
+
if (executionLines.length > 0) {
|
|
233
|
+
sections.push(['執行結果', ...executionLines].join('\n'));
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (rawSummary) {
|
|
238
|
+
const rawLines = renderValueLines(rawSummary);
|
|
239
|
+
if (rawLines.length > 0) {
|
|
240
|
+
sections.push(['原始返回', ...rawLines].join('\n'));
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (sections.length > 0) {
|
|
245
|
+
return sections.join('\n\n');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const details = pruneDisplayValue(payload.data);
|
|
249
|
+
const detailLines = renderValueLines(details);
|
|
250
|
+
|
|
251
|
+
if (detailLines.length > 0) {
|
|
252
|
+
return truncateRenderedText(detailLines.join('\n'));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return truncateRenderedText(JSON.stringify(payload, null, 2));
|
|
256
|
+
}
|
|
257
|
+
|
|
198
258
|
module.exports = {
|
|
199
259
|
formatResultText,
|
|
200
260
|
pruneDisplayValue,
|
package/tui/layout.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
const MIN_COLUMNS = 88;
|
|
2
|
+
const MIN_ROWS = 20;
|
|
3
|
+
const DEFAULT_SIDEBAR_WIDTH = 32;
|
|
4
|
+
const MIN_SIDEBAR_WIDTH = 24;
|
|
5
|
+
const MAX_SIDEBAR_WIDTH = 40;
|
|
6
|
+
const MIN_CHAT_WIDTH = 28;
|
|
7
|
+
|
|
8
|
+
function normalizeDimension(value) {
|
|
9
|
+
const numeric = Number(value);
|
|
10
|
+
if (!Number.isFinite(numeric) || numeric < 0) {
|
|
11
|
+
return 0;
|
|
12
|
+
}
|
|
13
|
+
return Math.floor(numeric);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function getTuiLayout(columns, rows) {
|
|
17
|
+
const safeColumns = normalizeDimension(columns);
|
|
18
|
+
const safeRows = normalizeDimension(rows);
|
|
19
|
+
const tooSmall = safeColumns < MIN_COLUMNS || safeRows < MIN_ROWS;
|
|
20
|
+
|
|
21
|
+
if (tooSmall) {
|
|
22
|
+
return {
|
|
23
|
+
columns: safeColumns,
|
|
24
|
+
rows: safeRows,
|
|
25
|
+
tooSmall: true,
|
|
26
|
+
sidebarWidth: Math.min(DEFAULT_SIDEBAR_WIDTH, safeColumns),
|
|
27
|
+
chatWidth: Math.max(0, safeColumns - DEFAULT_SIDEBAR_WIDTH - 2)
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const sidebarWidth = clamp(
|
|
32
|
+
Math.round(safeColumns * 0.28),
|
|
33
|
+
MIN_SIDEBAR_WIDTH,
|
|
34
|
+
Math.min(MAX_SIDEBAR_WIDTH, Math.max(MIN_SIDEBAR_WIDTH, safeColumns - MIN_CHAT_WIDTH - 2))
|
|
35
|
+
);
|
|
36
|
+
const chatWidth = Math.max(MIN_CHAT_WIDTH, safeColumns - sidebarWidth - 2);
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
columns: safeColumns,
|
|
40
|
+
rows: safeRows,
|
|
41
|
+
tooSmall: false,
|
|
42
|
+
sidebarWidth,
|
|
43
|
+
chatWidth
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function clamp(value, min, max) {
|
|
48
|
+
return Math.max(min, Math.min(max, value));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = {
|
|
52
|
+
DEFAULT_SIDEBAR_WIDTH,
|
|
53
|
+
MAX_SIDEBAR_WIDTH,
|
|
54
|
+
MIN_CHAT_WIDTH,
|
|
55
|
+
MIN_COLUMNS,
|
|
56
|
+
MIN_ROWS,
|
|
57
|
+
MIN_SIDEBAR_WIDTH,
|
|
58
|
+
getTuiLayout,
|
|
59
|
+
normalizeDimension
|
|
60
|
+
};
|