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.
@@ -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
- execSync(`which ${cmd} 2>/dev/null || command -v ${cmd} 2>/dev/null`, { stdio: 'ignore' });
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; // PM2 可选,继续运行
162
+ return true;
161
163
  }
162
164
  }
163
165
 
164
- return true; // PM2 可选
166
+ return true;
165
167
  }
166
168
 
167
- // 配置文件路径
168
- const CONFIG_DIR = path.join(process.env.HOME, '.feishu-config');
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iflow-feishu",
3
- "version": "1.1.3",
3
+ "version": "1.1.5",
4
4
  "description": "iFlow CLI 飞书插件 - 将 iFlow AI 助手接入飞书机器人",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -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, MODEL_MAX_TOKENS } = require('../core/constants');
9
+ const { DEFAULT_MODEL, DEFAULT_MAX_TOKENS, MODEL_CONTEXT_LIMITS, getModelContextLimit } = require('../core/constants');
11
10
 
12
- const CONFIG_DIR = path.join(process.env.HOME, '.feishu-config');
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
- throw new Error('缺少飞书 App ID');
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: process.env.HOME || '/data/data/com.termux/files/home',
108
+ workDir: homeDir,
138
109
  maxTokens: DEFAULT_MAX_TOKENS,
139
- modelMaxTokens: MODEL_MAX_TOKENS
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(process.env.HOME || '/tmp', '.iflow-feishu', 'sessions'),
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
  };
@@ -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
- const MODEL_MAX_TOKENS = {
26
- 'qwen3-coder-plus': 128000,
27
- 'glm-5': 128000,
28
- 'gpt-4': 128000,
29
- 'gpt-4-turbo': 128000,
30
- 'gpt-3.5-turbo': 16384,
31
- 'claude-3': 200000,
32
- 'claude-3-opus': 200000,
33
- 'claude-3-sonnet': 200000,
34
- 'claude-3-haiku': 200000,
35
- 'llama-3': 8192,
36
- 'llama-3-70b': 8192,
37
- 'mixtral': 32768,
38
- 'mistral-large': 32768
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
- // 从 package.json 读取版本号
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
- CARD_UPDATE_INTERVAL,
57
- TIMER_UPDATE_INTERVAL,
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
  };
@@ -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
- child.on('close', (code) => {
83
- clearTimeout(timer);
84
- logger.info(`⏱️ iFlow CLI 完成, 退出码: ${code}`);
85
-
86
- if (code === 0 || stdout) {
87
- resolve({
88
- stdout,
89
- stderr,
90
- success: true
91
- });
92
- } else {
93
- reject(new Error(`iFlow CLI failed: ${stderr || 'Unknown error'}`));
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 { SEARCH_KEYWORDS, DEFAULT_MODEL, DEFAULT_MAX_TOKENS, MODEL_MAX_TOKENS } = require('./constants');
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(process.env.HOME, '.iflow/settings.json');
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
- const modelMaxTokens = this.service.config.iflow.modelMaxTokens || {};
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);
@@ -244,12 +244,10 @@ class FeishuService {
244
244
  let hasDetectedThinking = false;
245
245
  let isInThinkingPhase = false;
246
246
 
247
- // 计算初始上下文百分比
248
- const session = this.sessionManager.get(chatId);
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 currentLength = (stdoutSoFar || '').length;
295
- const percent = this.messageProcessor.calculateContentLeftPercent(chatId, currentLength, modelName);
296
- updater.setPercent(percent);
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 finalExtracted = this.streamHandler.extractResponse(output.stdout || output, chatId, modelName);
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();
@@ -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
- if (execInfo.tokenUsage && execInfo.tokenUsage.total > 0) {
97
- const used = execInfo.tokenUsage.total;
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 计算: 已用=${used}, 剩余=${remaining}, 百分比=${percent}%`);
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(process.env.HOME, '.iflow', 'log');
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 outputTokens = parseInt(tokenMatch[2], 10);
176
- const totalUsed = inputTokens + outputTokens;
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 };
@@ -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(process.env.HOME, '.iflow/settings.json');
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(process.env.HOME, '.iflow/settings.json');
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';