@wu529778790/open-im 0.1.0
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/.env.example +16 -0
- package/LICENSE +21 -0
- package/README.md +51 -0
- package/dist/access/access-control.d.ts +5 -0
- package/dist/access/access-control.js +11 -0
- package/dist/adapters/claude-adapter.d.ts +7 -0
- package/dist/adapters/claude-adapter.js +17 -0
- package/dist/adapters/registry.d.ts +4 -0
- package/dist/adapters/registry.js +11 -0
- package/dist/adapters/tool-adapter.interface.d.ts +35 -0
- package/dist/adapters/tool-adapter.interface.js +4 -0
- package/dist/claude/cli-runner.d.ts +28 -0
- package/dist/claude/cli-runner.js +166 -0
- package/dist/claude/stream-parser.d.ts +17 -0
- package/dist/claude/stream-parser.js +50 -0
- package/dist/claude/types.d.ts +54 -0
- package/dist/claude/types.js +21 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +6 -0
- package/dist/commands/handler.d.ts +30 -0
- package/dist/commands/handler.js +122 -0
- package/dist/config.d.ts +20 -0
- package/dist/config.js +88 -0
- package/dist/constants.d.ts +7 -0
- package/dist/constants.js +15 -0
- package/dist/hook/permission-server.d.ts +4 -0
- package/dist/hook/permission-server.js +12 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +76 -0
- package/dist/logger.d.ts +16 -0
- package/dist/logger.js +65 -0
- package/dist/queue/request-queue.d.ts +6 -0
- package/dist/queue/request-queue.js +43 -0
- package/dist/sanitize.d.ts +1 -0
- package/dist/sanitize.js +11 -0
- package/dist/session/session-manager.d.ts +26 -0
- package/dist/session/session-manager.js +207 -0
- package/dist/setup.d.ts +5 -0
- package/dist/setup.js +97 -0
- package/dist/shared/active-chats.d.ts +4 -0
- package/dist/shared/active-chats.js +55 -0
- package/dist/shared/ai-task.d.ts +42 -0
- package/dist/shared/ai-task.js +167 -0
- package/dist/shared/message-dedup.d.ts +4 -0
- package/dist/shared/message-dedup.js +25 -0
- package/dist/shared/task-cleanup.d.ts +2 -0
- package/dist/shared/task-cleanup.js +19 -0
- package/dist/shared/types.d.ts +9 -0
- package/dist/shared/types.js +1 -0
- package/dist/shared/utils.d.ts +7 -0
- package/dist/shared/utils.js +72 -0
- package/dist/telegram/client.d.ts +6 -0
- package/dist/telegram/client.js +27 -0
- package/dist/telegram/event-handler.d.ts +8 -0
- package/dist/telegram/event-handler.js +174 -0
- package/dist/telegram/message-sender.d.ts +7 -0
- package/dist/telegram/message-sender.js +102 -0
- package/package.json +52 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
2
|
+
import { realpath } from 'node:fs/promises';
|
|
3
|
+
import { randomBytes } from 'node:crypto';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { createLogger } from '../logger.js';
|
|
6
|
+
import { APP_HOME } from '../constants.js';
|
|
7
|
+
const log = createLogger('Session');
|
|
8
|
+
const SESSIONS_FILE = join(APP_HOME, 'data', 'sessions.json');
|
|
9
|
+
export class SessionManager {
|
|
10
|
+
sessions = new Map();
|
|
11
|
+
convSessionMap = new Map();
|
|
12
|
+
defaultWorkDir;
|
|
13
|
+
allowedBaseDirs;
|
|
14
|
+
saveTimer = null;
|
|
15
|
+
constructor(defaultWorkDir, allowedBaseDirs) {
|
|
16
|
+
this.defaultWorkDir = defaultWorkDir;
|
|
17
|
+
this.allowedBaseDirs = allowedBaseDirs;
|
|
18
|
+
this.load();
|
|
19
|
+
}
|
|
20
|
+
getSessionIdForConv(userId, convId) {
|
|
21
|
+
const s = this.sessions.get(userId);
|
|
22
|
+
if (s?.activeConvId === convId)
|
|
23
|
+
return s.sessionId;
|
|
24
|
+
return this.convSessionMap.get(`${userId}:${convId}`);
|
|
25
|
+
}
|
|
26
|
+
setSessionIdForConv(userId, convId, sessionId) {
|
|
27
|
+
const s = this.sessions.get(userId);
|
|
28
|
+
if (s?.activeConvId === convId) {
|
|
29
|
+
s.sessionId = sessionId;
|
|
30
|
+
this.save();
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
this.convSessionMap.set(`${userId}:${convId}`, sessionId);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
getSessionIdForThread(_userId, _threadId) {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
setSessionIdForThread(userId, threadId, sessionId) {
|
|
40
|
+
const s = this.sessions.get(userId);
|
|
41
|
+
if (s && !s.threads)
|
|
42
|
+
s.threads = {};
|
|
43
|
+
const t = s?.threads?.[threadId];
|
|
44
|
+
if (t) {
|
|
45
|
+
t.sessionId = sessionId;
|
|
46
|
+
this.save();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
getWorkDir(userId) {
|
|
50
|
+
return this.sessions.get(userId)?.workDir ?? this.defaultWorkDir;
|
|
51
|
+
}
|
|
52
|
+
getConvId(userId) {
|
|
53
|
+
const s = this.sessions.get(userId);
|
|
54
|
+
if (s) {
|
|
55
|
+
if (!s.activeConvId) {
|
|
56
|
+
s.activeConvId = randomBytes(4).toString('hex');
|
|
57
|
+
this.save();
|
|
58
|
+
}
|
|
59
|
+
return s.activeConvId;
|
|
60
|
+
}
|
|
61
|
+
const convId = randomBytes(4).toString('hex');
|
|
62
|
+
this.sessions.set(userId, { workDir: this.defaultWorkDir, activeConvId: convId });
|
|
63
|
+
this.save();
|
|
64
|
+
return convId;
|
|
65
|
+
}
|
|
66
|
+
async setWorkDir(userId, workDir) {
|
|
67
|
+
const currentDir = this.getWorkDir(userId);
|
|
68
|
+
const realPath = await this.resolveAndValidate(currentDir, workDir);
|
|
69
|
+
const s = this.sessions.get(userId);
|
|
70
|
+
if (s) {
|
|
71
|
+
if (s.activeConvId && s.sessionId) {
|
|
72
|
+
this.convSessionMap.set(`${userId}:${s.activeConvId}`, s.sessionId);
|
|
73
|
+
}
|
|
74
|
+
s.workDir = realPath;
|
|
75
|
+
s.sessionId = undefined;
|
|
76
|
+
s.activeConvId = randomBytes(4).toString('hex');
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
this.sessions.set(userId, {
|
|
80
|
+
workDir: realPath,
|
|
81
|
+
activeConvId: randomBytes(4).toString('hex'),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
this.flushSync();
|
|
85
|
+
log.info(`WorkDir changed for user ${userId}: ${realPath}`);
|
|
86
|
+
return realPath;
|
|
87
|
+
}
|
|
88
|
+
newSession(userId) {
|
|
89
|
+
const s = this.sessions.get(userId);
|
|
90
|
+
if (s) {
|
|
91
|
+
if (s.activeConvId && s.sessionId) {
|
|
92
|
+
this.convSessionMap.set(`${userId}:${s.activeConvId}`, s.sessionId);
|
|
93
|
+
}
|
|
94
|
+
s.sessionId = undefined;
|
|
95
|
+
s.activeConvId = randomBytes(4).toString('hex');
|
|
96
|
+
s.totalTurns = 0;
|
|
97
|
+
this.flushSync();
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
addTurns(userId, turns) {
|
|
103
|
+
const s = this.sessions.get(userId);
|
|
104
|
+
if (!s)
|
|
105
|
+
return 0;
|
|
106
|
+
s.totalTurns = (s.totalTurns ?? 0) + turns;
|
|
107
|
+
this.save();
|
|
108
|
+
return s.totalTurns;
|
|
109
|
+
}
|
|
110
|
+
addTurnsForThread(userId, threadId, turns) {
|
|
111
|
+
const s = this.sessions.get(userId);
|
|
112
|
+
const t = s?.threads?.[threadId];
|
|
113
|
+
if (!t)
|
|
114
|
+
return 0;
|
|
115
|
+
t.totalTurns = (t.totalTurns ?? 0) + turns;
|
|
116
|
+
this.save();
|
|
117
|
+
return t.totalTurns;
|
|
118
|
+
}
|
|
119
|
+
getModel(userId, threadId) {
|
|
120
|
+
const s = this.sessions.get(userId);
|
|
121
|
+
if (threadId) {
|
|
122
|
+
const t = s?.threads?.[threadId];
|
|
123
|
+
if (t?.claudeModel)
|
|
124
|
+
return t.claudeModel;
|
|
125
|
+
}
|
|
126
|
+
return s?.claudeModel;
|
|
127
|
+
}
|
|
128
|
+
setModel(userId, model, threadId) {
|
|
129
|
+
const s = this.sessions.get(userId);
|
|
130
|
+
if (threadId) {
|
|
131
|
+
const t = s?.threads?.[threadId];
|
|
132
|
+
if (t) {
|
|
133
|
+
t.claudeModel = model;
|
|
134
|
+
this.save();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (s)
|
|
139
|
+
s.claudeModel = model;
|
|
140
|
+
else
|
|
141
|
+
this.sessions.set(userId, { workDir: this.defaultWorkDir, activeConvId: randomBytes(4).toString('hex'), claudeModel: model });
|
|
142
|
+
this.save();
|
|
143
|
+
}
|
|
144
|
+
async resolveAndValidate(baseDir, targetDir) {
|
|
145
|
+
const resolved = resolve(baseDir, targetDir);
|
|
146
|
+
if (!existsSync(resolved))
|
|
147
|
+
throw new Error(`目录不存在: ${resolved}`);
|
|
148
|
+
const realPath = await realpath(resolved);
|
|
149
|
+
const allowed = this.allowedBaseDirs.some((base) => realPath === base || realPath.startsWith(base + '/'));
|
|
150
|
+
if (!allowed)
|
|
151
|
+
throw new Error(`目录不在允许范围内: ${realPath}`);
|
|
152
|
+
return realPath;
|
|
153
|
+
}
|
|
154
|
+
load() {
|
|
155
|
+
try {
|
|
156
|
+
if (existsSync(SESSIONS_FILE)) {
|
|
157
|
+
const data = JSON.parse(readFileSync(SESSIONS_FILE, 'utf-8'));
|
|
158
|
+
for (const [k, v] of Object.entries(data)) {
|
|
159
|
+
if (v && typeof v.workDir === 'string') {
|
|
160
|
+
if (!v.activeConvId)
|
|
161
|
+
v.activeConvId = randomBytes(4).toString('hex');
|
|
162
|
+
this.sessions.set(k, v);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
/* ignore */
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
save() {
|
|
172
|
+
if (this.saveTimer)
|
|
173
|
+
return;
|
|
174
|
+
this.saveTimer = setTimeout(() => {
|
|
175
|
+
this.saveTimer = null;
|
|
176
|
+
this.doFlush();
|
|
177
|
+
}, 500);
|
|
178
|
+
}
|
|
179
|
+
flushSync() {
|
|
180
|
+
if (this.saveTimer) {
|
|
181
|
+
clearTimeout(this.saveTimer);
|
|
182
|
+
this.saveTimer = null;
|
|
183
|
+
}
|
|
184
|
+
this.doFlush();
|
|
185
|
+
}
|
|
186
|
+
destroy() {
|
|
187
|
+
if (this.saveTimer) {
|
|
188
|
+
clearTimeout(this.saveTimer);
|
|
189
|
+
this.saveTimer = null;
|
|
190
|
+
}
|
|
191
|
+
this.doFlush();
|
|
192
|
+
}
|
|
193
|
+
doFlush() {
|
|
194
|
+
try {
|
|
195
|
+
const dir = dirname(SESSIONS_FILE);
|
|
196
|
+
if (!existsSync(dir))
|
|
197
|
+
mkdirSync(dir, { recursive: true });
|
|
198
|
+
const obj = {};
|
|
199
|
+
for (const [k, v] of this.sessions)
|
|
200
|
+
obj[k] = v;
|
|
201
|
+
writeFileSync(SESSIONS_FILE, JSON.stringify(obj, null, 2), 'utf-8');
|
|
202
|
+
}
|
|
203
|
+
catch (err) {
|
|
204
|
+
log.error('Failed to save sessions:', err);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
package/dist/setup.d.ts
ADDED
package/dist/setup.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 首次运行时的交互式配置引导
|
|
3
|
+
* 使用 prompts 库,兼容 tsx watch、IDE 终端等环境
|
|
4
|
+
*/
|
|
5
|
+
import prompts from 'prompts';
|
|
6
|
+
import { mkdirSync, writeFileSync, existsSync } from 'node:fs';
|
|
7
|
+
import { join, dirname } from 'node:path';
|
|
8
|
+
import { APP_HOME } from './constants.js';
|
|
9
|
+
function printManualInstructions(configPath) {
|
|
10
|
+
console.log('\n━━━ open-im 首次配置 ━━━\n');
|
|
11
|
+
console.log('当前环境不支持交互输入,请手动创建配置文件:');
|
|
12
|
+
console.log('');
|
|
13
|
+
console.log(' 1. 创建目录:', dirname(configPath));
|
|
14
|
+
console.log(' 2. 创建文件:', configPath);
|
|
15
|
+
console.log(' 3. 填入以下内容(替换为你的 Token 和用户 ID):');
|
|
16
|
+
console.log('');
|
|
17
|
+
console.log(`{
|
|
18
|
+
"telegramBotToken": "你的Bot Token",
|
|
19
|
+
"allowedUserIds": ["你的Telegram用户ID"],
|
|
20
|
+
"claudeWorkDir": "${process.cwd().replace(/\\/g, '/')}",
|
|
21
|
+
"claudeSkipPermissions": true,
|
|
22
|
+
"aiCommand": "claude"
|
|
23
|
+
}`);
|
|
24
|
+
console.log('');
|
|
25
|
+
console.log('或设置环境变量: TELEGRAM_BOT_TOKEN=xxx 后再运行');
|
|
26
|
+
console.log('');
|
|
27
|
+
}
|
|
28
|
+
export async function runInteractiveSetup() {
|
|
29
|
+
const configPath = join(APP_HOME, 'config.json');
|
|
30
|
+
const forceManual = process.argv.includes('--manual') || process.argv.includes('-m');
|
|
31
|
+
if (forceManual || !process.stdin.isTTY) {
|
|
32
|
+
printManualInstructions(configPath);
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
console.log('\n━━━ open-im 首次配置 ━━━\n');
|
|
36
|
+
console.log('配置将保存到:', configPath);
|
|
37
|
+
console.log('');
|
|
38
|
+
const onCancel = () => {
|
|
39
|
+
console.log('\n已取消配置。');
|
|
40
|
+
process.exit(0);
|
|
41
|
+
};
|
|
42
|
+
const response = await prompts([
|
|
43
|
+
{
|
|
44
|
+
type: 'text',
|
|
45
|
+
name: 'telegramBotToken',
|
|
46
|
+
message: 'Telegram Bot Token(必填,从 @BotFather 获取)',
|
|
47
|
+
validate: (v) => (v.trim() ? true : 'Token 不能为空'),
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
type: 'text',
|
|
51
|
+
name: 'allowedUserIds',
|
|
52
|
+
message: '白名单用户 ID(可选,逗号分隔,留空=所有人可访问)',
|
|
53
|
+
initial: '',
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
type: 'select',
|
|
57
|
+
name: 'aiCommand',
|
|
58
|
+
message: 'AI 工具(↑↓ 选择)',
|
|
59
|
+
choices: [
|
|
60
|
+
{ title: 'claude-code', value: 'claude' },
|
|
61
|
+
{ title: 'codex', value: 'codex' },
|
|
62
|
+
{ title: 'cursor', value: 'cursor' },
|
|
63
|
+
],
|
|
64
|
+
initial: 0,
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
type: 'text',
|
|
68
|
+
name: 'workDir',
|
|
69
|
+
message: '工作目录',
|
|
70
|
+
initial: process.cwd(),
|
|
71
|
+
},
|
|
72
|
+
], { onCancel });
|
|
73
|
+
if (!response.telegramBotToken) {
|
|
74
|
+
console.log('\n未输入 Token,取消配置。');
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
const config = {
|
|
78
|
+
telegramBotToken: response.telegramBotToken.trim(),
|
|
79
|
+
allowedUserIds: response.allowedUserIds
|
|
80
|
+
? response.allowedUserIds
|
|
81
|
+
.split(',')
|
|
82
|
+
.map((s) => s.trim())
|
|
83
|
+
.filter(Boolean)
|
|
84
|
+
: [],
|
|
85
|
+
claudeWorkDir: (response.workDir || process.cwd()).trim(),
|
|
86
|
+
claudeSkipPermissions: true,
|
|
87
|
+
aiCommand: response.aiCommand ?? 'claude',
|
|
88
|
+
};
|
|
89
|
+
const dir = dirname(configPath);
|
|
90
|
+
if (!existsSync(dir)) {
|
|
91
|
+
mkdirSync(dir, { recursive: true });
|
|
92
|
+
}
|
|
93
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
|
94
|
+
console.log('\n✅ 配置已保存到', configPath);
|
|
95
|
+
console.log('');
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare function loadActiveChats(): void;
|
|
2
|
+
export declare function getActiveChatId(platform: 'feishu' | 'telegram'): string | undefined;
|
|
3
|
+
export declare function setActiveChatId(platform: 'feishu' | 'telegram', chatId: string): void;
|
|
4
|
+
export declare function flushActiveChats(): void;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { writeFile, mkdir } from 'node:fs/promises';
|
|
3
|
+
import { join, dirname } from 'node:path';
|
|
4
|
+
import { APP_HOME } from '../constants.js';
|
|
5
|
+
const ACTIVE_CHATS_FILE = join(APP_HOME, 'data', 'active-chats.json');
|
|
6
|
+
let data = {};
|
|
7
|
+
let saveTimer = null;
|
|
8
|
+
function scheduleSave() {
|
|
9
|
+
if (saveTimer)
|
|
10
|
+
clearTimeout(saveTimer);
|
|
11
|
+
saveTimer = setTimeout(async () => {
|
|
12
|
+
saveTimer = null;
|
|
13
|
+
try {
|
|
14
|
+
await mkdir(dirname(ACTIVE_CHATS_FILE), { recursive: true });
|
|
15
|
+
await writeFile(ACTIVE_CHATS_FILE, JSON.stringify(data, null, 2), 'utf-8');
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
/* ignore */
|
|
19
|
+
}
|
|
20
|
+
}, 500);
|
|
21
|
+
}
|
|
22
|
+
export function loadActiveChats() {
|
|
23
|
+
try {
|
|
24
|
+
if (existsSync(ACTIVE_CHATS_FILE)) {
|
|
25
|
+
data = JSON.parse(readFileSync(ACTIVE_CHATS_FILE, 'utf-8'));
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
data = {};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export function getActiveChatId(platform) {
|
|
33
|
+
return data[platform];
|
|
34
|
+
}
|
|
35
|
+
export function setActiveChatId(platform, chatId) {
|
|
36
|
+
if (data[platform] === chatId)
|
|
37
|
+
return;
|
|
38
|
+
data[platform] = chatId;
|
|
39
|
+
scheduleSave();
|
|
40
|
+
}
|
|
41
|
+
export function flushActiveChats() {
|
|
42
|
+
if (saveTimer) {
|
|
43
|
+
clearTimeout(saveTimer);
|
|
44
|
+
saveTimer = null;
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
const dir = dirname(ACTIVE_CHATS_FILE);
|
|
48
|
+
if (!existsSync(dir))
|
|
49
|
+
mkdirSync(dir, { recursive: true });
|
|
50
|
+
writeFileSync(ACTIVE_CHATS_FILE, JSON.stringify(data, null, 2), 'utf-8');
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
/* ignore */
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 共享 AI 任务执行层 - 支持多 ToolAdapter
|
|
3
|
+
*/
|
|
4
|
+
import type { Config } from '../config.js';
|
|
5
|
+
import type { SessionManager } from '../session/session-manager.js';
|
|
6
|
+
import type { ToolAdapter } from '../adapters/tool-adapter.interface.js';
|
|
7
|
+
import type { CostRecord } from './types.js';
|
|
8
|
+
export interface TaskDeps {
|
|
9
|
+
config: Config;
|
|
10
|
+
sessionManager: SessionManager;
|
|
11
|
+
userCosts: Map<string, CostRecord>;
|
|
12
|
+
}
|
|
13
|
+
export interface TaskContext {
|
|
14
|
+
userId: string;
|
|
15
|
+
chatId: string;
|
|
16
|
+
workDir: string;
|
|
17
|
+
sessionId: string | undefined;
|
|
18
|
+
convId?: string;
|
|
19
|
+
threadId?: string;
|
|
20
|
+
platform: string;
|
|
21
|
+
taskKey: string;
|
|
22
|
+
}
|
|
23
|
+
export interface TaskAdapter {
|
|
24
|
+
streamUpdate(content: string, toolNote?: string): void;
|
|
25
|
+
sendComplete(content: string, note: string, thinkingText?: string): Promise<void>;
|
|
26
|
+
sendError(error: string): Promise<void>;
|
|
27
|
+
onThinkingToText?(content: string): void;
|
|
28
|
+
extraCleanup?(): void;
|
|
29
|
+
throttleMs: number;
|
|
30
|
+
onTaskReady(state: TaskRunState): void;
|
|
31
|
+
onFirstContent?(): void;
|
|
32
|
+
sendImage?(imagePath: string): Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
export interface TaskRunState {
|
|
35
|
+
handle: {
|
|
36
|
+
abort: () => void;
|
|
37
|
+
};
|
|
38
|
+
latestContent: string;
|
|
39
|
+
settle: () => void;
|
|
40
|
+
startedAt: number;
|
|
41
|
+
}
|
|
42
|
+
export declare function runAITask(deps: TaskDeps, ctx: TaskContext, prompt: string, toolAdapter: ToolAdapter, platformAdapter: TaskAdapter): Promise<void>;
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 共享 AI 任务执行层 - 支持多 ToolAdapter
|
|
3
|
+
*/
|
|
4
|
+
import { formatToolStats, formatToolCallNotification, trackCost, getContextWarning, } from './utils.js';
|
|
5
|
+
import { createLogger } from '../logger.js';
|
|
6
|
+
const log = createLogger('AITask');
|
|
7
|
+
function buildCompletionNote(result, sessionManager, ctx) {
|
|
8
|
+
const toolInfo = formatToolStats(result.toolStats, result.numTurns);
|
|
9
|
+
const parts = [];
|
|
10
|
+
if (result.cost > 0) {
|
|
11
|
+
parts.push(`耗时 ${(result.durationMs / 1000).toFixed(1)}s`);
|
|
12
|
+
parts.push(`费用 $${result.cost.toFixed(4)}`);
|
|
13
|
+
}
|
|
14
|
+
else {
|
|
15
|
+
parts.push('完成');
|
|
16
|
+
}
|
|
17
|
+
if (toolInfo)
|
|
18
|
+
parts.push(toolInfo);
|
|
19
|
+
if (result.model)
|
|
20
|
+
parts.push(result.model);
|
|
21
|
+
const totalTurns = ctx.threadId
|
|
22
|
+
? sessionManager.addTurnsForThread(ctx.userId, ctx.threadId, result.numTurns)
|
|
23
|
+
: sessionManager.addTurns(ctx.userId, result.numTurns);
|
|
24
|
+
const ctxWarning = getContextWarning(totalTurns);
|
|
25
|
+
if (ctxWarning)
|
|
26
|
+
parts.push(ctxWarning);
|
|
27
|
+
return parts.join(' | ');
|
|
28
|
+
}
|
|
29
|
+
export function runAITask(deps, ctx, prompt, toolAdapter, platformAdapter) {
|
|
30
|
+
const { config, sessionManager, userCosts } = deps;
|
|
31
|
+
return new Promise((resolve) => {
|
|
32
|
+
let lastUpdateTime = 0;
|
|
33
|
+
let pendingUpdate = null;
|
|
34
|
+
let settled = false;
|
|
35
|
+
let firstContentLogged = false;
|
|
36
|
+
let wasThinking = false;
|
|
37
|
+
let thinkingText = '';
|
|
38
|
+
const toolLines = [];
|
|
39
|
+
const startTime = Date.now();
|
|
40
|
+
const cleanup = () => {
|
|
41
|
+
if (pendingUpdate) {
|
|
42
|
+
clearTimeout(pendingUpdate);
|
|
43
|
+
pendingUpdate = null;
|
|
44
|
+
}
|
|
45
|
+
platformAdapter.extraCleanup?.();
|
|
46
|
+
};
|
|
47
|
+
const settle = () => {
|
|
48
|
+
if (settled)
|
|
49
|
+
return;
|
|
50
|
+
settled = true;
|
|
51
|
+
cleanup();
|
|
52
|
+
resolve();
|
|
53
|
+
};
|
|
54
|
+
let taskState;
|
|
55
|
+
const throttledUpdate = (content) => {
|
|
56
|
+
taskState.latestContent = content;
|
|
57
|
+
const now = Date.now();
|
|
58
|
+
const elapsed = now - lastUpdateTime;
|
|
59
|
+
if (elapsed >= platformAdapter.throttleMs) {
|
|
60
|
+
lastUpdateTime = now;
|
|
61
|
+
if (pendingUpdate) {
|
|
62
|
+
clearTimeout(pendingUpdate);
|
|
63
|
+
pendingUpdate = null;
|
|
64
|
+
}
|
|
65
|
+
const toolNote = toolLines.length > 0 ? toolLines.slice(-3).join('\n') : undefined;
|
|
66
|
+
platformAdapter.streamUpdate(content, toolNote);
|
|
67
|
+
}
|
|
68
|
+
else if (!pendingUpdate) {
|
|
69
|
+
pendingUpdate = setTimeout(() => {
|
|
70
|
+
pendingUpdate = null;
|
|
71
|
+
lastUpdateTime = Date.now();
|
|
72
|
+
const toolNote = toolLines.length > 0 ? toolLines.slice(-3).join('\n') : undefined;
|
|
73
|
+
platformAdapter.streamUpdate(taskState.latestContent, toolNote);
|
|
74
|
+
}, platformAdapter.throttleMs - elapsed);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
const handle = toolAdapter.run(prompt, ctx.sessionId, ctx.workDir, {
|
|
78
|
+
onSessionId: (id) => {
|
|
79
|
+
if (ctx.threadId)
|
|
80
|
+
sessionManager.setSessionIdForThread(ctx.userId, ctx.threadId, id);
|
|
81
|
+
else if (ctx.convId)
|
|
82
|
+
sessionManager.setSessionIdForConv(ctx.userId, ctx.convId, id);
|
|
83
|
+
},
|
|
84
|
+
onThinking: (t) => {
|
|
85
|
+
if (!firstContentLogged) {
|
|
86
|
+
firstContentLogged = true;
|
|
87
|
+
platformAdapter.onFirstContent?.();
|
|
88
|
+
}
|
|
89
|
+
wasThinking = true;
|
|
90
|
+
thinkingText = t;
|
|
91
|
+
throttledUpdate(`💭 **思考中...**\n\n${t}`);
|
|
92
|
+
},
|
|
93
|
+
onText: (accumulated) => {
|
|
94
|
+
if (!firstContentLogged) {
|
|
95
|
+
firstContentLogged = true;
|
|
96
|
+
platformAdapter.onFirstContent?.();
|
|
97
|
+
}
|
|
98
|
+
if (wasThinking && platformAdapter.onThinkingToText) {
|
|
99
|
+
wasThinking = false;
|
|
100
|
+
if (pendingUpdate) {
|
|
101
|
+
clearTimeout(pendingUpdate);
|
|
102
|
+
pendingUpdate = null;
|
|
103
|
+
}
|
|
104
|
+
lastUpdateTime = Date.now();
|
|
105
|
+
taskState.latestContent = accumulated;
|
|
106
|
+
platformAdapter.onThinkingToText(accumulated);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
wasThinking = false;
|
|
110
|
+
throttledUpdate(accumulated);
|
|
111
|
+
},
|
|
112
|
+
onToolUse: (toolName, toolInput) => {
|
|
113
|
+
const notification = formatToolCallNotification(toolName, toolInput);
|
|
114
|
+
toolLines.push(notification);
|
|
115
|
+
if (toolLines.length > 5)
|
|
116
|
+
toolLines.shift();
|
|
117
|
+
throttledUpdate(taskState.latestContent);
|
|
118
|
+
},
|
|
119
|
+
onComplete: async (result) => {
|
|
120
|
+
if (settled)
|
|
121
|
+
return;
|
|
122
|
+
settled = true;
|
|
123
|
+
if (pendingUpdate) {
|
|
124
|
+
clearTimeout(pendingUpdate);
|
|
125
|
+
pendingUpdate = null;
|
|
126
|
+
}
|
|
127
|
+
const note = buildCompletionNote(result, sessionManager, ctx);
|
|
128
|
+
log.info(`Task completed for user ${ctx.userId}: cost=$${result.cost.toFixed(4)}`);
|
|
129
|
+
trackCost(userCosts, ctx.userId, result.cost, result.durationMs);
|
|
130
|
+
const finalContent = result.accumulated || result.result || '(无输出)';
|
|
131
|
+
try {
|
|
132
|
+
await platformAdapter.sendComplete(finalContent, note, thinkingText || undefined);
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
log.error('Failed to send complete:', err);
|
|
136
|
+
}
|
|
137
|
+
cleanup();
|
|
138
|
+
resolve();
|
|
139
|
+
},
|
|
140
|
+
onError: async (error) => {
|
|
141
|
+
if (settled)
|
|
142
|
+
return;
|
|
143
|
+
settled = true;
|
|
144
|
+
if (pendingUpdate) {
|
|
145
|
+
clearTimeout(pendingUpdate);
|
|
146
|
+
pendingUpdate = null;
|
|
147
|
+
}
|
|
148
|
+
log.error(`Task error for user ${ctx.userId}: ${error}`);
|
|
149
|
+
try {
|
|
150
|
+
await platformAdapter.sendError(error);
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
log.error('Failed to send error:', err);
|
|
154
|
+
}
|
|
155
|
+
cleanup();
|
|
156
|
+
resolve();
|
|
157
|
+
},
|
|
158
|
+
}, {
|
|
159
|
+
skipPermissions: config.claudeSkipPermissions,
|
|
160
|
+
timeoutMs: config.claudeTimeoutMs,
|
|
161
|
+
model: sessionManager.getModel(ctx.userId, ctx.threadId) ?? config.claudeModel,
|
|
162
|
+
chatId: ctx.chatId,
|
|
163
|
+
});
|
|
164
|
+
taskState = { handle, latestContent: '', settle, startedAt: Date.now() };
|
|
165
|
+
platformAdapter.onTaskReady(taskState);
|
|
166
|
+
});
|
|
167
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { DEDUP_TTL_MS } from '../constants.js';
|
|
2
|
+
const MAX_DEDUP_SIZE = 1000;
|
|
3
|
+
export class MessageDedup {
|
|
4
|
+
processedMessages = new Map();
|
|
5
|
+
isDuplicate(messageId) {
|
|
6
|
+
const now = Date.now();
|
|
7
|
+
if (this.processedMessages.has(messageId))
|
|
8
|
+
return true;
|
|
9
|
+
this.processedMessages.set(messageId, now);
|
|
10
|
+
for (const [id, ts] of this.processedMessages) {
|
|
11
|
+
if (now - ts > DEDUP_TTL_MS)
|
|
12
|
+
this.processedMessages.delete(id);
|
|
13
|
+
else
|
|
14
|
+
break;
|
|
15
|
+
}
|
|
16
|
+
while (this.processedMessages.size > MAX_DEDUP_SIZE) {
|
|
17
|
+
const k = this.processedMessages.keys().next().value;
|
|
18
|
+
if (k !== undefined)
|
|
19
|
+
this.processedMessages.delete(k);
|
|
20
|
+
else
|
|
21
|
+
break;
|
|
22
|
+
}
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createLogger } from '../logger.js';
|
|
2
|
+
const log = createLogger('TaskCleanup');
|
|
3
|
+
const TASK_TIMEOUT_MS = 30 * 60 * 1000;
|
|
4
|
+
const INTERVAL_MS = 10 * 60 * 1000;
|
|
5
|
+
export function startTaskCleanup(runningTasks) {
|
|
6
|
+
const timer = setInterval(() => {
|
|
7
|
+
const now = Date.now();
|
|
8
|
+
for (const [key, task] of runningTasks) {
|
|
9
|
+
if (now - task.startedAt > TASK_TIMEOUT_MS) {
|
|
10
|
+
log.warn(`Auto-cleaning timeout task: ${key}`);
|
|
11
|
+
task.settle();
|
|
12
|
+
task.handle.abort();
|
|
13
|
+
runningTasks.delete(key);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}, INTERVAL_MS);
|
|
17
|
+
timer.unref();
|
|
18
|
+
return () => clearInterval(timer);
|
|
19
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { CostRecord } from './types.js';
|
|
2
|
+
export declare function truncateText(text: string, maxLen: number): string;
|
|
3
|
+
export declare function splitLongContent(text: string, maxLen: number): string[];
|
|
4
|
+
export declare function formatToolStats(toolStats: Record<string, number>, numTurns: number): string;
|
|
5
|
+
export declare function formatToolCallNotification(toolName: string, toolInput?: Record<string, unknown>): string;
|
|
6
|
+
export declare function trackCost(userCosts: Map<string, CostRecord>, userId: string, cost: number, durationMs: number): void;
|
|
7
|
+
export declare function getContextWarning(totalTurns: number): string | null;
|