flowmind 1.4.8 → 1.5.1

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.
@@ -3,44 +3,74 @@
3
3
  * Manage database, Redis, API, and other external resource connections
4
4
  */
5
5
 
6
+ const PROVIDER_MAP = {
7
+ 'yapi-mcp': { componentType: 'apiDoc', provider: 'yapi', mcpServer: 'yapi-mcp' },
8
+ yapi: { componentType: 'apiDoc', provider: 'yapi', mcpServer: 'yapi-mcp' },
9
+ 'yuque-mcp': { componentType: 'knowledgeBase', provider: 'yuque', mcpServer: 'yuque-mcp' },
10
+ yuque: { componentType: 'knowledgeBase', provider: 'yuque', mcpServer: 'yuque-mcp' },
11
+ 'aliyun-dms-mcp-server': { componentType: 'databaseManager', provider: 'aliyun-dms', mcpServer: 'aliyun-dms-mcp-server' },
12
+ 'aliyun-dms': { componentType: 'databaseManager', provider: 'aliyun-dms', mcpServer: 'aliyun-dms-mcp-server' },
13
+ 'aliyun-redis': { componentType: 'redisMonitor', provider: 'aliyun-redis', mcpServer: 'aliyun-redis' },
14
+ redis: { componentType: 'redisMonitor', provider: 'aliyun-redis', mcpServer: 'aliyun-redis' },
15
+ 'friday-auto-flow': { componentType: 'workflow', provider: 'friday-flow', mcpServer: 'friday-auto-flow' },
16
+ 'friday-flow': { componentType: 'workflow', provider: 'friday-flow', mcpServer: 'friday-auto-flow' },
17
+ 'aliyun-sls': { componentType: 'logService', provider: 'aliyun-sls', mcpServer: 'aliyun-sls' },
18
+ sls: { componentType: 'logService', provider: 'aliyun-sls', mcpServer: 'aliyun-sls' }
19
+ };
20
+
6
21
  module.exports = {
7
- canHandle(input, context) {
22
+ canHandle(input) {
8
23
  if (!input) return false;
9
- return /资源绑定|resource.*bind|数据库.*连接|redis.*连接|连接.*管理|connection.*manage/i.test(input);
24
+ return /资源绑定|resource.*bind|数据库.*连接|redis.*连接|连接.*管理|connection.*manage|绑定.*(mcp|token|地址|endpoint|url|host)|使用.*已绑定/i.test(input);
10
25
  },
11
26
 
12
27
  async execute(input, context) {
13
28
  const registry = context.componentRegistry;
14
29
  const params = parseResourceParams(input);
15
30
 
31
+ if (params.action === 'bind') {
32
+ return bindResource(input, context, params);
33
+ }
34
+
16
35
  if (params.action === 'list') {
17
36
  const components = registry?.getAll ? registry.getAll() : [];
37
+ const bindings = await context.flowmind?.learning?.listResourceBindings?.() || [];
18
38
  return {
19
39
  type: 'result',
20
40
  skill: 'resource-bind',
21
- message: `Found ${components.length} configured component(s)`,
41
+ message: `Found ${components.length} configured component(s) and ${bindings.length} learned binding(s)`,
22
42
  data: {
23
- components: components.map(c => ({
43
+ components: components.map((c) => ({
24
44
  name: c.name,
25
45
  type: c.type,
26
46
  provider: c.provider,
27
47
  active: c.active,
28
48
  initialized: c.initialized,
29
49
  mcpServer: c.mcpServer
30
- }))
50
+ })),
51
+ bindings
31
52
  },
32
53
  input,
33
54
  timestamp: new Date().toISOString()
34
55
  };
35
56
  }
36
57
 
37
- if (params.action === 'status') {
38
- const status = registry ? registry.getStatus() : {};
58
+ if (params.action === 'status' || params.action === 'use' || params.action === 'show') {
59
+ const status = registry?.getStatus ? registry.getStatus() : {};
60
+ const matchedBinding = context.resourceBinding
61
+ || await context.flowmind?.learning?.matchResourceBinding?.(input, 'resource-bind')
62
+ || null;
63
+
39
64
  return {
40
65
  type: 'result',
41
66
  skill: 'resource-bind',
42
- message: 'Resource connection status',
43
- data: { status },
67
+ message: matchedBinding
68
+ ? `Using learned binding for business: ${matchedBinding.business}`
69
+ : 'Resource connection status',
70
+ data: {
71
+ status,
72
+ matchedBinding
73
+ },
44
74
  input,
45
75
  timestamp: new Date().toISOString()
46
76
  };
@@ -49,10 +79,15 @@ module.exports = {
49
79
  return {
50
80
  type: 'result',
51
81
  skill: 'resource-bind',
52
- message: 'Resource binding. Available actions: list, status',
82
+ message: 'Resource binding. Available actions: bind, list, status, use',
53
83
  data: {
54
- actions: ['list - List all resources', 'status - Show connection status'],
55
- supportedTypes: ['MySQL', 'PostgreSQL', 'Redis', 'REST API']
84
+ actions: [
85
+ 'bind - Save MCP/address/token for a business',
86
+ 'list - List configured resources and learned bindings',
87
+ 'status - Show connection status and matched binding',
88
+ 'use - Resolve a learned binding for the current business'
89
+ ],
90
+ supportedTypes: ['MySQL', 'PostgreSQL', 'Redis', 'REST API', 'YApi', 'Yuque', 'Workflow']
56
91
  },
57
92
  input,
58
93
  timestamp: new Date().toISOString()
@@ -60,10 +95,167 @@ module.exports = {
60
95
  }
61
96
  };
62
97
 
98
+ async function bindResource(input, context, params) {
99
+ const learning = context.flowmind?.learning;
100
+ if (!learning?.saveResourceBinding) {
101
+ return {
102
+ type: 'error',
103
+ skill: 'resource-bind',
104
+ message: 'Learning engine is not available for resource binding',
105
+ input,
106
+ timestamp: new Date().toISOString()
107
+ };
108
+ }
109
+
110
+ const binding = buildBinding(input, params, context);
111
+ const saved = await learning.saveResourceBinding(binding, {
112
+ currentSkill: 'resource-bind'
113
+ });
114
+ const storagePath = learning.expandPath(learning.learningPath);
115
+
116
+ return {
117
+ type: 'result',
118
+ skill: 'resource-bind',
119
+ message: `Saved learned binding for business: ${saved.business}`,
120
+ data: {
121
+ binding: saved,
122
+ storage: `${storagePath}/resource-bindings.json`
123
+ },
124
+ input,
125
+ timestamp: new Date().toISOString()
126
+ };
127
+ }
128
+
129
+ function buildBinding(input, params, context) {
130
+ const inferred = resolveProviderInfo(params);
131
+ const business = inferBusiness(input, params, inferred);
132
+ const aliases = [business, params.project, params.namespace, params.alias, params.env].filter(Boolean);
133
+ const keywords = extractKeywords(input, aliases);
134
+
135
+ return {
136
+ business,
137
+ aliases,
138
+ componentType: params.componentType || inferred.componentType || context.flowmind?.learning?.inferComponentType?.(input, 'resource-bind') || null,
139
+ provider: params.provider || inferred.provider || null,
140
+ mcpServer: params.mcpServer || inferred.mcpServer || null,
141
+ connection: {
142
+ address: params.address || params.url || params.endpoint || null,
143
+ host: params.host || null,
144
+ port: params.port || null,
145
+ token: params.token || null,
146
+ apiKey: params.apiKey || null,
147
+ namespace: params.namespace || null,
148
+ project: params.project || null,
149
+ sourceId: params.sourceId || null,
150
+ databaseId: params.databaseId || null,
151
+ env: params.env || null
152
+ },
153
+ metadata: {
154
+ input
155
+ },
156
+ keywords
157
+ };
158
+ }
159
+
63
160
  function parseResourceParams(input) {
64
161
  const params = {};
65
- if (/列表|list|查看|show/i.test(input)) params.action = 'list';
162
+ if (/列表|list|查看全部|show all/i.test(input)) params.action = 'list';
66
163
  if (/状态|status|连接/i.test(input)) params.action = 'status';
67
- if (/绑定|bind|添加|add/i.test(input)) params.action = 'bind';
164
+ if (/绑定|bind|添加|add|记住|保存/i.test(input)) params.action = 'bind';
165
+ if (/使用|use|套用|复用/i.test(input)) params.action = 'use';
166
+ if (/查看绑定|show binding|显示绑定/i.test(input)) params.action = 'show';
167
+
168
+ const keyMap = {
169
+ business: ['业务', 'business', 'biz', 'service'],
170
+ alias: ['别名', 'alias'],
171
+ mcpServer: ['mcp', 'mcpServer', 'mcp_server'],
172
+ provider: ['provider', '服务商'],
173
+ componentType: ['component', 'componentType', '组件'],
174
+ address: ['地址', 'address'],
175
+ url: ['url', 'baseUrl', 'baseURL'],
176
+ endpoint: ['endpoint'],
177
+ host: ['host'],
178
+ port: ['port'],
179
+ token: ['token'],
180
+ apiKey: ['apiKey', 'apikey', 'key'],
181
+ namespace: ['namespace', 'repo', '仓库'],
182
+ project: ['project', '项目'],
183
+ sourceId: ['sourceId', 'source_id'],
184
+ databaseId: ['databaseId', 'database_id'],
185
+ env: ['env', '环境']
186
+ };
187
+
188
+ for (const [targetKey, aliases] of Object.entries(keyMap)) {
189
+ const value = findKeyValue(input, aliases);
190
+ if (value) params[targetKey] = value;
191
+ }
192
+
193
+ const bizSuffixMatch = input.match(/(?:绑定|bind)?\s*([\u4e00-\u9fa5A-Za-z0-9._-]+)\s*业务/i);
194
+ if (!params.business && bizSuffixMatch) {
195
+ params.business = bizSuffixMatch[1];
196
+ }
197
+
198
+ if (!params.business) {
199
+ const bizKeywordMatch = input.match(/(?:绑定|bind)\s*([\u4e00-\u9fa5A-Za-z0-9._-]+?)\s*(平台|服务|环境|工程|项目)/i);
200
+ if (bizKeywordMatch) {
201
+ params.business = `${bizKeywordMatch[1]}${bizKeywordMatch[2]}`;
202
+ }
203
+ }
204
+
205
+ const urlMatch = input.match(/https?:\/\/[^\s,,]+/i);
206
+ if (!params.address && !params.url && urlMatch) {
207
+ params.address = urlMatch[0];
208
+ }
209
+
68
210
  return params;
69
211
  }
212
+
213
+ function findKeyValue(input, aliases) {
214
+ for (const alias of aliases) {
215
+ const regex = new RegExp(`${escapeRegex(alias)}\\s*[:=:]\\s*([^\\s,,]+)`, 'i');
216
+ const match = input.match(regex);
217
+ if (match) return match[1].trim();
218
+ }
219
+ return null;
220
+ }
221
+
222
+ function resolveProviderInfo(params) {
223
+ const candidates = [params.provider, params.mcpServer, params.componentType]
224
+ .filter(Boolean)
225
+ .map((value) => String(value).toLowerCase());
226
+
227
+ for (const candidate of candidates) {
228
+ if (PROVIDER_MAP[candidate]) return PROVIDER_MAP[candidate];
229
+ }
230
+
231
+ return {};
232
+ }
233
+
234
+ function inferBusiness(input, params, inferred = {}) {
235
+ const explicit = params.business || params.project || params.namespace;
236
+ if (explicit) return explicit;
237
+
238
+ const keywordMatch = String(input || '').match(/(?:绑定|bind)\s*([\u4e00-\u9fa5A-Za-z0-9._-]+?)\s*(平台|服务|环境|工程|项目)/i);
239
+ if (keywordMatch) {
240
+ return `${keywordMatch[1]}${keywordMatch[2]}`;
241
+ }
242
+
243
+ if (inferred.componentType === 'workflow') {
244
+ return params.env ? `发布平台-${params.env}` : '发布平台';
245
+ }
246
+
247
+ if (inferred.provider) {
248
+ return params.env ? `${inferred.provider}-${params.env}` : inferred.provider;
249
+ }
250
+
251
+ return params.env ? `default-${params.env}` : 'default';
252
+ }
253
+
254
+ function extractKeywords(input, aliases = []) {
255
+ const words = input.match(/[\u4e00-\u9fa5A-Za-z0-9._-]+/g) || [];
256
+ return [...new Set([...aliases.filter(Boolean), ...words])].slice(0, 16);
257
+ }
258
+
259
+ function escapeRegex(value) {
260
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
261
+ }
@@ -11,8 +11,11 @@ module.exports = {
11
11
 
12
12
  async execute(input, context) {
13
13
  const apiDoc = context.componentRegistry?.getAdapter('apiDoc');
14
+ const learnedBinding = context.resourceBinding?.componentType === 'apiDoc'
15
+ ? context.resourceBinding
16
+ : null;
14
17
 
15
- if (!apiDoc) {
18
+ if (!apiDoc && !learnedBinding) {
16
19
  return {
17
20
  type: 'result',
18
21
  skill: 'yapi-sync-interface',
@@ -32,7 +35,8 @@ module.exports = {
32
35
  message: `Searching YApi for: ${params.keyword || 'all'}`,
33
36
  data: {
34
37
  mcpTool: 'yapi_search_apis',
35
- args: { projectKeyword: params.project, nameKeyword: params.keyword, limit: 20 }
38
+ args: { projectKeyword: params.project || learnedBinding?.connection?.project, nameKeyword: params.keyword, limit: 20 },
39
+ binding: learnedBinding
36
40
  },
37
41
  input,
38
42
  timestamp: new Date().toISOString()
@@ -43,10 +47,11 @@ module.exports = {
43
47
  return {
44
48
  type: 'result',
45
49
  skill: 'yapi-sync-interface',
46
- message: `Listing YApi categories for project: ${params.project || 'default'}`,
50
+ message: `Listing YApi categories for project: ${params.project || learnedBinding?.connection?.project || 'default'}`,
47
51
  data: {
48
52
  mcpTool: 'yapi_get_categories',
49
- args: { projectId: params.project }
53
+ args: { projectId: params.project || learnedBinding?.connection?.project },
54
+ binding: learnedBinding
50
55
  },
51
56
  input,
52
57
  timestamp: new Date().toISOString()
@@ -57,10 +62,11 @@ module.exports = {
57
62
  return {
58
63
  type: 'result',
59
64
  skill: 'yapi-sync-interface',
60
- message: `Exporting YApi project: ${params.project}`,
65
+ message: `Exporting YApi project: ${params.project || learnedBinding?.connection?.project || 'default'}`,
61
66
  data: {
62
67
  mcpTool: 'yapi_export_project',
63
- args: { projectId: params.project, type: params.format || 'swagger' }
68
+ args: { projectId: params.project || learnedBinding?.connection?.project, type: params.format || 'swagger' },
69
+ binding: learnedBinding
64
70
  },
65
71
  input,
66
72
  timestamp: new Date().toISOString()
@@ -73,7 +79,8 @@ module.exports = {
73
79
  message: 'YApi sync. Available actions: search, list, export, import',
74
80
  data: {
75
81
  actions: ['search - Search interfaces', 'list - List categories', 'export - Export project', 'import - Import Swagger'],
76
- mcpTools: ['yapi_search_apis', 'yapi_get_categories', 'yapi_save_api', 'yapi_import_swagger', 'yapi_export_project']
82
+ mcpTools: ['yapi_search_apis', 'yapi_get_categories', 'yapi_save_api', 'yapi_import_swagger', 'yapi_export_project'],
83
+ binding: learnedBinding
77
84
  },
78
85
  input,
79
86
  timestamp: new Date().toISOString()
@@ -14,8 +14,11 @@ module.exports = {
14
14
 
15
15
  async execute(input, context) {
16
16
  const knowledgeBase = context.componentRegistry?.getAdapter('knowledgeBase');
17
+ const learnedBinding = context.resourceBinding?.componentType === 'knowledgeBase'
18
+ ? context.resourceBinding
19
+ : null;
17
20
 
18
- if (!knowledgeBase) {
21
+ if (!knowledgeBase && !learnedBinding) {
19
22
  return {
20
23
  type: 'result',
21
24
  skill: 'yuque-sync-design',
@@ -35,7 +38,8 @@ module.exports = {
35
38
  message: `Searching Yuque for: ${params.keyword || 'all'}`,
36
39
  data: {
37
40
  mcpTool: 'search',
38
- args: { q: params.keyword, type: 'doc' }
41
+ args: { q: params.keyword, type: 'doc' },
42
+ binding: learnedBinding
39
43
  },
40
44
  input,
41
45
  timestamp: new Date().toISOString()
@@ -49,7 +53,8 @@ module.exports = {
49
53
  message: 'Listing Yuque repositories',
50
54
  data: {
51
55
  mcpTool: 'get_user_repos',
52
- args: {}
56
+ args: {},
57
+ binding: learnedBinding
53
58
  },
54
59
  input,
55
60
  timestamp: new Date().toISOString()
@@ -57,7 +62,7 @@ module.exports = {
57
62
  }
58
63
 
59
64
  if (params.action === 'sync') {
60
- return syncDesignDoc(params, input);
65
+ return syncDesignDoc(params, input, learnedBinding);
61
66
  }
62
67
 
63
68
  return {
@@ -66,7 +71,8 @@ module.exports = {
66
71
  message: 'Yuque design sync. Available actions: search, list, sync',
67
72
  data: {
68
73
  actions: ['search - Search documents', 'list - List repos', 'sync - Sync design doc'],
69
- mcpTools: ['get_user_repos', 'get_repo_docs', 'get_doc', 'create_doc', 'update_doc', 'search']
74
+ mcpTools: ['get_user_repos', 'get_repo_docs', 'get_doc', 'create_doc', 'update_doc', 'search'],
75
+ binding: learnedBinding
70
76
  },
71
77
  input,
72
78
  timestamp: new Date().toISOString()
@@ -92,7 +98,7 @@ function parseYuqueParams(input) {
92
98
  return params;
93
99
  }
94
100
 
95
- async function syncDesignDoc(params, input) {
101
+ async function syncDesignDoc(params, input, learnedBinding) {
96
102
  const filePath = params.path || 'DESIGN.md';
97
103
 
98
104
  if (!(await fs.pathExists(filePath))) {
@@ -115,12 +121,13 @@ async function syncDesignDoc(params, input) {
115
121
  data: {
116
122
  mcpTool: 'create_doc',
117
123
  args: {
118
- namespace: params.repo,
124
+ namespace: params.repo || learnedBinding?.connection?.namespace,
119
125
  slug: title.toLowerCase().replace(/\s+/g, '-'),
120
126
  title,
121
127
  body: content,
122
128
  format: 'markdown'
123
- }
129
+ },
130
+ binding: learnedBinding
124
131
  },
125
132
  input,
126
133
  timestamp: new Date().toISOString()
package/tui/app.jsx CHANGED
@@ -1,21 +1,27 @@
1
1
  const React = require('react');
2
- const { Box, Text, useApp, useInput } = require('ink');
2
+ const { Box, useApp, useInput } = require('ink');
3
3
  const Sidebar = require('./components/Sidebar.jsx');
4
4
  const ChatPanel = require('./components/ChatPanel.jsx');
5
- const ResultPanel = require('./components/ResultPanel.jsx');
6
5
  const StatusBar = require('./components/StatusBar.jsx');
6
+ const { formatResultText } = require('./format-result');
7
7
 
8
- function App({ flowmind }) {
9
- const [results, setResults] = React.useState([]);
8
+ function App({ flowmind, asciiMode = false }) {
9
+ const [messages, setMessages] = React.useState([]);
10
10
  const [isProcessing, setIsProcessing] = React.useState(false);
11
11
  const [focusPanel, setFocusPanel] = React.useState('chat'); // 'chat' | 'sidebar'
12
12
  const mountedRef = React.useRef(true);
13
+ const messageIdRef = React.useRef(0);
13
14
  const { exit } = useApp();
14
15
 
15
16
  React.useEffect(() => {
16
17
  return () => { mountedRef.current = false; };
17
18
  }, []);
18
19
 
20
+ const appendMessage = React.useCallback((role, text, metadata = {}) => {
21
+ const id = ++messageIdRef.current;
22
+ setMessages(prev => [...prev, { id, role, text, metadata }]);
23
+ }, []);
24
+
19
25
  // Ctrl+C always exits; Tab switches focus between panels
20
26
  useInput((input, key) => {
21
27
  if (key.ctrl && input === 'c') exit();
@@ -24,51 +30,61 @@ function App({ flowmind }) {
24
30
  }
25
31
  });
26
32
 
27
- const handleCommand = React.useCallback(async (input, addResponse) => {
33
+ const handleCommand = React.useCallback(async (input) => {
28
34
  if (!mountedRef.current) return;
35
+ appendMessage('user', input);
29
36
  setIsProcessing(true);
37
+
30
38
  try {
31
39
  const result = await flowmind.process(input);
32
40
  if (!mountedRef.current) return;
33
- if (result.type === 'result') {
34
- const text = typeof result.data === 'string' ? result.data : JSON.stringify(result.data, null, 2);
35
- addResponse(text);
36
- } else if (result.type === 'learning') {
37
- addResponse(result.message || 'Learning recorded');
38
- } else if (result.type === 'error') {
39
- addResponse('Error: ' + result.message);
40
- }
41
- setResults(prev => [...prev, result]);
41
+
42
+ appendMessage('flowmind', formatResultText(result), {
43
+ type: result.type,
44
+ skill: result.metadata?.skill || result.skill,
45
+ duration: result.metadata?.duration,
46
+ scene: result.metadata?.sceneMatch?.scene?.name
47
+ });
42
48
  } catch (e) {
43
- if (mountedRef.current) addResponse('Error: ' + e.message);
49
+ if (mountedRef.current) appendMessage('flowmind', 'Error: ' + e.message, { type: 'error' });
44
50
  } finally {
45
51
  if (mountedRef.current) setIsProcessing(false);
46
52
  }
47
- }, [flowmind]);
53
+ }, [appendMessage, flowmind]);
48
54
 
49
55
  const handleSkillSelect = React.useCallback((skill) => {
50
56
  if (!mountedRef.current) return;
51
57
  try {
52
- setResults(prev => [...prev, {
53
- type: 'result',
54
- data: { name: skill.name, description: skill.definition?.description || 'No description', category: skill.category || 'general', path: skill.path },
55
- metadata: { skill: skill.name }
56
- }]);
58
+ appendMessage(
59
+ 'system',
60
+ [
61
+ `Skill: ${skill.name}`,
62
+ `Category: ${skill.category || 'general'}`,
63
+ `Description: ${skill.definition?.description || 'No description'}`,
64
+ `Path: ${skill.path}`
65
+ ].join('\n'),
66
+ { label: 'skill inspect' }
67
+ );
68
+ setFocusPanel('chat');
57
69
  } catch (e) {
58
70
  // ignore skill select errors
59
71
  }
60
- }, []);
72
+ }, [appendMessage]);
61
73
 
62
74
  return (
63
75
  React.createElement(Box, { flexDirection: 'column', width: '100%', height: '100%' },
64
76
  React.createElement(Box, { flexDirection: 'row', flexGrow: 1 },
65
- React.createElement(Sidebar, { flowmind: flowmind, width: 30, onSkillSelect: handleSkillSelect, focused: focusPanel === 'sidebar' }),
66
- React.createElement(Box, { flexDirection: 'column', width: '70%', flexGrow: 1 },
67
- React.createElement(ChatPanel, { onSubmit: handleCommand, isProcessing: isProcessing, onExit: exit, focused: focusPanel === 'chat' }),
68
- React.createElement(ResultPanel, { results: results })
69
- )
77
+ React.createElement(Sidebar, { flowmind: flowmind, width: 32, onSkillSelect: handleSkillSelect, focused: focusPanel === 'sidebar', asciiMode: asciiMode }),
78
+ React.createElement(ChatPanel, {
79
+ messages,
80
+ onSubmit: handleCommand,
81
+ isProcessing: isProcessing,
82
+ onExit: exit,
83
+ focused: focusPanel === 'chat',
84
+ asciiMode: asciiMode
85
+ })
70
86
  ),
71
- React.createElement(StatusBar, { flowmind: flowmind, focusPanel: focusPanel })
87
+ React.createElement(StatusBar, { flowmind: flowmind, focusPanel: focusPanel, asciiMode: asciiMode })
72
88
  )
73
89
  );
74
90
  }
@@ -2,18 +2,13 @@ const React = require('react');
2
2
  const { Box, Text, useInput } = require('ink');
3
3
  const TextInput = require('ink-text-input').default || require('ink-text-input');
4
4
  const Spinner = require('ink-spinner').default || require('ink-spinner');
5
+ const { getBorderStyle, isExitCommand } = require('../ui');
5
6
 
6
- function ChatPanel({ onSubmit, isProcessing, onExit, focused }) {
7
+ function ChatPanel({ messages, onSubmit, isProcessing, onExit, focused, asciiMode = false }) {
7
8
  const [input, setInput] = React.useState('');
8
- const [history, setHistory] = 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 mountedRef = React.useRef(true);
13
-
14
- React.useEffect(() => {
15
- return () => { mountedRef.current = false; };
16
- }, []);
17
12
 
18
13
  // Handle Up/Down arrow for command history (only when focused)
19
14
  useInput((ch, key) => {
@@ -40,58 +35,75 @@ function ChatPanel({ onSubmit, isProcessing, onExit, focused }) {
40
35
  });
41
36
 
42
37
  const handleSubmit = (value) => {
43
- if (!value.trim()) return;
44
- setHistory(prev => [...prev, { role: 'user', text: value }]);
38
+ const normalized = value.trim();
39
+ if (!normalized) return;
45
40
  // Add to command history (deduplicate consecutive)
46
- if (cmdHistory.length === 0 || cmdHistory[cmdHistory.length - 1] !== value) {
47
- setCmdHistory(prev => [...prev, value]);
41
+ if (cmdHistory.length === 0 || cmdHistory[cmdHistory.length - 1] !== normalized) {
42
+ setCmdHistory(prev => [...prev, normalized]);
48
43
  }
49
44
  setHistoryIndex(-1);
50
45
  setSavedInput('');
51
46
  setInput('');
52
- if (value.toLowerCase() === 'exit' || value.toLowerCase() === 'quit') {
47
+ if (isExitCommand(normalized)) {
53
48
  if (onExit) onExit();
54
49
  return;
55
50
  }
56
- try {
57
- onSubmit(value, (response) => {
58
- if (mountedRef.current) {
59
- setHistory(prev => [...prev, { role: 'flowmind', text: response }]);
60
- }
61
- });
62
- } catch (e) {
63
- if (mountedRef.current) {
64
- setHistory(prev => [...prev, { role: 'flowmind', text: 'Error: ' + e.message }]);
65
- }
66
- }
51
+ onSubmit(normalized);
67
52
  };
68
53
 
69
- const displayHistory = history.slice(-20);
54
+ const displayMessages = messages.slice(-18);
55
+
56
+ const renderMeta = (message) => {
57
+ const metaParts = [];
58
+
59
+ if (message.metadata?.label) metaParts.push(message.metadata.label);
60
+ if (message.metadata?.skill) metaParts.push(message.metadata.skill);
61
+ if (message.metadata?.duration) metaParts.push(message.metadata.duration + 'ms');
62
+ if (message.metadata?.scene) metaParts.push('scene: ' + message.metadata.scene);
63
+
64
+ if (metaParts.length === 0) return null;
65
+
66
+ return React.createElement(
67
+ Text,
68
+ { color: 'gray' },
69
+ metaParts.join(' | ')
70
+ );
71
+ };
72
+
73
+ const renderMessage = (message) => {
74
+ const isUser = message.role === 'user';
75
+ const isSystem = message.role === 'system';
76
+ const accentColor = isUser ? 'green' : (isSystem ? 'yellow' : 'cyan');
77
+ const bodyColor = isUser ? 'white' : (isSystem ? 'yellow' : 'cyan');
78
+ const label = isUser ? 'You' : (isSystem ? 'FlowMind Guide' : 'FlowMind');
79
+
80
+ return React.createElement(
81
+ Box,
82
+ { key: message.id, flexDirection: 'column', marginBottom: 1 },
83
+ React.createElement(Text, { color: accentColor, bold: true }, label),
84
+ renderMeta(message),
85
+ React.createElement(Text, { color: bodyColor }, message.text)
86
+ );
87
+ };
70
88
 
71
89
  return (
72
- React.createElement(Box, { flexDirection: 'column', borderStyle: 'single', borderColor: focused ? 'green' : 'gray', paddingX: 1 },
73
- React.createElement(Text, { bold: true, color: focused ? 'green' : 'gray' }, focused ? 'Command Input [Focused]' : 'Command Input'),
74
- React.createElement(Box, { flexDirection: 'column', marginTop: 1, minHeight: 6 },
75
- displayHistory.length === 0 && React.createElement(Text, { color: 'gray' }, 'Type a command to get started. Type "exit" to quit.'),
76
- displayHistory.map((msg, i) =>
77
- React.createElement(Box, { key: i, flexDirection: 'column' },
78
- msg.role === 'user'
79
- ? React.createElement(Text, null,
80
- React.createElement(Text, { color: 'green', bold: true }, '> '),
81
- React.createElement(Text, { color: 'white' }, msg.text)
82
- )
83
- : React.createElement(Text, null,
84
- React.createElement(Text, { color: 'cyan', bold: true }, '< '),
85
- React.createElement(Text, { color: 'cyan' }, msg.text)
86
- )
87
- )
88
- )
90
+ React.createElement(Box, { flexDirection: 'column', flexGrow: 1, borderStyle: getBorderStyle(asciiMode), borderColor: focused ? 'green' : 'gray', paddingX: 1 },
91
+ 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(Box, { flexDirection: 'column', flexGrow: 1, marginTop: 1, overflow: 'hidden' },
94
+ displayMessages.length === 0 && React.createElement(Box, { flexDirection: 'column' },
95
+ 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
+ React.createElement(Text, { color: 'gray' }, 'Type `exit` or `/exit` to quit, or press Ctrl+C.')
98
+ ),
99
+ displayMessages.map(renderMessage)
89
100
  ),
90
- React.createElement(Box, { marginTop: 1 },
101
+ 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'),
91
103
  isProcessing
92
104
  ? React.createElement(Text, { color: 'yellow' },
93
105
  React.createElement(Spinner, { type: 'dots' }),
94
- ' Processing...'
106
+ ' FlowMind is working...'
95
107
  )
96
108
  : React.createElement(Box, null,
97
109
  React.createElement(Text, { color: focused ? 'green' : 'gray', bold: true }, '> '),