iflow-feishu 1.1.3 → 1.1.5
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/bin/iflow-feishu.js +15 -15
- package/package.json +1 -1
- package/src/config/config.js +18 -60
- package/src/core/constants.js +75 -30
- package/src/core/iflow-client.js +22 -13
- package/src/core/message-processor.js +9 -43
- package/src/core/service.js +24 -10
- package/src/core/session.js +27 -3
- package/src/core/stream-handler.js +54 -29
- package/src/handlers/commands.js +8 -4
package/bin/iflow-feishu.js
CHANGED
|
@@ -2,19 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* iFlow Feishu CLI 入口
|
|
5
|
-
*
|
|
6
|
-
* 启动流程:
|
|
7
|
-
* 1. 检测 iFlow CLI -> 未安装则引导安装
|
|
8
|
-
* 2. 检测 PM2 -> 未安装则自动安装
|
|
9
|
-
* 3. 检测飞书配置 -> 未配置则引导输入
|
|
10
|
-
* 4. 启动服务
|
|
11
5
|
*/
|
|
12
6
|
|
|
13
7
|
const { spawn, execSync } = require('child_process');
|
|
14
8
|
const fs = require('fs');
|
|
15
9
|
const path = require('path');
|
|
10
|
+
const os = require('os');
|
|
16
11
|
const readline = require('readline');
|
|
17
12
|
|
|
13
|
+
// 跨平台获取用户主目录
|
|
14
|
+
function getHomeDir() {
|
|
15
|
+
return process.env.HOME || process.env.USERPROFILE || os.homedir() || '.';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
18
|
// 颜色输出
|
|
19
19
|
const colors = {
|
|
20
20
|
red: '\x1b[0;31m',
|
|
@@ -49,7 +49,11 @@ const VERSION = getVersion();
|
|
|
49
49
|
// 检查命令是否存在
|
|
50
50
|
function commandExists(cmd) {
|
|
51
51
|
try {
|
|
52
|
-
|
|
52
|
+
// 跨平台检测
|
|
53
|
+
const checkCmd = process.platform === 'win32'
|
|
54
|
+
? `where ${cmd} 2>nul`
|
|
55
|
+
: `which ${cmd} 2>/dev/null || command -v ${cmd} 2>/dev/null`;
|
|
56
|
+
execSync(checkCmd, { stdio: 'ignore' });
|
|
53
57
|
return true;
|
|
54
58
|
} catch {
|
|
55
59
|
return false;
|
|
@@ -89,7 +93,6 @@ async function checkIFlowCLI() {
|
|
|
89
93
|
|
|
90
94
|
if (commandExists('iflow')) {
|
|
91
95
|
const version = getCommandVersion('iflow');
|
|
92
|
-
// 验证 iFlow CLI 是否真正可用
|
|
93
96
|
try {
|
|
94
97
|
execSync('iflow --version', { stdio: 'pipe', timeout: 5000 });
|
|
95
98
|
log.success(`iFlow CLI 已安装 (版本: ${version})`);
|
|
@@ -112,7 +115,6 @@ async function checkIFlowCLI() {
|
|
|
112
115
|
log.info('正在安装 iFlow CLI...');
|
|
113
116
|
try {
|
|
114
117
|
execSync('npm install -g @iflow-ai/iflow-cli', { stdio: 'inherit' });
|
|
115
|
-
// 再次验证
|
|
116
118
|
if (commandExists('iflow')) {
|
|
117
119
|
log.success('iFlow CLI 安装成功');
|
|
118
120
|
return true;
|
|
@@ -157,15 +159,15 @@ async function checkPM2() {
|
|
|
157
159
|
} catch (err) {
|
|
158
160
|
log.warn(`PM2 安装失败: ${err.message}`);
|
|
159
161
|
console.log('服务将以前台模式运行');
|
|
160
|
-
return true;
|
|
162
|
+
return true;
|
|
161
163
|
}
|
|
162
164
|
}
|
|
163
165
|
|
|
164
|
-
return true;
|
|
166
|
+
return true;
|
|
165
167
|
}
|
|
166
168
|
|
|
167
|
-
//
|
|
168
|
-
const CONFIG_DIR = path.join(
|
|
169
|
+
// 配置文件路径(跨平台)
|
|
170
|
+
const CONFIG_DIR = path.join(getHomeDir(), '.feishu-config');
|
|
169
171
|
const CONFIG_PATH = path.join(CONFIG_DIR, 'feishu-app.json');
|
|
170
172
|
|
|
171
173
|
// 验证飞书凭证
|
|
@@ -339,8 +341,6 @@ async function checkFeishuConfig() {
|
|
|
339
341
|
// 启动服务
|
|
340
342
|
function startService() {
|
|
341
343
|
log.step('启动服务...');
|
|
342
|
-
|
|
343
|
-
// 直接运行服务
|
|
344
344
|
require('../src/index.js');
|
|
345
345
|
}
|
|
346
346
|
|
package/package.json
CHANGED
package/src/config/config.js
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 配置管理模块
|
|
3
|
-
*
|
|
4
|
-
* 负责加载和验证配置,支持交互式配置向导
|
|
5
3
|
*/
|
|
6
4
|
|
|
7
5
|
const fs = require('fs');
|
|
8
6
|
const path = require('path');
|
|
7
|
+
const os = require('os');
|
|
9
8
|
const readline = require('readline');
|
|
10
|
-
const { DEFAULT_MODEL, DEFAULT_MAX_TOKENS,
|
|
9
|
+
const { DEFAULT_MODEL, DEFAULT_MAX_TOKENS, MODEL_CONTEXT_LIMITS, getModelContextLimit } = require('../core/constants');
|
|
11
10
|
|
|
12
|
-
|
|
11
|
+
// 跨平台获取用户主目录
|
|
12
|
+
function getHomeDir() {
|
|
13
|
+
return process.env.HOME || process.env.USERPROFILE || os.homedir() || '/tmp';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const CONFIG_DIR = path.join(getHomeDir(), '.feishu-config');
|
|
13
17
|
const CONFIG_PATH = path.join(CONFIG_DIR, 'feishu-app.json');
|
|
14
18
|
|
|
15
|
-
// 获取版本号
|
|
16
19
|
function getVersion() {
|
|
17
20
|
try {
|
|
18
21
|
const pkgPath = path.join(__dirname, '..', '..', 'package.json');
|
|
@@ -25,10 +28,6 @@ function getVersion() {
|
|
|
25
28
|
|
|
26
29
|
const VERSION = getVersion();
|
|
27
30
|
|
|
28
|
-
/**
|
|
29
|
-
* 加载飞书配置
|
|
30
|
-
* @returns {Object|null}
|
|
31
|
-
*/
|
|
32
31
|
function loadFeishuConfig() {
|
|
33
32
|
try {
|
|
34
33
|
if (fs.existsSync(CONFIG_PATH)) {
|
|
@@ -41,28 +40,18 @@ function loadFeishuConfig() {
|
|
|
41
40
|
} catch (err) {
|
|
42
41
|
console.error(`[ERROR] 读取飞书配置失败: ${err.message}`);
|
|
43
42
|
}
|
|
44
|
-
|
|
45
43
|
return null;
|
|
46
44
|
}
|
|
47
45
|
|
|
48
|
-
/**
|
|
49
|
-
* 保存飞书配置
|
|
50
|
-
* @param {string} appId
|
|
51
|
-
* @param {string} appSecret
|
|
52
|
-
*/
|
|
53
46
|
function saveFeishuConfig(appId, appSecret) {
|
|
54
47
|
if (!fs.existsSync(CONFIG_DIR)) {
|
|
55
48
|
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
56
49
|
}
|
|
57
|
-
|
|
58
50
|
const config = { appId, appSecret };
|
|
59
51
|
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
|
|
60
52
|
console.log(`\n✅ 配置已保存到: ${CONFIG_PATH}\n`);
|
|
61
53
|
}
|
|
62
54
|
|
|
63
|
-
/**
|
|
64
|
-
* 配置向导 - 交互式创建配置
|
|
65
|
-
*/
|
|
66
55
|
async function setupWizard() {
|
|
67
56
|
const rl = readline.createInterface({
|
|
68
57
|
input: process.stdin,
|
|
@@ -94,37 +83,19 @@ async function setupWizard() {
|
|
|
94
83
|
}
|
|
95
84
|
|
|
96
85
|
rl.close();
|
|
97
|
-
|
|
98
86
|
saveFeishuConfig(appId.trim(), appSecret.trim());
|
|
99
|
-
|
|
100
87
|
return { appId: appId.trim(), appSecret: appSecret.trim() };
|
|
101
88
|
}
|
|
102
89
|
|
|
103
|
-
/**
|
|
104
|
-
* 验证配置
|
|
105
|
-
* @param {Object} config - 配置对象
|
|
106
|
-
* @throws {Error} - 配置无效时抛出错误
|
|
107
|
-
*/
|
|
108
90
|
function validateConfig(config) {
|
|
109
|
-
if (!config.feishu?.appId)
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
if (!config.feishu?.appSecret) {
|
|
114
|
-
throw new Error('缺少飞书 App Secret');
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
if (!config.server?.port || config.server.port < 1 || config.server.port > 65535) {
|
|
118
|
-
throw new Error('无效的服务端口');
|
|
119
|
-
}
|
|
120
|
-
|
|
91
|
+
if (!config.feishu?.appId) throw new Error('缺少飞书 App ID');
|
|
92
|
+
if (!config.feishu?.appSecret) throw new Error('缺少飞书 App Secret');
|
|
93
|
+
if (!config.server?.port || config.server.port < 1 || config.server.port > 65535) throw new Error('无效的服务端口');
|
|
121
94
|
return true;
|
|
122
95
|
}
|
|
123
96
|
|
|
124
|
-
/**
|
|
125
|
-
* 构建完整配置对象
|
|
126
|
-
*/
|
|
127
97
|
function buildConfig(feishuConfig) {
|
|
98
|
+
const homeDir = getHomeDir();
|
|
128
99
|
return {
|
|
129
100
|
version: VERSION,
|
|
130
101
|
feishu: {
|
|
@@ -134,37 +105,28 @@ function buildConfig(feishuConfig) {
|
|
|
134
105
|
iflow: {
|
|
135
106
|
command: 'iflow',
|
|
136
107
|
timeout: 300000,
|
|
137
|
-
workDir:
|
|
108
|
+
workDir: homeDir,
|
|
138
109
|
maxTokens: DEFAULT_MAX_TOKENS,
|
|
139
|
-
modelMaxTokens:
|
|
110
|
+
modelMaxTokens: MODEL_CONTEXT_LIMITS
|
|
140
111
|
},
|
|
141
112
|
server: {
|
|
142
113
|
port: parseInt(process.env.PORT, 10) || 18080,
|
|
143
114
|
host: '0.0.0.0'
|
|
144
115
|
},
|
|
145
116
|
sessions: {
|
|
146
|
-
dir: path.join(
|
|
117
|
+
dir: path.join(homeDir, '.iflow-feishu', 'sessions'),
|
|
147
118
|
maxHistory: 15,
|
|
148
119
|
},
|
|
149
120
|
card: {
|
|
150
121
|
titleFontSize: 'small',
|
|
151
|
-
colors: {
|
|
152
|
-
model: 'blue',
|
|
153
|
-
generating: 'orange',
|
|
154
|
-
completed: 'green'
|
|
155
|
-
}
|
|
122
|
+
colors: { model: 'blue', generating: 'orange', completed: 'green' }
|
|
156
123
|
}
|
|
157
124
|
};
|
|
158
125
|
}
|
|
159
126
|
|
|
160
|
-
/**
|
|
161
|
-
* 初始化配置(异步,支持交互式向导)
|
|
162
|
-
* @returns {Promise<Object>} 配置对象
|
|
163
|
-
*/
|
|
164
127
|
async function initConfig() {
|
|
165
128
|
let feishuConfig = loadFeishuConfig();
|
|
166
129
|
|
|
167
|
-
// 检查环境变量
|
|
168
130
|
if (!feishuConfig && process.env.FEISHU_APP_ID && process.env.FEISHU_APP_SECRET) {
|
|
169
131
|
feishuConfig = {
|
|
170
132
|
appId: process.env.FEISHU_APP_ID,
|
|
@@ -172,20 +134,15 @@ async function initConfig() {
|
|
|
172
134
|
};
|
|
173
135
|
}
|
|
174
136
|
|
|
175
|
-
// 配置缺失,启动向导
|
|
176
137
|
if (!feishuConfig) {
|
|
177
138
|
console.log('\n⚠️ 未找到飞书配置文件');
|
|
178
139
|
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
|
|
179
|
-
|
|
180
140
|
feishuConfig = await setupWizard();
|
|
181
141
|
}
|
|
182
142
|
|
|
183
143
|
const config = buildConfig(feishuConfig);
|
|
184
|
-
|
|
185
|
-
// 验证配置
|
|
186
144
|
validateConfig(config);
|
|
187
145
|
|
|
188
|
-
// 确保会话目录存在
|
|
189
146
|
if (!fs.existsSync(config.sessions.dir)) {
|
|
190
147
|
fs.mkdirSync(config.sessions.dir, { recursive: true });
|
|
191
148
|
}
|
|
@@ -197,5 +154,6 @@ module.exports = {
|
|
|
197
154
|
initConfig,
|
|
198
155
|
getVersion,
|
|
199
156
|
VERSION,
|
|
200
|
-
CONFIG_PATH
|
|
157
|
+
CONFIG_PATH,
|
|
158
|
+
getHomeDir
|
|
201
159
|
};
|
package/src/core/constants.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 常量定义
|
|
2
|
+
* 常量定义 - 动态模型上下文容量
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
const fs = require('fs');
|
|
@@ -22,45 +22,90 @@ const RECONNECT_MAX_DELAY = 30000;
|
|
|
22
22
|
const DEFAULT_MODEL = 'glm-5';
|
|
23
23
|
const DEFAULT_MAX_TOKENS = 128000;
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
'
|
|
31
|
-
'
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
'
|
|
35
|
-
'
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
'
|
|
25
|
+
/**
|
|
26
|
+
* 模型上下文容量映射表
|
|
27
|
+
*/
|
|
28
|
+
const MODEL_CONTEXT_LIMITS = {
|
|
29
|
+
// GLM 系列
|
|
30
|
+
'glm-4': 128000, 'glm-4-plus': 128000, 'glm-4-air': 128000, 'glm-4-flash': 128000,
|
|
31
|
+
'glm-5': 128000, 'glm-z1-air': 128000, 'glm-z1-airx': 128000, 'glm-z1-flash': 128000,
|
|
32
|
+
|
|
33
|
+
// GPT 系列
|
|
34
|
+
'gpt-4': 128000, 'gpt-4-turbo': 128000, 'gpt-4o': 128000, 'gpt-4o-mini': 128000,
|
|
35
|
+
'gpt-3.5-turbo': 16384, 'o1': 200000, 'o1-mini': 128000, 'o1-preview': 128000,
|
|
36
|
+
|
|
37
|
+
// Claude 系列
|
|
38
|
+
'claude-3-opus': 200000, 'claude-3-sonnet': 200000, 'claude-3-haiku': 200000,
|
|
39
|
+
'claude-3.5-sonnet': 200000, 'claude-3.5-haiku': 200000,
|
|
40
|
+
|
|
41
|
+
// Qwen 系列
|
|
42
|
+
'qwen-turbo': 131072, 'qwen-plus': 131072, 'qwen-max': 32768,
|
|
43
|
+
'qwen2.5': 131072, 'qwen3': 131072, 'qwen3-coder-plus': 128000, 'qwen3-coder': 131072,
|
|
44
|
+
|
|
45
|
+
// DeepSeek 系列
|
|
46
|
+
'deepseek-chat': 64000, 'deepseek-coder': 16384, 'deepseek-reasoner': 64000,
|
|
47
|
+
|
|
48
|
+
// LLaMA 系列
|
|
49
|
+
'llama-3': 8192, 'llama-3-8b': 8192, 'llama-3-70b': 8192,
|
|
50
|
+
'llama-3.1': 131072, 'llama-3.2': 131072,
|
|
51
|
+
|
|
52
|
+
// Mistral 系列
|
|
53
|
+
'mistral-small': 32768, 'mistral-medium': 32768, 'mistral-large': 32768, 'mixtral': 32768,
|
|
54
|
+
|
|
55
|
+
// Yi 系列
|
|
56
|
+
'yi-lightning': 16384, 'yi-large': 32768,
|
|
57
|
+
|
|
58
|
+
// Moonshot (Kimi)
|
|
59
|
+
'moonshot-v1-8k': 8192, 'moonshot-v1-32k': 32768, 'moonshot-v1-128k': 131072, 'kimi': 131072,
|
|
60
|
+
|
|
61
|
+
// 其他
|
|
62
|
+
'gemini-pro': 32760, 'gemini-1.5-pro': 1048576, 'gemini-1.5-flash': 1048576
|
|
39
63
|
};
|
|
40
64
|
|
|
41
|
-
|
|
65
|
+
/**
|
|
66
|
+
* 动态获取模型上下文上限
|
|
67
|
+
*/
|
|
68
|
+
function getModelContextLimit(modelName) {
|
|
69
|
+
if (!modelName) return DEFAULT_MAX_TOKENS;
|
|
70
|
+
|
|
71
|
+
const name = modelName.toLowerCase();
|
|
72
|
+
|
|
73
|
+
// 1. 精确匹配
|
|
74
|
+
if (MODEL_CONTEXT_LIMITS[modelName]) return MODEL_CONTEXT_LIMITS[modelName];
|
|
75
|
+
if (MODEL_CONTEXT_LIMITS[name]) return MODEL_CONTEXT_LIMITS[name];
|
|
76
|
+
|
|
77
|
+
// 2. 模糊匹配
|
|
78
|
+
for (const [key, limit] of Object.entries(MODEL_CONTEXT_LIMITS)) {
|
|
79
|
+
if (name.includes(key) || key.includes(name)) return limit;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 3. 按系列推断
|
|
83
|
+
if (name.includes('glm')) return 128000;
|
|
84
|
+
if (name.includes('gpt-4') || name.includes('gpt4')) return 128000;
|
|
85
|
+
if (name.includes('gpt-3')) return 16384;
|
|
86
|
+
if (name.includes('claude')) return 200000;
|
|
87
|
+
if (name.includes('qwen')) return 131072;
|
|
88
|
+
if (name.includes('deepseek')) return 64000;
|
|
89
|
+
if (name.includes('llama')) return 8192;
|
|
90
|
+
if (name.includes('mistral') || name.includes('mixtral')) return 32768;
|
|
91
|
+
if (name.includes('gemini-1.5')) return 1048576;
|
|
92
|
+
if (name.includes('gemini')) return 32760;
|
|
93
|
+
|
|
94
|
+
return DEFAULT_MAX_TOKENS;
|
|
95
|
+
}
|
|
96
|
+
|
|
42
97
|
function getVersion() {
|
|
43
98
|
try {
|
|
44
99
|
const pkgPath = path.join(__dirname, '..', '..', 'package.json');
|
|
45
100
|
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
46
101
|
return pkg.version || '1.0.0';
|
|
47
|
-
} catch {
|
|
48
|
-
return '1.0.0';
|
|
49
|
-
}
|
|
102
|
+
} catch { return '1.0.0'; }
|
|
50
103
|
}
|
|
51
104
|
|
|
52
105
|
const VERSION = getVersion();
|
|
53
106
|
|
|
54
107
|
module.exports = {
|
|
55
|
-
SEARCH_KEYWORDS,
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
CARD_UPDATE_DELAY,
|
|
59
|
-
RECONNECT_MAX_ATTEMPTS,
|
|
60
|
-
RECONNECT_BASE_DELAY,
|
|
61
|
-
RECONNECT_MAX_DELAY,
|
|
62
|
-
DEFAULT_MODEL,
|
|
63
|
-
DEFAULT_MAX_TOKENS,
|
|
64
|
-
MODEL_MAX_TOKENS,
|
|
65
|
-
VERSION
|
|
108
|
+
SEARCH_KEYWORDS, CARD_UPDATE_INTERVAL, TIMER_UPDATE_INTERVAL, CARD_UPDATE_DELAY,
|
|
109
|
+
RECONNECT_MAX_ATTEMPTS, RECONNECT_BASE_DELAY, RECONNECT_MAX_DELAY,
|
|
110
|
+
DEFAULT_MODEL, DEFAULT_MAX_TOKENS, MODEL_CONTEXT_LIMITS, getModelContextLimit, VERSION
|
|
66
111
|
};
|
package/src/core/iflow-client.js
CHANGED
|
@@ -79,19 +79,28 @@ class IFlowClient {
|
|
|
79
79
|
reject(new Error(`iFlow CLI 超时 (${this.config.iflow.timeout}ms)`));
|
|
80
80
|
}, this.config.iflow.timeout);
|
|
81
81
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
82
|
+
// 使用 exit 事件而不是 close,确保所有 IO 完成
|
|
83
|
+
// 使用 exit 事件而不是 close,确保所有 IO 完成
|
|
84
|
+
child.on('exit', (code) => {
|
|
85
|
+
// 给一点时间让最后的 stdout/stderr 数据被收集
|
|
86
|
+
setTimeout(() => {
|
|
87
|
+
clearTimeout(timer);
|
|
88
|
+
logger.info(`⏱️ iFlow CLI 完成, 退出码: ${code}`);
|
|
89
|
+
// <Execution Info> 在 stderr 中,合并到 stdout 以便提取 token 信息
|
|
90
|
+
const fullOutput = stdout + '\n' + stderr;
|
|
91
|
+
logger.info(`📊 stdout 长度: ${stdout.length}, stderr 长度: ${stderr.length}`);
|
|
92
|
+
logger.info(`📊 包含 Execution Info: ${fullOutput.includes('<Execution Info>')}`);
|
|
93
|
+
|
|
94
|
+
if (code === 0 || stdout) {
|
|
95
|
+
resolve({
|
|
96
|
+
stdout: fullOutput, // 返回完整输出,包含 stderr 中的 Execution Info
|
|
97
|
+
stderr,
|
|
98
|
+
success: true
|
|
99
|
+
});
|
|
100
|
+
} else {
|
|
101
|
+
reject(new Error(`iFlow CLI failed: ${stderr || 'Unknown error'}`));
|
|
102
|
+
}
|
|
103
|
+
}, 100);
|
|
95
104
|
});
|
|
96
105
|
|
|
97
106
|
child.on('error', (err) => {
|
|
@@ -2,26 +2,26 @@
|
|
|
2
2
|
* 消息处理器模块
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
const
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const { SEARCH_KEYWORDS, DEFAULT_MODEL, DEFAULT_MAX_TOKENS, getModelContextLimit } = require('./constants');
|
|
6
7
|
const { logger } = require('../utils/logger');
|
|
7
8
|
|
|
9
|
+
// 跨平台获取用户主目录
|
|
10
|
+
function getHomeDir() {
|
|
11
|
+
return process.env.HOME || process.env.USERPROFILE || os.homedir() || '/tmp';
|
|
12
|
+
}
|
|
13
|
+
|
|
8
14
|
class MessageProcessor {
|
|
9
15
|
constructor(service) {
|
|
10
16
|
this.service = service;
|
|
11
17
|
}
|
|
12
18
|
|
|
13
|
-
/**
|
|
14
|
-
* 解析消息事件
|
|
15
|
-
* @param {Object} event - 飞书事件
|
|
16
|
-
* @returns {Object|null} - 解析后的消息对象
|
|
17
|
-
*/
|
|
18
19
|
parseMessage(event) {
|
|
19
20
|
const message = event.event?.message || event.body?.event?.message || event.message;
|
|
20
21
|
if (!message) {
|
|
21
22
|
logger.warn('无法解析消息,消息格式不正确');
|
|
22
23
|
return null;
|
|
23
24
|
}
|
|
24
|
-
|
|
25
25
|
return {
|
|
26
26
|
chatId: message.chat_id,
|
|
27
27
|
msgId: message.message_id,
|
|
@@ -30,14 +30,8 @@ class MessageProcessor {
|
|
|
30
30
|
};
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
/**
|
|
34
|
-
* 提取文本内容
|
|
35
|
-
* @param {Object} message - 消息对象
|
|
36
|
-
* @returns {string} - 提取的文本
|
|
37
|
-
*/
|
|
38
33
|
extractText(message) {
|
|
39
34
|
const content = message.raw?.content || message.content;
|
|
40
|
-
|
|
41
35
|
try {
|
|
42
36
|
if (typeof content === 'string') {
|
|
43
37
|
return JSON.parse(content).text || content;
|
|
@@ -49,24 +43,15 @@ class MessageProcessor {
|
|
|
49
43
|
}
|
|
50
44
|
}
|
|
51
45
|
|
|
52
|
-
/**
|
|
53
|
-
* 检测是否需要搜索
|
|
54
|
-
* @param {string} text - 用户文本
|
|
55
|
-
* @returns {boolean}
|
|
56
|
-
*/
|
|
57
46
|
needsSearch(text) {
|
|
58
47
|
return SEARCH_KEYWORDS.some(keyword => text.includes(keyword));
|
|
59
48
|
}
|
|
60
49
|
|
|
61
|
-
/**
|
|
62
|
-
* 获取当前模型名称
|
|
63
|
-
* @returns {string}
|
|
64
|
-
*/
|
|
65
50
|
getModelName() {
|
|
66
51
|
try {
|
|
67
52
|
const fs = require('fs');
|
|
68
53
|
const path = require('path');
|
|
69
|
-
const settingsPath = path.join(
|
|
54
|
+
const settingsPath = path.join(getHomeDir(), '.iflow', 'settings.json');
|
|
70
55
|
|
|
71
56
|
if (fs.existsSync(settingsPath)) {
|
|
72
57
|
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
@@ -78,22 +63,10 @@ class MessageProcessor {
|
|
|
78
63
|
return DEFAULT_MODEL;
|
|
79
64
|
}
|
|
80
65
|
|
|
81
|
-
/**
|
|
82
|
-
* 获取模型最大 token 数
|
|
83
|
-
* @param {string} modelName - 模型名称
|
|
84
|
-
* @returns {number}
|
|
85
|
-
*/
|
|
86
66
|
getMaxTokens(modelName = DEFAULT_MODEL) {
|
|
87
|
-
|
|
88
|
-
return modelMaxTokens[modelName] || this.service.config.iflow.maxTokens || DEFAULT_MAX_TOKENS;
|
|
67
|
+
return getModelContextLimit(modelName);
|
|
89
68
|
}
|
|
90
69
|
|
|
91
|
-
/**
|
|
92
|
-
* 构建带上下文的 prompt
|
|
93
|
-
* @param {string} chatId - 聊天ID
|
|
94
|
-
* @param {string} userText - 用户文本
|
|
95
|
-
* @returns {Object} - 包含 prompt 和历史信息
|
|
96
|
-
*/
|
|
97
70
|
buildPromptWithContext(chatId, userText) {
|
|
98
71
|
const session = this.service.sessionManager.get(chatId);
|
|
99
72
|
let messageWithContext = userText;
|
|
@@ -126,13 +99,6 @@ class MessageProcessor {
|
|
|
126
99
|
};
|
|
127
100
|
}
|
|
128
101
|
|
|
129
|
-
/**
|
|
130
|
-
* 计算剩余上下文百分比
|
|
131
|
-
* @param {string} chatId - 聊天ID
|
|
132
|
-
* @param {number} outputLength - 输出长度
|
|
133
|
-
* @param {string} modelName - 模型名称
|
|
134
|
-
* @returns {number}
|
|
135
|
-
*/
|
|
136
102
|
calculateContentLeftPercent(chatId, outputLength, modelName) {
|
|
137
103
|
const session = this.service.sessionManager.get(chatId);
|
|
138
104
|
const historyLength = session.messages.reduce((sum, msg) => sum + (msg.content?.length || 0), 0);
|
package/src/core/service.js
CHANGED
|
@@ -244,12 +244,10 @@ class FeishuService {
|
|
|
244
244
|
let hasDetectedThinking = false;
|
|
245
245
|
let isInThinkingPhase = false;
|
|
246
246
|
|
|
247
|
-
//
|
|
248
|
-
const
|
|
249
|
-
const historyLength = session.messages.reduce((sum, msg) => sum + (msg.content?.length || 0), 0);
|
|
250
|
-
const initialPercent = this.messageProcessor.calculateContentLeftPercent(chatId, 0, modelName);
|
|
247
|
+
// 使用上次保存的上下文百分比作为初始值
|
|
248
|
+
const initialPercent = this.sessionManager.getContentLeftPercent(chatId);
|
|
251
249
|
|
|
252
|
-
logger.info(`初始上下文 - 剩余: ${initialPercent}
|
|
250
|
+
logger.info(`初始上下文 - 剩余: ${initialPercent}% (来自上次保存)`);
|
|
253
251
|
|
|
254
252
|
// 创建卡片更新器
|
|
255
253
|
const updater = this.streamHandler.createCardUpdater(cardId, startTime, modelName, initialPercent);
|
|
@@ -290,10 +288,17 @@ class FeishuService {
|
|
|
290
288
|
updater.setReasoning(extracted.reasoning);
|
|
291
289
|
updater.setContent(extracted.content || '');
|
|
292
290
|
|
|
293
|
-
//
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
|
|
291
|
+
// 优先从流式输出中实时提取 token 信息
|
|
292
|
+
const streamPercent = this.streamHandler.extractTokenUsageFromChunk(stdoutSoFar, modelName);
|
|
293
|
+
if (streamPercent !== null) {
|
|
294
|
+
// 流式检测到 Execution Info,使用准确的 token 信息
|
|
295
|
+
updater.setPercent(streamPercent);
|
|
296
|
+
logger.debug(`📊 流式更新 left: ${streamPercent}%`);
|
|
297
|
+
} else if (extracted.contentLeftPercent !== null) {
|
|
298
|
+
// 从完整提取中得到
|
|
299
|
+
updater.setPercent(extracted.contentLeftPercent);
|
|
300
|
+
}
|
|
301
|
+
// 否则保持之前的百分比值(初始值或上次保存的值)
|
|
297
302
|
|
|
298
303
|
updater.update();
|
|
299
304
|
},
|
|
@@ -304,7 +309,10 @@ class FeishuService {
|
|
|
304
309
|
updater.endStream(Date.now());
|
|
305
310
|
|
|
306
311
|
// 获取最终结果
|
|
307
|
-
const
|
|
312
|
+
const finalOutput = output.stdout || output;
|
|
313
|
+
logger.info(`📊 最终输出长度: ${finalOutput?.length || 0}, 最后500字符: ${finalOutput?.slice(-500)}`);
|
|
314
|
+
const finalExtracted = this.streamHandler.extractResponse(finalOutput, chatId, modelName);
|
|
315
|
+
logger.info(`📊 最终提取的 contentLeftPercent: ${finalExtracted.contentLeftPercent}`);
|
|
308
316
|
if (finalExtracted.contentLeftPercent !== null) {
|
|
309
317
|
updater.setPercent(finalExtracted.contentLeftPercent);
|
|
310
318
|
}
|
|
@@ -327,6 +335,12 @@ class FeishuService {
|
|
|
327
335
|
|
|
328
336
|
logger.info(`完成 - 思考: ${finalThinkingTime || 0}ms, 回复: ${finalResponseTime}ms`);
|
|
329
337
|
|
|
338
|
+
// 保存新的上下文剩余百分比
|
|
339
|
+
if (finalExtracted.contentLeftPercent !== null) {
|
|
340
|
+
this.sessionManager.setContentLeftPercent(chatId, finalExtracted.contentLeftPercent);
|
|
341
|
+
logger.info(`📊 保存上下文剩余: ${finalExtracted.contentLeftPercent}%`);
|
|
342
|
+
}
|
|
343
|
+
|
|
330
344
|
// 更新最终卡片
|
|
331
345
|
if (cardId) {
|
|
332
346
|
const hasReasoning = finalExtracted.reasoning && finalExtracted.reasoning.trim();
|
package/src/core/session.js
CHANGED
|
@@ -16,7 +16,7 @@ class SessionManager {
|
|
|
16
16
|
const filePath = path.join(this.config.dir, `${chatId}.json`);
|
|
17
17
|
this.sessions.set(chatId, fs.existsSync(filePath)
|
|
18
18
|
? JSON.parse(fs.readFileSync(filePath, 'utf8'))
|
|
19
|
-
: { messages: [], createdAt: Date.now() }
|
|
19
|
+
: { messages: [], createdAt: Date.now(), lastContentLeftPercent: 100 }
|
|
20
20
|
);
|
|
21
21
|
}
|
|
22
22
|
return this.sessions.get(chatId);
|
|
@@ -30,10 +30,34 @@ class SessionManager {
|
|
|
30
30
|
session.messages = session.messages.slice(-this.config.maxHistory);
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
this.save(chatId);
|
|
34
|
+
return session;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 保存上下文剩余百分比
|
|
39
|
+
*/
|
|
40
|
+
setContentLeftPercent(chatId, percent) {
|
|
41
|
+
const session = this.get(chatId);
|
|
42
|
+
session.lastContentLeftPercent = percent;
|
|
43
|
+
this.save(chatId);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 获取上下文剩余百分比
|
|
48
|
+
*/
|
|
49
|
+
getContentLeftPercent(chatId) {
|
|
50
|
+
const session = this.get(chatId);
|
|
51
|
+
return session.lastContentLeftPercent || 100;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 保存会话到文件
|
|
56
|
+
*/
|
|
57
|
+
save(chatId) {
|
|
58
|
+
const session = this.get(chatId);
|
|
33
59
|
const filePath = path.join(this.config.dir, `${chatId}.json`);
|
|
34
60
|
fs.writeFileSync(filePath, JSON.stringify(session, null, 2));
|
|
35
|
-
|
|
36
|
-
return session;
|
|
37
61
|
}
|
|
38
62
|
|
|
39
63
|
clear(chatId) {
|
|
@@ -3,19 +3,22 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
const { logger } = require('../utils/logger');
|
|
6
|
-
const { CARD_UPDATE_INTERVAL, TIMER_UPDATE_INTERVAL, CARD_UPDATE_DELAY } = require('./constants');
|
|
6
|
+
const { CARD_UPDATE_INTERVAL, TIMER_UPDATE_INTERVAL, CARD_UPDATE_DELAY, getModelContextLimit } = require('./constants');
|
|
7
7
|
|
|
8
8
|
class StreamHandler {
|
|
9
9
|
constructor(service) {
|
|
10
10
|
this.service = service;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* 获取模型最大 token 数(动态)
|
|
15
|
+
*/
|
|
16
|
+
getMaxTokens(modelName) {
|
|
17
|
+
return getModelContextLimit(modelName);
|
|
18
|
+
}
|
|
19
|
+
|
|
13
20
|
/**
|
|
14
21
|
* 从输出中提取响应
|
|
15
|
-
* @param {string|Object} output - 输出内容
|
|
16
|
-
* @param {string} chatId - 聊天ID
|
|
17
|
-
* @param {string} modelName - 模型名称
|
|
18
|
-
* @returns {Object} - { reasoning, content, contentLeftPercent }
|
|
19
22
|
*/
|
|
20
23
|
extractResponse(output, chatId, modelName) {
|
|
21
24
|
let text = '';
|
|
@@ -32,9 +35,9 @@ class StreamHandler {
|
|
|
32
35
|
const maxTokens = this.getMaxTokens(modelName);
|
|
33
36
|
|
|
34
37
|
// 首先尝试从 Execution Info 提取
|
|
35
|
-
let contentLeftPercent = this.extractTokenUsage(text, maxTokens);
|
|
38
|
+
let contentLeftPercent = this.extractTokenUsage(text, maxTokens, modelName);
|
|
36
39
|
|
|
37
|
-
//
|
|
40
|
+
// 如果没有,尝试从回复文本中提取
|
|
38
41
|
if (contentLeftPercent === null) {
|
|
39
42
|
contentLeftPercent = this.extractContextFromContent(text);
|
|
40
43
|
}
|
|
@@ -51,12 +54,41 @@ class StreamHandler {
|
|
|
51
54
|
return { reasoning, content: content || '(无法提取响应)', contentLeftPercent };
|
|
52
55
|
}
|
|
53
56
|
|
|
57
|
+
/**
|
|
58
|
+
* 从流式 chunk 中实时提取 token 使用信息
|
|
59
|
+
*/
|
|
60
|
+
extractTokenUsageFromChunk(accumulatedOutput, modelName) {
|
|
61
|
+
if (!accumulatedOutput || typeof accumulatedOutput !== 'string') {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const execInfoMatch = accumulatedOutput.match(/<Execution Info>([\s\S]*?)(?:<\/Execution Info>|$)/);
|
|
66
|
+
if (execInfoMatch) {
|
|
67
|
+
try {
|
|
68
|
+
const jsonStr = execInfoMatch[1].trim();
|
|
69
|
+
const braceCount = (jsonStr.match(/{/g) || []).length - (jsonStr.match(/}/g) || []).length;
|
|
70
|
+
if (braceCount === 0) {
|
|
71
|
+
const execInfo = JSON.parse(jsonStr);
|
|
72
|
+
if (execInfo.tokenUsage && execInfo.tokenUsage.input > 0) {
|
|
73
|
+
const maxTokens = this.getMaxTokens(modelName);
|
|
74
|
+
const used = execInfo.tokenUsage.input;
|
|
75
|
+
const remaining = Math.max(0, maxTokens - used);
|
|
76
|
+
const percent = Math.round((remaining / maxTokens) * 100);
|
|
77
|
+
logger.info(`📊 流式检测 token - 模型: ${modelName}, 上限: ${maxTokens}, 已用: ${used}, 剩余: ${percent}%`);
|
|
78
|
+
return percent;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
} catch (e) {
|
|
82
|
+
// JSON 还不完整,忽略
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
54
88
|
/**
|
|
55
89
|
* 从回复内容中提取上下文剩余百分比
|
|
56
|
-
* 匹配格式: "📊 剩余 ~77%", "剩余 ~77%", "剩余 77%" 等
|
|
57
90
|
*/
|
|
58
91
|
extractContextFromContent(text) {
|
|
59
|
-
// 匹配多种格式: 剩余 ~77%, 剩余 77%, 📊 剩余 ~77%
|
|
60
92
|
const patterns = [
|
|
61
93
|
/剩余\s*~?(\d+)%/i,
|
|
62
94
|
/📊\s*剩余\s*~?(\d+)%/i,
|
|
@@ -76,35 +108,30 @@ class StreamHandler {
|
|
|
76
108
|
return null;
|
|
77
109
|
}
|
|
78
110
|
|
|
79
|
-
/**
|
|
80
|
-
* 获取模型最大 token 数
|
|
81
|
-
*/
|
|
82
|
-
getMaxTokens(modelName) {
|
|
83
|
-
const modelMaxTokens = this.service.config.iflow.modelMaxTokens || {};
|
|
84
|
-
return modelMaxTokens[modelName] || this.service.config.iflow.maxTokens || 128000;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
111
|
/**
|
|
88
112
|
* 从输出中提取 token 使用信息
|
|
89
113
|
*/
|
|
90
|
-
extractTokenUsage(text, maxTokens) {
|
|
114
|
+
extractTokenUsage(text, maxTokens, modelName) {
|
|
115
|
+
if (!text || typeof text !== 'string') {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
91
119
|
const execInfoMatch = text.match(/<Execution Info>([\s\S]*?)<\/Execution Info>/);
|
|
92
120
|
if (execInfoMatch) {
|
|
93
121
|
try {
|
|
94
122
|
const execInfo = JSON.parse(execInfoMatch[1]);
|
|
95
123
|
logger.info(`📊 解析到 Execution Info: ${JSON.stringify(execInfo.tokenUsage)}`);
|
|
96
|
-
|
|
97
|
-
|
|
124
|
+
// 使用 input 来计算剩余上下文(input 包含历史对话 + 本次请求)
|
|
125
|
+
if (execInfo.tokenUsage && execInfo.tokenUsage.input > 0) {
|
|
126
|
+
const used = execInfo.tokenUsage.input;
|
|
98
127
|
const remaining = Math.max(0, maxTokens - used);
|
|
99
128
|
const percent = Math.round((remaining / maxTokens) * 100);
|
|
100
|
-
logger.info(`📊 Token
|
|
129
|
+
logger.info(`📊 Token 计算 - 模型: ${modelName}, 上限: ${maxTokens}, 输入: ${used}, 剩余: ${percent}%`);
|
|
101
130
|
return percent;
|
|
102
131
|
}
|
|
103
132
|
} catch (e) {
|
|
104
133
|
logger.warn(`📊 解析 Execution Info 失败: ${e.message}`);
|
|
105
134
|
}
|
|
106
|
-
} else {
|
|
107
|
-
logger.info(`📊 未找到 Execution Info 标签`);
|
|
108
135
|
}
|
|
109
136
|
return null;
|
|
110
137
|
}
|
|
@@ -151,7 +178,7 @@ class StreamHandler {
|
|
|
151
178
|
try {
|
|
152
179
|
const fs = require('fs');
|
|
153
180
|
const path = require('path');
|
|
154
|
-
const logDir = path.join(
|
|
181
|
+
const logDir = path.join(getHomeDir(), '.iflow', 'log');
|
|
155
182
|
|
|
156
183
|
const maxTokens = this.getMaxTokens(modelName);
|
|
157
184
|
|
|
@@ -172,10 +199,8 @@ class StreamHandler {
|
|
|
172
199
|
const tokenMatch = lastPart.match(/(\d+)\s+tokens\s+from\s+the\s+input\s+messages\s+and\s+(\d+)\s+tokens\s+for\s+the\s+completion/);
|
|
173
200
|
if (tokenMatch) {
|
|
174
201
|
const inputTokens = parseInt(tokenMatch[1], 10);
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
const remaining = Math.max(0, maxTokens - totalUsed);
|
|
178
|
-
logger.info(`📊 从日志读取 token: 输入=${inputTokens}, 输出=${outputTokens}, 总计=${totalUsed}/${maxTokens}`);
|
|
202
|
+
const remaining = Math.max(0, maxTokens - inputTokens);
|
|
203
|
+
logger.info(`📊 从日志读取 token - 模型: ${modelName}, 上限: ${maxTokens}, 输入: ${inputTokens}`);
|
|
179
204
|
return Math.round((remaining / maxTokens) * 100);
|
|
180
205
|
}
|
|
181
206
|
|
|
@@ -266,4 +291,4 @@ class StreamHandler {
|
|
|
266
291
|
}
|
|
267
292
|
}
|
|
268
293
|
|
|
269
|
-
module.exports = { StreamHandler };
|
|
294
|
+
module.exports = { StreamHandler };
|
package/src/handlers/commands.js
CHANGED
|
@@ -4,8 +4,14 @@
|
|
|
4
4
|
|
|
5
5
|
const fs = require('fs');
|
|
6
6
|
const path = require('path');
|
|
7
|
+
const os = require('os');
|
|
7
8
|
const { logger } = require('../utils/logger');
|
|
8
9
|
|
|
10
|
+
// 跨平台获取用户主目录
|
|
11
|
+
function getHomeDir() {
|
|
12
|
+
return process.env.HOME || process.env.USERPROFILE || os.homedir() || '/tmp';
|
|
13
|
+
}
|
|
14
|
+
|
|
9
15
|
class CommandHandler {
|
|
10
16
|
constructor(service) {
|
|
11
17
|
this.service = service;
|
|
@@ -66,7 +72,7 @@ class CommandHandler {
|
|
|
66
72
|
}
|
|
67
73
|
|
|
68
74
|
async handleMode(chatId, args) {
|
|
69
|
-
const settingsPath = path.join(
|
|
75
|
+
const settingsPath = path.join(getHomeDir(), '.iflow', 'settings.json');
|
|
70
76
|
|
|
71
77
|
let currentMode = 'default';
|
|
72
78
|
try {
|
|
@@ -79,7 +85,6 @@ class CommandHandler {
|
|
|
79
85
|
}
|
|
80
86
|
|
|
81
87
|
if (args.length === 0) {
|
|
82
|
-
// 显示当前模式
|
|
83
88
|
const text = `🎛️ 当前模式:**${currentMode}**
|
|
84
89
|
|
|
85
90
|
可用模式:
|
|
@@ -95,7 +100,6 @@ class CommandHandler {
|
|
|
95
100
|
return true;
|
|
96
101
|
}
|
|
97
102
|
|
|
98
|
-
// 切换模式
|
|
99
103
|
const newMode = args[0];
|
|
100
104
|
const validModes = ['default', 'yolo', 'plan', 'smart'];
|
|
101
105
|
|
|
@@ -136,7 +140,7 @@ class CommandHandler {
|
|
|
136
140
|
let currentMode = 'default';
|
|
137
141
|
|
|
138
142
|
try {
|
|
139
|
-
const settingsPath = path.join(
|
|
143
|
+
const settingsPath = path.join(getHomeDir(), '.iflow', 'settings.json');
|
|
140
144
|
if (fs.existsSync(settingsPath)) {
|
|
141
145
|
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
142
146
|
modelName = settings.modelName || 'glm-5';
|