foliko 2.0.28 → 2.0.30
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/package.json +1 -1
- package/src/agent/chat.js +160 -221
- package/src/common/constants.js +1 -1
- package/src/llm/tokens.js +75 -102
- package/src/session/session.js +32 -4
- package/src/storage/jsonl.js +5 -3
package/package.json
CHANGED
package/src/agent/chat.js
CHANGED
|
@@ -112,6 +112,9 @@ class AgentChatHandler extends EventEmitter {
|
|
|
112
112
|
this._toolsTokensCacheVersion = 0;
|
|
113
113
|
this._systemPromptTokensCache = null;
|
|
114
114
|
this._systemPromptTokensCacheKey = null;
|
|
115
|
+
// 消息 token 缓存(避免重复计算同一条消息)
|
|
116
|
+
this._messageTokenCache = new Map();
|
|
117
|
+
this._messageTokenCacheMaxSize = 200;
|
|
115
118
|
|
|
116
119
|
// ChatQueueManager: 队列管理
|
|
117
120
|
this.queueManager = new ChatQueueManager({
|
|
@@ -718,31 +721,44 @@ class AgentChatHandler extends EventEmitter {
|
|
|
718
721
|
async *chatStream(message, options = {}) {
|
|
719
722
|
const sessionId = options.sessionId || this.getDefaultSessionId();
|
|
720
723
|
const framework = this.agent.framework;
|
|
724
|
+
// ★ 提到外层作用域,catch 中要回滚 userMessage 时需要访问
|
|
721
725
|
let messageStore = null;
|
|
722
726
|
let messages = null;
|
|
723
727
|
let userMessage = null;
|
|
724
|
-
let didRetryWithCompression = false;
|
|
725
728
|
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
+
try {
|
|
730
|
+
const prepared = await this._prepareSession(message, sessionId);
|
|
731
|
+
messageStore = prepared.messageStore;
|
|
732
|
+
messages = prepared.messages;
|
|
733
|
+
userMessage = prepared.userMessage;
|
|
734
|
+
|
|
735
|
+
if (!this._aiClient) {
|
|
736
|
+
throw new Error('AI 客户端未配置');
|
|
737
|
+
}
|
|
738
|
+
const systemPrompt = framework.prompts.build();
|
|
739
|
+
//await fs.promises.writeFile(`.${sessionId}_systemPrompt.md`, systemPrompt); // 调试用:保存系统提示词
|
|
740
|
+
const tools = this._getToolMap();
|
|
729
741
|
const toolLoop = new ToolLoop({
|
|
730
742
|
tools,
|
|
731
|
-
maxSteps:
|
|
732
|
-
prepareStep:
|
|
733
|
-
repairToolCall:
|
|
743
|
+
maxSteps: this._maxSteps || 1000,
|
|
744
|
+
prepareStep: this._createPrepareStep(),
|
|
745
|
+
repairToolCall: this._repairToolCall.bind(this),
|
|
734
746
|
abortSignal: framework.getAbortSignal?.(),
|
|
735
|
-
thinkingMode:
|
|
747
|
+
thinkingMode: this._thinkingMode,
|
|
736
748
|
});
|
|
737
749
|
|
|
750
|
+
// DeepSeek thinking mode: 收集 reasoning_content
|
|
751
|
+
const isThinking = this._thinkingMode;
|
|
738
752
|
let reasoningContent = '';
|
|
753
|
+
|
|
739
754
|
const stream = toolLoop.stream({
|
|
740
755
|
messages,
|
|
741
|
-
systemPrompt
|
|
742
|
-
model:
|
|
743
|
-
client:
|
|
744
|
-
...
|
|
756
|
+
systemPrompt,
|
|
757
|
+
model: this.model,
|
|
758
|
+
client: this._aiClient,
|
|
759
|
+
...this.providerOptions,
|
|
745
760
|
});
|
|
761
|
+
// 遍历流式响应
|
|
746
762
|
let fullText = '';
|
|
747
763
|
let usageRecv = {};
|
|
748
764
|
|
|
@@ -765,94 +781,67 @@ class AgentChatHandler extends EventEmitter {
|
|
|
765
781
|
yield { type: 'usage', inputTokens: usageRecv.inputTokens, outputTokens: usageRecv.outputTokens };
|
|
766
782
|
}
|
|
767
783
|
}
|
|
768
|
-
|
|
769
784
|
// 持久化用量
|
|
770
785
|
messageStore.usage = usageRecv;
|
|
771
|
-
|
|
772
|
-
|
|
786
|
+
|
|
787
|
+
// 同时通过 framework 事件广播(绕开 queue 事件链路)
|
|
788
|
+
if (this.agent && this.agent.framework) {
|
|
789
|
+
this.agent.framework.emit('agent:usage', {
|
|
790
|
+
sessionId,
|
|
791
|
+
usage: usageRecv,
|
|
792
|
+
});
|
|
773
793
|
}
|
|
774
794
|
|
|
795
|
+
// yield usage 数据,让 UI 层能直接获取
|
|
796
|
+
// (usage 已在 stream 循环中 yield,无需重复)
|
|
797
|
+
|
|
775
798
|
const userMsg = messages.length > 0 ? messages[messages.length - 1] : null;
|
|
776
|
-
|
|
799
|
+
this.emit('message', { content: fullText, sessionId: sessionId, userMessage: userMsg });
|
|
777
800
|
|
|
801
|
+
// ★ 修复:成功路径才持久化。AI 失败时若 finally 也 save,
|
|
802
|
+
// 会把刚 push 的 userMessage 写盘 → 下次请求 loadHistory 再次
|
|
803
|
+
// load 到,_prepareSession 再 push 一份 → 连续两条相同 user
|
|
804
|
+
// → AI SDK 抛 "Invalid prompt: ModelMessage[] schema"
|
|
778
805
|
try {
|
|
779
806
|
await messageStore.save();
|
|
780
807
|
} catch (err) {
|
|
781
808
|
logger.error(`[${sessionId}] Failed to save message store: ${err.message}`);
|
|
782
809
|
}
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
if (
|
|
790
|
-
const
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
} else {
|
|
795
|
-
// 重试前重新 build 系统提示词
|
|
796
|
-
this._systemPrompt = this.agent._buildSystemPrompt();
|
|
797
|
-
this._invalidateMessageTokensCache(messageStore);
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
if (!this._aiClient) {
|
|
801
|
-
throw new Error('AI 客户端未配置');
|
|
802
|
-
}
|
|
803
|
-
|
|
804
|
-
// 执行流式生成
|
|
805
|
-
for await (const chunk of doStream(this, sessionId, message, messageStore, messages, userMessage)) {
|
|
806
|
-
yield chunk;
|
|
807
|
-
}
|
|
808
|
-
return; // 成功完成
|
|
809
|
-
} catch (err) {
|
|
810
|
-
// ★ 上下文超限:压缩后重试一次
|
|
811
|
-
if (!didRetryWithCompression && this._isContextOverflowError(err)) {
|
|
812
|
-
logger.warn(`[AgentChat] chatStream context overflow (attempt ${attempt + 1}), compressing and retrying...`);
|
|
813
|
-
try {
|
|
814
|
-
await this._compressContext(sessionId, messages, messageStore);
|
|
815
|
-
this._invalidateMessageTokensCache(messageStore);
|
|
816
|
-
logger.info(`[AgentChat] Compression done, retrying stream...`);
|
|
817
|
-
didRetryWithCompression = true;
|
|
818
|
-
continue; // 重试
|
|
819
|
-
} catch (compressErr) {
|
|
820
|
-
logger.error(`[AgentChat] Compression failed: ${compressErr.message}`);
|
|
810
|
+
} catch (err) {
|
|
811
|
+
// ★ 修复:失败时打印 InvalidPromptError 的 cause(含 Zod issue 路径),
|
|
812
|
+
// 方便定位是哪条消息、哪个字段不匹配 ModelMessage[] schema
|
|
813
|
+
logger.error('[AgentChat] chatStream error:', err.message);
|
|
814
|
+
if (err.cause) {
|
|
815
|
+
const cause = err.cause;
|
|
816
|
+
if (cause.issues) {
|
|
817
|
+
for (const issue of cause.issues.slice(0, 5)) {
|
|
818
|
+
logger.error(
|
|
819
|
+
`[AgentChat] schema issue: path=${JSON.stringify(issue.path)} code=${issue.code} message=${issue.message}`
|
|
820
|
+
);
|
|
821
821
|
}
|
|
822
|
+
} else {
|
|
823
|
+
logger.error(`[AgentChat] error cause: ${cause.message || String(cause)}`);
|
|
822
824
|
}
|
|
825
|
+
}
|
|
823
826
|
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
if (
|
|
827
|
-
const
|
|
828
|
-
if (
|
|
829
|
-
|
|
830
|
-
logger.error(
|
|
831
|
-
`[AgentChat] schema issue: path=${JSON.stringify(issue.path)} code=${issue.code} message=${issue.message}`
|
|
832
|
-
);
|
|
833
|
-
}
|
|
834
|
-
} else {
|
|
835
|
-
logger.error(`[AgentChat] error cause: ${cause.message || String(cause)}`);
|
|
827
|
+
// ★ 修复:失败时 ROLLBACK 掉本轮 push 的 userMessage
|
|
828
|
+
try {
|
|
829
|
+
if (messages && userMessage) {
|
|
830
|
+
const last = messages[messages.length - 1];
|
|
831
|
+
if (last && last.role === 'user' && last === userMessage) {
|
|
832
|
+
messages.pop();
|
|
836
833
|
}
|
|
837
834
|
}
|
|
835
|
+
} catch { /* ignore */ }
|
|
838
836
|
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
if (messages && userMessage) {
|
|
842
|
-
const last = messages[messages.length - 1];
|
|
843
|
-
if (last && last.role === 'user' && last === userMessage) {
|
|
844
|
-
messages.pop();
|
|
845
|
-
}
|
|
846
|
-
}
|
|
847
|
-
} catch { /* ignore */ }
|
|
837
|
+
// 统一错误处理:提取简洁消息并通过 yield 传递
|
|
838
|
+
const friendlyMessage = err?.message?.split('\n')[0] || '未知错误';
|
|
848
839
|
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
}
|
|
853
|
-
yield { type: 'error', error: friendlyMessage };
|
|
854
|
-
return; // 失败返回
|
|
840
|
+
// 用户主动中断(framework.stop())不上报 message:error,避免与上游重复
|
|
841
|
+
if (err.name !== 'AbortError') {
|
|
842
|
+
this.emit('message:error', { sessionId, error: friendlyMessage, originalError: err });
|
|
855
843
|
}
|
|
844
|
+
yield { type: 'error', error: friendlyMessage };
|
|
856
845
|
}
|
|
857
846
|
}
|
|
858
847
|
|
|
@@ -905,132 +894,109 @@ class AgentChatHandler extends EventEmitter {
|
|
|
905
894
|
let messageStore = null;
|
|
906
895
|
let messages = null;
|
|
907
896
|
let userMessage = null;
|
|
908
|
-
let didRetryWithCompression = false;
|
|
909
|
-
|
|
910
|
-
// 最多压缩重试 1 次
|
|
911
|
-
const maxCompressionRetries = 1;
|
|
912
|
-
|
|
913
|
-
for (let attempt = 0; attempt <= maxCompressionRetries; attempt++) {
|
|
914
|
-
try {
|
|
915
|
-
// 首次尝试前做 prepareSession;压缩重试时跳过(已压缩过)
|
|
916
|
-
if (attempt === 0) {
|
|
917
|
-
const prepared = await this._prepareSession(message, sessionId);
|
|
918
|
-
messageStore = prepared.messageStore;
|
|
919
|
-
messages = prepared.messages;
|
|
920
|
-
userMessage = prepared.userMessage;
|
|
921
|
-
} else {
|
|
922
|
-
// 压缩重试前重新 build 系统提示词(压缩后 messages 变了)
|
|
923
|
-
this._systemPrompt = this.agent._buildSystemPrompt();
|
|
924
|
-
this._invalidateMessageTokensCache(messageStore);
|
|
925
|
-
}
|
|
926
|
-
|
|
927
|
-
if (!this._aiClient) {
|
|
928
|
-
throw new Error('AI 客户端未配置');
|
|
929
|
-
}
|
|
930
|
-
const systemPrompt = framework.prompts.build();
|
|
931
|
-
const tools = this._getToolMap();
|
|
932
897
|
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
898
|
+
try {
|
|
899
|
+
const prepared = await this._prepareSession(message, sessionId);
|
|
900
|
+
messageStore = prepared.messageStore;
|
|
901
|
+
messages = prepared.messages;
|
|
902
|
+
userMessage = prepared.userMessage;
|
|
938
903
|
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
abortSignal: framework.getAbortSignal?.(),
|
|
945
|
-
thinkingMode: this._thinkingMode,
|
|
946
|
-
});
|
|
904
|
+
if (!this._aiClient) {
|
|
905
|
+
throw new Error('AI 客户端未配置');
|
|
906
|
+
}
|
|
907
|
+
const systemPrompt = framework.prompts.build();
|
|
908
|
+
const tools = this._getToolMap();
|
|
947
909
|
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
...apiOptions,
|
|
954
|
-
});
|
|
955
|
-
// result = { text, response: { messages }, steps, usage, reasoningText }
|
|
910
|
+
// DeepSeek thinking mode: 处理参数
|
|
911
|
+
const apiOptions = { ...this.providerOptions };
|
|
912
|
+
if (this._thinkingMode) {
|
|
913
|
+
delete apiOptions.temperature;
|
|
914
|
+
}
|
|
956
915
|
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
916
|
+
const toolLoop = new ToolLoop({
|
|
917
|
+
tools,
|
|
918
|
+
maxSteps: this._maxSteps || 1000,
|
|
919
|
+
prepareStep: this._createPrepareStep(),
|
|
920
|
+
repairToolCall: this._repairToolCall.bind(this),
|
|
921
|
+
abortSignal: framework.getAbortSignal?.(),
|
|
922
|
+
thinkingMode: this._thinkingMode,
|
|
923
|
+
});
|
|
964
924
|
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
925
|
+
const result = await toolLoop.generate({
|
|
926
|
+
messages,
|
|
927
|
+
systemPrompt,
|
|
928
|
+
model: this.model,
|
|
929
|
+
client: this._aiClient,
|
|
930
|
+
...apiOptions,
|
|
931
|
+
});
|
|
932
|
+
// result = { text, response: { messages }, steps, usage, reasoningText }
|
|
971
933
|
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
}
|
|
934
|
+
if (result.usage && (result.usage.promptTokens > 0 || result.usage.completionTokens > 0)) {
|
|
935
|
+
this._updateMessageStoreUsage(messageStore, result.usage);
|
|
936
|
+
} else {
|
|
937
|
+
const inputTokens = this._tokenCounter.countMessages(messages);
|
|
938
|
+
const outputTokens = this._tokenCounter.countText(result.text || '');
|
|
939
|
+
messageStore.usage = { promptTokens: inputTokens, completionTokens: outputTokens };
|
|
940
|
+
}
|
|
978
941
|
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
942
|
+
// ToolLoop 已经直接在 messages 末尾添加了新消息,不需要再 push
|
|
943
|
+
// 计算 original 的 user message
|
|
944
|
+
const userMsg = result.text
|
|
945
|
+
? messages.slice(-1)[0]
|
|
946
|
+
: messages[messages.length - 1];
|
|
947
|
+
this.emit('message', { content: result.text, sessionId: sessionId, userMessage: userMsg });
|
|
948
|
+
|
|
949
|
+
// ★ 修复:AI 调用成功后才持久化(之前 finally 总是 save,
|
|
950
|
+
// 会把刚 push 的 userMessage 写盘;下次请求 loadHistory 再次 load 到,
|
|
951
|
+
// _prepareSession 又 push 一份 → 连续两条相同 user 消息 →
|
|
952
|
+
// AI SDK 抛 "Invalid prompt: ModelMessage[] schema")
|
|
953
|
+
try {
|
|
954
|
+
await messageStore.save();
|
|
984
955
|
} catch (err) {
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
956
|
+
logger.error(`[${sessionId}] Failed to save message store: ${err.message}`);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
return {
|
|
960
|
+
success: true,
|
|
961
|
+
message: cleanResponse(result.text || ''),
|
|
962
|
+
stepCount: result.stepCount || 1,
|
|
963
|
+
};
|
|
964
|
+
} catch (err) {
|
|
965
|
+
// ★ 修复:失败时打印 InvalidPromptError 的 cause(含 Zod issue 路径),
|
|
966
|
+
// 方便定位是哪条消息、哪个字段不匹配 ModelMessage[] schema
|
|
967
|
+
logger.error('[AgentChat] _processMessage error:', err.message);
|
|
968
|
+
if (err.cause) {
|
|
969
|
+
const cause = err.cause;
|
|
970
|
+
if (cause.issues) {
|
|
971
|
+
// ZodError:逐条打印
|
|
972
|
+
for (const issue of cause.issues.slice(0, 5)) {
|
|
973
|
+
logger.error(
|
|
974
|
+
`[AgentChat] schema issue: path=${JSON.stringify(issue.path)} code=${issue.code} message=${issue.message}`
|
|
975
|
+
);
|
|
998
976
|
}
|
|
977
|
+
} else {
|
|
978
|
+
logger.error(`[AgentChat] error cause: ${cause.message || String(cause)}`);
|
|
999
979
|
}
|
|
980
|
+
}
|
|
981
|
+
const errorMsg = err.message || String(err);
|
|
1000
982
|
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
`[AgentChat] schema issue: path=${JSON.stringify(issue.path)} code=${issue.code} message=${issue.message}`
|
|
1009
|
-
);
|
|
1010
|
-
}
|
|
1011
|
-
} else {
|
|
1012
|
-
logger.error(`[AgentChat] error cause: ${cause.message || String(cause)}`);
|
|
983
|
+
// ★ 修复:失败时 ROLLBACK 掉本轮 push 的 userMessage,
|
|
984
|
+
// 避免下次请求把它从 jsonl load 出来再 push 一遍
|
|
985
|
+
try {
|
|
986
|
+
if (messages && userMessage) {
|
|
987
|
+
const last = messages[messages.length - 1];
|
|
988
|
+
if (last && last.role === 'user' && last === userMessage) {
|
|
989
|
+
messages.pop();
|
|
1013
990
|
}
|
|
1014
991
|
}
|
|
1015
|
-
|
|
992
|
+
} catch { /* ignore */ }
|
|
1016
993
|
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
}
|
|
1024
|
-
}
|
|
1025
|
-
} catch { /* ignore */ }
|
|
1026
|
-
|
|
1027
|
-
return {
|
|
1028
|
-
success: false,
|
|
1029
|
-
message: 'AI 服务暂时不可用,请稍后重试。',
|
|
1030
|
-
error: errorMsg,
|
|
1031
|
-
stepCount: 0,
|
|
1032
|
-
};
|
|
1033
|
-
}
|
|
994
|
+
return {
|
|
995
|
+
success: false,
|
|
996
|
+
message: 'AI 服务暂时不可用,请稍后重试。',
|
|
997
|
+
error: errorMsg,
|
|
998
|
+
stepCount: 0,
|
|
999
|
+
};
|
|
1034
1000
|
}
|
|
1035
1001
|
}
|
|
1036
1002
|
|
|
@@ -1065,7 +1031,7 @@ class AgentChatHandler extends EventEmitter {
|
|
|
1065
1031
|
const totalTokens = cachedMsgTokens + toolsTokens + systemPromptTokens;
|
|
1066
1032
|
const limit = this._maxContextTokens * SIMPLE_COMPRESS_TOKEN_RATIO;
|
|
1067
1033
|
|
|
1068
|
-
|
|
1034
|
+
//gger.info(`[_prepareSession] BEFORE: messages=${messages.length}, tokens=${totalTokens}/${limit}, threshold=${this._compressionMessageThreshold}`);
|
|
1069
1035
|
|
|
1070
1036
|
if (totalTokens > limit || messages.length > this._compressionMessageThreshold) {
|
|
1071
1037
|
logger.info(
|
|
@@ -1537,33 +1503,6 @@ class AgentChatHandler extends EventEmitter {
|
|
|
1537
1503
|
return this._tokenCounter.countTools(this._toolExecutor.getAllTools());
|
|
1538
1504
|
}
|
|
1539
1505
|
|
|
1540
|
-
/**
|
|
1541
|
-
* 判断是否为上下文超限错误
|
|
1542
|
-
* @param {Error} err
|
|
1543
|
-
* @returns {boolean}
|
|
1544
|
-
* @private
|
|
1545
|
-
*/
|
|
1546
|
-
_isContextOverflowError(err) {
|
|
1547
|
-
if (!err) return false;
|
|
1548
|
-
const msg = err.message || '';
|
|
1549
|
-
const lowerMsg = msg.toLowerCase();
|
|
1550
|
-
// DeepSeek / OpenAI 兼容的上下文超限错误关键词
|
|
1551
|
-
const keywords = [
|
|
1552
|
-
'context_length',
|
|
1553
|
-
'context length',
|
|
1554
|
-
'maximum context',
|
|
1555
|
-
'max context',
|
|
1556
|
-
'too many tokens',
|
|
1557
|
-
'token limit',
|
|
1558
|
-
'tokens exceed',
|
|
1559
|
-
'exceed maximum',
|
|
1560
|
-
'maximum tokens',
|
|
1561
|
-
'context_exceeded',
|
|
1562
|
-
'context overflow',
|
|
1563
|
-
];
|
|
1564
|
-
return keywords.some(k => lowerMsg.includes(k));
|
|
1565
|
-
}
|
|
1566
|
-
|
|
1567
1506
|
/**
|
|
1568
1507
|
* 获取缓存的工具 token 数(工具列表不变时复用)
|
|
1569
1508
|
* @private
|
package/src/common/constants.js
CHANGED
package/src/llm/tokens.js
CHANGED
|
@@ -5,70 +5,17 @@
|
|
|
5
5
|
* Ported from pi's compaction/compaction.ts with enhancements
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
//
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
[0x1100, 0x11ff], // Hangul Jamo
|
|
17
|
-
[0x3000, 0x303f], // CJK Symbols and Punctuation
|
|
18
|
-
[0xff00, 0xffef], // Halfwidth and Fullwidth Forms
|
|
19
|
-
];
|
|
20
|
-
|
|
21
|
-
function isCJKChar(char) {
|
|
22
|
-
const code = char.codePointAt(0);
|
|
23
|
-
if (!code) return false;
|
|
24
|
-
for (const [start, end] of CJK_RANGES) {
|
|
25
|
-
if (code >= start && code <= end) return true;
|
|
26
|
-
}
|
|
27
|
-
return false;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* 改进的 Token 估算
|
|
32
|
-
* 根据文本语言类型采用不同估算系数
|
|
33
|
-
*
|
|
34
|
-
* @param {string} text - 输入文本
|
|
35
|
-
* @returns {number} 估算的 token 数
|
|
36
|
-
*/
|
|
37
|
-
function encode(text) {
|
|
38
|
-
if (!text || typeof text !== 'string') return 0;
|
|
39
|
-
|
|
40
|
-
let cjkChars = 0;
|
|
41
|
-
let asciiChars = 0;
|
|
42
|
-
let otherChars = 0;
|
|
43
|
-
|
|
44
|
-
// 逐字符统计
|
|
45
|
-
for (const char of text) {
|
|
46
|
-
if (isCJKChar(char)) {
|
|
47
|
-
cjkChars++;
|
|
48
|
-
} else if (char.charCodeAt(0) < 128) {
|
|
49
|
-
// ASCII 字符 (英文、数字、标点)
|
|
50
|
-
asciiChars++;
|
|
51
|
-
} else {
|
|
52
|
-
otherChars++;
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// 更精确的估算系数 (基于 tiktoken 统计数据):
|
|
57
|
-
// - ASCII/英文: ~0.25 tokens/char (4 chars per token)
|
|
58
|
-
// - 中文: ~0.5 tokens/char (2 chars per token, 中文字符是 3 字节 UTF-8 但 tokenizer 输出约 0.5)
|
|
59
|
-
// - 其他 (emoji, 特殊符号): ~0.25 tokens/char
|
|
60
|
-
const asciiTokens = asciiChars * 0.25;
|
|
61
|
-
const cjkTokens = cjkChars * 0.5;
|
|
62
|
-
const otherTokens = otherChars * 0.25;
|
|
63
|
-
|
|
64
|
-
return Math.ceil(asciiTokens + cjkTokens + otherTokens);
|
|
8
|
+
// 全局 token 缓存(避免重复计算相同内容)
|
|
9
|
+
const _tokenCache = new Map();
|
|
10
|
+
const _TOKEN_CACHE_MAX_SIZE = 500;
|
|
11
|
+
|
|
12
|
+
function _getCacheKey(message) {
|
|
13
|
+
if (message.id) return message.id;
|
|
14
|
+
if (message.role === 'tool') return `tool:${message.tool_call_id || message.toolName}`;
|
|
15
|
+
return `${message.role}:${JSON.stringify(message.content || '').slice(0, 100)}`;
|
|
65
16
|
}
|
|
66
17
|
|
|
67
|
-
|
|
68
|
-
* 旧版 encode(兼容)
|
|
69
|
-
* @deprecated 使用新版 encode 替代
|
|
70
|
-
*/
|
|
71
|
-
function encodeOld(text, bytesPerToken = 4) {
|
|
18
|
+
function encode(text, bytesPerToken = 4) {
|
|
72
19
|
if (!text) return 0;
|
|
73
20
|
const bytes = Buffer.byteLength(String(text), 'utf8');
|
|
74
21
|
return Math.ceil(bytes / bytesPerToken);
|
|
@@ -88,66 +35,92 @@ function safeJsonStringify(value) {
|
|
|
88
35
|
}
|
|
89
36
|
}
|
|
90
37
|
|
|
91
|
-
/**
|
|
92
|
-
* 估算单条消息的 token 数(使用改进的编码方法)
|
|
93
|
-
*/
|
|
94
38
|
function estimateTokens(message) {
|
|
95
|
-
|
|
39
|
+
// 性能优化:使用缓存避免重复计算
|
|
40
|
+
const cacheKey = _getCacheKey(message);
|
|
41
|
+
if (_tokenCache.has(cacheKey)) {
|
|
42
|
+
return _tokenCache.get(cacheKey);
|
|
43
|
+
}
|
|
96
44
|
|
|
97
|
-
let
|
|
45
|
+
let chars = 0;
|
|
98
46
|
|
|
99
47
|
switch (message.role) {
|
|
100
|
-
case 'user':
|
|
101
|
-
case 'assistant':
|
|
102
|
-
case 'custom':
|
|
103
|
-
case 'toolResult': {
|
|
48
|
+
case 'user': {
|
|
104
49
|
const content = message.content;
|
|
105
50
|
if (typeof content === 'string') {
|
|
106
|
-
|
|
51
|
+
chars = content.length;
|
|
107
52
|
} else if (Array.isArray(content)) {
|
|
108
53
|
for (const block of content) {
|
|
109
54
|
if (block.type === 'text' && block.text) {
|
|
110
|
-
|
|
111
|
-
} else if (block.type === 'tool-call' || block.type === 'toolCall') {
|
|
112
|
-
total += encode(block.name || '') + encode(safeJsonStringify(block.arguments || {}));
|
|
113
|
-
} else if (block.type === 'tool-result' || block.type === 'toolResult') {
|
|
114
|
-
total += encode(typeof block.content === 'string' ? block.content : safeJsonStringify(block.content));
|
|
115
|
-
} else if (block.type === 'image') {
|
|
116
|
-
total += 85;
|
|
117
|
-
} else if (block.type === 'thinking') {
|
|
118
|
-
total += encode(block.thinking || '');
|
|
55
|
+
chars += block.text.length;
|
|
119
56
|
}
|
|
120
57
|
}
|
|
121
58
|
}
|
|
122
|
-
|
|
59
|
+
const result = Math.ceil(chars / 4);
|
|
60
|
+
_updateCache(cacheKey, result);
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
case 'assistant': {
|
|
64
|
+
const assistant = message;
|
|
65
|
+
for (const block of assistant.content || []) {
|
|
66
|
+
if (block.type === 'text') {
|
|
67
|
+
chars += block.text.length;
|
|
68
|
+
} else if (block.type === 'thinking') {
|
|
69
|
+
chars += block.thinking.length;
|
|
70
|
+
} else if (block.type === 'toolCall') {
|
|
71
|
+
chars += block.name.length + safeJsonStringify(block.arguments).length;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const result = Math.ceil(chars / 4);
|
|
75
|
+
_updateCache(cacheKey, result);
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
78
|
+
case 'custom':
|
|
79
|
+
case 'toolResult': {
|
|
80
|
+
if (typeof message.content === 'string') {
|
|
81
|
+
chars = message.content.length;
|
|
82
|
+
} else {
|
|
83
|
+
for (const block of message.content || []) {
|
|
84
|
+
if (block.type === 'text' && block.text) {
|
|
85
|
+
chars += block.text.length;
|
|
86
|
+
}
|
|
87
|
+
if (block.type === 'image') {
|
|
88
|
+
chars += 4800;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const result = Math.ceil(chars / 4);
|
|
93
|
+
_updateCache(cacheKey, result);
|
|
94
|
+
return result;
|
|
123
95
|
}
|
|
124
96
|
case 'bashExecution': {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
97
|
+
chars = message.command.length + (message.output?.length || 0);
|
|
98
|
+
const result = Math.ceil(chars / 4);
|
|
99
|
+
_updateCache(cacheKey, result);
|
|
100
|
+
return result;
|
|
128
101
|
}
|
|
129
102
|
case 'branchSummary':
|
|
130
103
|
case 'compactionSummary': {
|
|
131
|
-
|
|
132
|
-
|
|
104
|
+
chars = message.summary.length;
|
|
105
|
+
const result = Math.ceil(chars / 4);
|
|
106
|
+
_updateCache(cacheKey, result);
|
|
107
|
+
return result;
|
|
133
108
|
}
|
|
134
109
|
}
|
|
135
110
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
total += 15; // tool_calls overhead
|
|
139
|
-
for (const tc of message.tool_calls) {
|
|
140
|
-
if (tc.function) {
|
|
141
|
-
total += encode(tc.function.name || '') + encode(safeJsonStringify(tc.function.arguments || {}));
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
}
|
|
111
|
+
return 0;
|
|
112
|
+
}
|
|
145
113
|
|
|
146
|
-
|
|
147
|
-
|
|
114
|
+
function _updateCache(key, value) {
|
|
115
|
+
if (_tokenCache.size >= _TOKEN_CACHE_MAX_SIZE) {
|
|
116
|
+
// 删除最旧的 20%
|
|
117
|
+
const keysToDelete = Math.floor(_TOKEN_CACHE_MAX_SIZE * 0.2);
|
|
118
|
+
const iterator = _tokenCache.keys();
|
|
119
|
+
for (let i = 0; i < keysToDelete; i++) {
|
|
120
|
+
_tokenCache.delete(iterator.next().value);
|
|
121
|
+
}
|
|
148
122
|
}
|
|
149
|
-
|
|
150
|
-
return total;
|
|
123
|
+
_tokenCache.set(key, value);
|
|
151
124
|
}
|
|
152
125
|
|
|
153
126
|
function calculateContextTokens(usage) {
|
|
@@ -222,8 +195,8 @@ class TokenCounter {
|
|
|
222
195
|
this.toolSchema = config.toolSchema || null;
|
|
223
196
|
}
|
|
224
197
|
|
|
225
|
-
countText(text) {
|
|
226
|
-
return encode(text);
|
|
198
|
+
countText(text, bytesPerToken = 4) {
|
|
199
|
+
return encode(text, bytesPerToken);
|
|
227
200
|
}
|
|
228
201
|
|
|
229
202
|
countMessages(messages) {
|
package/src/session/session.js
CHANGED
|
@@ -123,6 +123,11 @@ class SessionManager {
|
|
|
123
123
|
_loadEntriesFromFile(filePath) {
|
|
124
124
|
if (!fs.existsSync(filePath)) return [];
|
|
125
125
|
|
|
126
|
+
// 性能优化:使用流式读取大文件,减少内存占用
|
|
127
|
+
const stats = fs.statSync(filePath);
|
|
128
|
+
if (stats.size > 5 * 1024 * 1024) { // > 5MB 使用流
|
|
129
|
+
return this._loadEntriesFromFileStream(filePath);
|
|
130
|
+
}
|
|
126
131
|
const content = fs.readFileSync(filePath, 'utf8');
|
|
127
132
|
const entries = [];
|
|
128
133
|
const lines = content.trim().split('\n');
|
|
@@ -152,6 +157,28 @@ class SessionManager {
|
|
|
152
157
|
return entries;
|
|
153
158
|
}
|
|
154
159
|
|
|
160
|
+
async _loadEntriesFromFileStream(filePath) {
|
|
161
|
+
// 性能优化:流式读取大文件
|
|
162
|
+
const entries = [];
|
|
163
|
+
const seenIds = new Set();
|
|
164
|
+
const readline = require('readline');
|
|
165
|
+
const rl = readline.createInterface({
|
|
166
|
+
input: fs.createReadStream(filePath),
|
|
167
|
+
crlfDelay: Infinity
|
|
168
|
+
});
|
|
169
|
+
for await (const line of rl) {
|
|
170
|
+
if (!line.trim()) continue;
|
|
171
|
+
try {
|
|
172
|
+
const entry = JSON.parse(line);
|
|
173
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
174
|
+
if (entry.id && seenIds.has(entry.id)) continue;
|
|
175
|
+
if (entry.id) seenIds.add(entry.id);
|
|
176
|
+
entries.push(entry);
|
|
177
|
+
} catch { /* skip */ }
|
|
178
|
+
}
|
|
179
|
+
return entries;
|
|
180
|
+
}
|
|
181
|
+
|
|
155
182
|
_buildIndex() {
|
|
156
183
|
this.byId.clear();
|
|
157
184
|
this.labelsById.clear();
|
|
@@ -175,7 +202,8 @@ class SessionManager {
|
|
|
175
202
|
|
|
176
203
|
_rewriteFile() {
|
|
177
204
|
if (!this.persist || !this.sessionFile) return;
|
|
178
|
-
|
|
205
|
+
// 性能优化:单次写入代替多次追加
|
|
206
|
+
const content = this.fileEntries.map(e => JSON.stringify(e)).join('\n') + '\n';
|
|
179
207
|
fs.writeFileSync(this.sessionFile, content);
|
|
180
208
|
}
|
|
181
209
|
|
|
@@ -190,10 +218,10 @@ class SessionManager {
|
|
|
190
218
|
return;
|
|
191
219
|
}
|
|
192
220
|
|
|
221
|
+
// 性能优化:使用批量写入代替多次追加
|
|
193
222
|
if (!this.flushed) {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
}
|
|
223
|
+
const batch = this.fileEntries.map(e => `${JSON.stringify(e)}\n`).join('');
|
|
224
|
+
fs.writeFileSync(this.sessionFile, batch);
|
|
197
225
|
this.flushed = true;
|
|
198
226
|
} else {
|
|
199
227
|
fs.appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);
|
package/src/storage/jsonl.js
CHANGED
|
@@ -136,7 +136,8 @@ class JsonlSessionStorage {
|
|
|
136
136
|
}
|
|
137
137
|
|
|
138
138
|
static async _loadJsonlStorage(fsModule, filePath) {
|
|
139
|
-
|
|
139
|
+
// 性能优化:使用异步读取,避免阻塞事件循环
|
|
140
|
+
const content = await fsModule.promises.readFile(filePath, 'utf-8');
|
|
140
141
|
const lines = content.split('\n').filter(line => line.trim());
|
|
141
142
|
if (lines.length === 0) {
|
|
142
143
|
throw invalidSession(filePath, 'missing session header');
|
|
@@ -195,7 +196,7 @@ class JsonlSessionStorage {
|
|
|
195
196
|
timestamp: new Date().toISOString(),
|
|
196
197
|
targetId: leafId
|
|
197
198
|
};
|
|
198
|
-
fs.
|
|
199
|
+
await this.fs.promises.appendFile(this.filePath, JSON.stringify(entry) + '\n');
|
|
199
200
|
this.entries.push(entry);
|
|
200
201
|
this.byId.set(entry.id, entry);
|
|
201
202
|
this.currentLeafId = leafId;
|
|
@@ -206,7 +207,8 @@ class JsonlSessionStorage {
|
|
|
206
207
|
}
|
|
207
208
|
|
|
208
209
|
async appendEntry(entry) {
|
|
209
|
-
|
|
210
|
+
// 性能优化:使用异步追加,减少阻塞时间
|
|
211
|
+
await this.fs.promises.appendFile(this.filePath, JSON.stringify(entry) + '\n');
|
|
210
212
|
this.entries.push(entry);
|
|
211
213
|
this.byId.set(entry.id, entry);
|
|
212
214
|
updateLabelCache(this.labelsById, entry);
|