@hwj123weijian/pi-feishu 0.2.0 → 0.6.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/README.md +43 -5
- package/package.json +3 -2
- package/src/contracts.ts +63 -1
- package/src/controller.ts +257 -53
- package/src/credentials.ts +17 -6
- package/src/extension.ts +219 -24
- package/src/gateway.ts +105 -5
- package/src/remote-commands.ts +142 -0
package/README.md
CHANGED
|
@@ -8,17 +8,21 @@
|
|
|
8
8
|
|
|
9
9
|
- 仅处理飞书私聊(`p2p`)文本消息
|
|
10
10
|
- 首次启动生成一次性绑定码,只允许一个 Owner
|
|
11
|
+
- 收到消息后立刻在原消息上添加“思考中”表情作为已读回执,回复送达后自动移除
|
|
12
|
+
- 以 `/` 开头的消息走远程命令快速通道,不排队、不进入 LLM 上下文
|
|
11
13
|
- 所有消息进入当前 Pi 会话,不创建额外 Agent 或会话
|
|
14
|
+
- 消息需要排队时发送“排队中(第 N 位)”提示,轮到处理时自动撤回;队列变化时提示自动改写为新位数
|
|
15
|
+
- Owner 在排队中的消息上点 ❌(`CrossMark`)表情即可取消该消息
|
|
12
16
|
- Assistant 文本通过 Pi 的 `message_update` 增量渲染到同一张飞书卡片
|
|
13
17
|
- 工具执行时仅显示安全的状态摘要,不展示参数、命令、文件内容或工具输出
|
|
14
18
|
- SDK 以 500ms / 120 字符节流卡片更新;完成、失败、中止都会收尾卡片
|
|
15
19
|
- 缺少更新权限或飞书卡片更新失败时,自动退化为原私聊的一次性文本回复
|
|
16
20
|
- 串行处理消息,避免多条飞书消息同时驱动 Pi
|
|
17
21
|
- 按飞书 `message_id` 去重
|
|
18
|
-
- `/feishu stop`、`/feishu logout`
|
|
22
|
+
- 长连接由跨会话的全局控制器保管:本地 `/new`、远程 `/new` 等会话切换不会断开;`/feishu stop`、`/feishu logout` 或 Pi 进程退出时释放
|
|
19
23
|
- 凭据优先从环境变量读取,也可保存到本地凭据文件
|
|
20
24
|
|
|
21
|
-
|
|
25
|
+
支持 Owner 在飞书私聊中请求创建群聊;扩展会把 Owner 拉入新群、发送欢迎消息,并允许 Owner 在该扩展创建的群里 @机器人继续协作。其他群聊不会被处理。图片、文件、卡片、语音、多用户、多会话、进程级沙箱或远程命令权限管理仍不支持。Pi 本身仍拥有当前本地进程的权限。
|
|
22
26
|
|
|
23
27
|
## 环境要求
|
|
24
28
|
|
|
@@ -34,12 +38,14 @@
|
|
|
34
38
|
2. 在“权限管理”中申请:
|
|
35
39
|
- `im:message.p2p_msg:readonly`:接收私聊消息
|
|
36
40
|
- `im:message:send_as_bot`:以机器人身份回复
|
|
41
|
+
- `im:chat`:创建群聊并邀请 Owner
|
|
37
42
|
- `im:message:update`:持续更新机器人发出的交互卡片
|
|
43
|
+
- 表情回复相关权限:在原消息上添加/移除“思考中”已读回执
|
|
38
44
|
3. 在“事件与回调”中选择“使用长连接接收事件”。
|
|
39
45
|
4. 添加事件 `im.message.receive_v1`。
|
|
40
46
|
5. 创建并发布一个应用版本,使权限和事件订阅在企业内生效。
|
|
41
47
|
|
|
42
|
-
这个扩展不需要公网回调地址,也不需要加密密钥或 Verification Token。若未授予 `im:message:update
|
|
48
|
+
这个扩展不需要公网回调地址,也不需要加密密钥或 Verification Token。若未授予 `im:message:update`,扩展仍可工作,但会降级为最终文本一次性回复。若未授予表情回复权限,扩展同样可工作,只是原消息上不会出现已读表情。
|
|
43
49
|
|
|
44
50
|
## 安装
|
|
45
51
|
|
|
@@ -93,7 +99,7 @@ pi -e D:\ai_study\pi-feishu
|
|
|
93
99
|
/bind 123456
|
|
94
100
|
```
|
|
95
101
|
|
|
96
|
-
绑定成功后,直接私聊机器人即可驱动当前 Pi
|
|
102
|
+
绑定成功后,直接私聊机器人即可驱动当前 Pi 会话。原消息会立刻出现“思考中”表情(已读回执),处理过程中先显示“正在思考”,随后持续更新正文;Pi 调用工具时卡片会显示“正在执行工具”,最终状态变为“已完成”,同时已读表情自动移除。
|
|
97
103
|
|
|
98
104
|
也支持手动参数:
|
|
99
105
|
|
|
@@ -122,6 +128,30 @@ pi -e D:\ai_study\pi-feishu
|
|
|
122
128
|
|
|
123
129
|
环境变量优先于凭据文件。如果环境变量中的 App ID 与已保存的 App ID 不同,旧 Owner 绑定不会被继承。
|
|
124
130
|
|
|
131
|
+
### 飞书远程命令
|
|
132
|
+
|
|
133
|
+
在飞书私聊里直接发送以下命令,会走高优先级快速通道:不进入消息队列排队,也不会作为提问发给 Pi。
|
|
134
|
+
|
|
135
|
+
| 命令 | 作用 |
|
|
136
|
+
| --- | --- |
|
|
137
|
+
| `/help` | 显示远程命令帮助 |
|
|
138
|
+
| `/new` | 新建 Pi 会话(丢弃当前对话上下文) |
|
|
139
|
+
| `/stop` | 中止当前任务,并跳过队列中剩余消息 |
|
|
140
|
+
| `/status` | 查看连接、队列、模型与上下文占用 |
|
|
141
|
+
| `/compact` | 压缩当前会话上下文 |
|
|
142
|
+
| `/thinking` | 查看思考档位,或切换:`/thinking off|minimal|low|medium|high|xhigh|max` |
|
|
143
|
+
| `/model` | 查看当前模型,或模糊匹配切换:`/model <模型ID或名称>` |
|
|
144
|
+
|
|
145
|
+
在飞书私聊中也可直接说“帮我拉个 XX 群”或“创建飞书群 XX”。机器人会创建群聊、邀请已绑定的 Owner 并发送欢迎消息;之后 Owner 在新群里 @机器人即可继续使用当前 Pi 会话。已创建群聊的授权列表会保存在本地凭据文件中,Pi 重启后仍可继续使用。
|
|
146
|
+
|
|
147
|
+
未知命令会返回提示,不会发给 Pi。`/model` 的候选来自本地 Pi 的 scoped models(未配置时为全部已授权模型);`/model` 无参数时展示当前模型。注意:任何以 `/` 开头的消息都会先被当作命令解析,想发给 Pi 的提问请勿以 `/` 开头。
|
|
148
|
+
|
|
149
|
+
### 取消排队中的消息
|
|
150
|
+
|
|
151
|
+
消息排队期间,Owner 直接在**自己的这条消息**上点 ❌(`CrossMark`)表情即可取消:排队提示与已读表情立即清理,消息不会发给 Pi,后面消息的排队提示自动前移。仅 Owner 的取消表情生效,已开工的消息不能取消(用 `/stop` 中止)。
|
|
152
|
+
|
|
153
|
+
飞书的“消息撤回”事件未被官方 Node SDK 的 channel 抽象透出(长连接也不允许挂第二条连接分流事件),因此这里以 ❌ 表情作为取消入口,效果等价于撤回后移出队列。
|
|
154
|
+
|
|
125
155
|
## 常见问题
|
|
126
156
|
|
|
127
157
|
### `setup` 或 `start` 连接失败
|
|
@@ -140,6 +170,14 @@ pi -e D:\ai_study\pi-feishu
|
|
|
140
170
|
|
|
141
171
|
确认已申请并发布 `im:message:update`。卡片初始化或更新失败时,扩展会自动改为最终文本回复,避免用户没有任何反馈。
|
|
142
172
|
|
|
173
|
+
### 没有已读表情
|
|
174
|
+
|
|
175
|
+
确认已申请并发布表情回复相关权限。该权限缺失时扩展自动降级:消息照常收发,只是原消息上不会出现“思考中”表情。
|
|
176
|
+
|
|
177
|
+
### 远程 `/new` 提示暂不可用
|
|
178
|
+
|
|
179
|
+
远程会话切换依赖本地 Pi 提供的命令上下文。先在本地 Pi 执行任意 `/feishu` 子命令(例如 `/feishu status`),再从飞书发送 `/new`。切换完成后长连接自动延续,无需重新执行 `/feishu start`。
|
|
180
|
+
|
|
143
181
|
### Bot 提示“未授权”
|
|
144
182
|
|
|
145
183
|
该 Bot 已绑定其他 Owner。若要重新绑定,先在本地 Pi 执行 `/feishu logout`,再重新 `setup`、`start` 和 `/bind`。
|
|
@@ -156,7 +194,7 @@ npm test
|
|
|
156
194
|
npm run build
|
|
157
195
|
```
|
|
158
196
|
|
|
159
|
-
测试使用 Fake Gateway 和 Fake Agent,不需要真实飞书凭据,覆盖凭据处理、Owner
|
|
197
|
+
测试使用 Fake Gateway 和 Fake Agent,不需要真实飞书凭据,覆盖凭据处理、Owner 绑定、私聊过滤、串行队列、排队提示改位与撤回、❌ 取消、`/stop` 按条跳过、去重、已读表情回执、远程命令快速通道、思考档位与模型切换、Pi 增量文本、工具状态、流式卡片收尾、降级、错误脱敏和清理行为。
|
|
160
198
|
|
|
161
199
|
## 许可证
|
|
162
200
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hwj123weijian/pi-feishu",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Minimal Feishu private-chat bridge for Pi",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -31,7 +31,8 @@
|
|
|
31
31
|
"typecheck": "tsc --noEmit"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@larksuiteoapi/node-sdk": "1.72.0"
|
|
34
|
+
"@larksuiteoapi/node-sdk": "1.72.0",
|
|
35
|
+
"typebox": "1.3.7"
|
|
35
36
|
},
|
|
36
37
|
"peerDependencies": {
|
|
37
38
|
"@earendil-works/pi-coding-agent": "0.83.0"
|
package/src/contracts.ts
CHANGED
|
@@ -2,6 +2,7 @@ export interface FeishuCredentials {
|
|
|
2
2
|
appId: string;
|
|
3
3
|
appSecret: string;
|
|
4
4
|
ownerOpenId?: string;
|
|
5
|
+
managedGroupIds?: string[];
|
|
5
6
|
}
|
|
6
7
|
|
|
7
8
|
export interface CredentialStore {
|
|
@@ -21,6 +22,15 @@ export interface FeishuIncomingMessage {
|
|
|
21
22
|
|
|
22
23
|
export type FeishuMessageHandler = (message: FeishuIncomingMessage) => Promise<void> | void;
|
|
23
24
|
|
|
25
|
+
export interface FeishuReactionEvent {
|
|
26
|
+
messageId: string;
|
|
27
|
+
operatorOpenId: string;
|
|
28
|
+
emojiType: string;
|
|
29
|
+
action: "added" | "removed";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type FeishuReactionHandler = (event: FeishuReactionEvent) => void;
|
|
33
|
+
|
|
24
34
|
export interface FeishuReplySnapshot {
|
|
25
35
|
text: string;
|
|
26
36
|
status: "正在思考" | "正在生成回复" | "正在执行工具" | "已完成" | "处理失败" | "已取消";
|
|
@@ -36,8 +46,60 @@ export interface FeishuReply {
|
|
|
36
46
|
export interface FeishuGateway {
|
|
37
47
|
connect(handler: FeishuMessageHandler): Promise<void>;
|
|
38
48
|
disconnect(): Promise<void>;
|
|
39
|
-
|
|
49
|
+
createGroupChat(name: string, ownerOpenId: string): Promise<string>;
|
|
50
|
+
/** Sends text and resolves with the sent Feishu message id (for later recall). */
|
|
51
|
+
sendText(chatId: string, text: string, replyTo?: string): Promise<string | undefined>;
|
|
40
52
|
beginReply(chatId: string, replyTo: string): Promise<FeishuReply>;
|
|
53
|
+
/** Edits an already-sent text message in place. */
|
|
54
|
+
editText(messageId: string, text: string): Promise<void>;
|
|
55
|
+
/** Adds an emoji reaction to a message and returns the Feishu reaction id. */
|
|
56
|
+
addReaction(messageId: string, emojiType: string): Promise<string>;
|
|
57
|
+
removeReaction(messageId: string, reactionId: string): Promise<void>;
|
|
58
|
+
recallMessage(messageId: string): Promise<void>;
|
|
59
|
+
/** Subscribes to emoji reactions on messages visible to the bot. */
|
|
60
|
+
onReaction(handler: FeishuReactionHandler): () => void;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface FeishuStatus {
|
|
64
|
+
configured: boolean;
|
|
65
|
+
running: boolean;
|
|
66
|
+
ownerOpenId?: string;
|
|
67
|
+
appId?: string;
|
|
68
|
+
source?: "environment" | "file";
|
|
69
|
+
pendingMessages: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface PiRuntimeSnapshot {
|
|
73
|
+
streaming: boolean;
|
|
74
|
+
model?: string;
|
|
75
|
+
thinkingLevel?: string;
|
|
76
|
+
contextPercent?: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface PiModelInfo {
|
|
80
|
+
id: string;
|
|
81
|
+
name: string;
|
|
82
|
+
provider: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Exposes the Pi runtime capabilities needed by remote slash commands.
|
|
87
|
+
* Implementations must be defensive: the captured context goes stale after
|
|
88
|
+
* a session switch, so methods may become no-ops until the next event.
|
|
89
|
+
*/
|
|
90
|
+
export interface PiRuntime {
|
|
91
|
+
isIdle(): boolean;
|
|
92
|
+
abort(): void;
|
|
93
|
+
compact(): void;
|
|
94
|
+
/** Returns false when the runtime is unavailable or the switch was cancelled. */
|
|
95
|
+
newSession(): Promise<boolean>;
|
|
96
|
+
/** Returns false when the requested thinking level does not exist. */
|
|
97
|
+
setThinkingLevel(level: string): Promise<boolean>;
|
|
98
|
+
/** Models the user can currently switch to. */
|
|
99
|
+
listModels(): PiModelInfo[];
|
|
100
|
+
/** Switches by fuzzy id/name match; throws with a user-facing reason on failure. */
|
|
101
|
+
switchModel(query: string): Promise<PiModelInfo>;
|
|
102
|
+
snapshot(): PiRuntimeSnapshot;
|
|
41
103
|
}
|
|
42
104
|
|
|
43
105
|
export type AgentActivity = { kind: "thinking" } | { kind: "tool"; toolName: string };
|
package/src/controller.ts
CHANGED
|
@@ -7,18 +7,40 @@ import type {
|
|
|
7
7
|
FeishuGateway,
|
|
8
8
|
FeishuGatewayFactory,
|
|
9
9
|
FeishuIncomingMessage,
|
|
10
|
+
FeishuReactionEvent,
|
|
10
11
|
FeishuReply,
|
|
12
|
+
FeishuStatus,
|
|
13
|
+
PiRuntime,
|
|
11
14
|
} from "./contracts.js";
|
|
12
15
|
import { CredentialError, errorMessage, resolveCredentialInput, resolveRuntimeCredentials } from "./credentials.js";
|
|
13
16
|
import { MessageDeduplicator } from "./message-deduplicator.js";
|
|
14
17
|
import { SerialMessageQueue } from "./message-queue.js";
|
|
15
18
|
import { OwnerBinding } from "./owner-binding.js";
|
|
19
|
+
import { executeRemoteCommand, parseRemoteCommand } from "./remote-commands.js";
|
|
20
|
+
|
|
21
|
+
// 与 easycodeclient 的飞书集成一致:THINKING 表情兼作"已读 + 处理中"回执。
|
|
22
|
+
const READ_REACTION_EMOJI = "THINKING";
|
|
23
|
+
|
|
24
|
+
interface PendingTask {
|
|
25
|
+
messageId: string;
|
|
26
|
+
chatId: string;
|
|
27
|
+
reactionId: string;
|
|
28
|
+
tipText: string;
|
|
29
|
+
tipMessageId: string | undefined;
|
|
30
|
+
started: boolean;
|
|
31
|
+
cancelled: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function queueTipText(position: number): string {
|
|
35
|
+
return `⏳ 已收到,排队中(第 ${position} 位),将在当前任务完成后处理。`;
|
|
36
|
+
}
|
|
16
37
|
|
|
17
38
|
export interface FeishuControllerOptions {
|
|
18
39
|
store: CredentialStore;
|
|
19
40
|
gatewayFactory: FeishuGatewayFactory;
|
|
20
41
|
validateCredentials: CredentialValidator;
|
|
21
42
|
agent: AgentBridge;
|
|
43
|
+
runtime?: PiRuntime;
|
|
22
44
|
generateBindingCode?: () => string;
|
|
23
45
|
}
|
|
24
46
|
|
|
@@ -27,33 +49,29 @@ export interface FeishuStartResult {
|
|
|
27
49
|
bindingCode?: string;
|
|
28
50
|
}
|
|
29
51
|
|
|
30
|
-
export interface FeishuStatus {
|
|
31
|
-
configured: boolean;
|
|
32
|
-
running: boolean;
|
|
33
|
-
ownerOpenId?: string;
|
|
34
|
-
appId?: string;
|
|
35
|
-
source?: "environment" | "file";
|
|
36
|
-
pendingMessages: number;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
52
|
export class FeishuController {
|
|
40
53
|
private readonly store: CredentialStore;
|
|
41
54
|
private readonly gatewayFactory: FeishuGatewayFactory;
|
|
42
55
|
private readonly validateCredentials: CredentialValidator;
|
|
43
56
|
private readonly agent: AgentBridge;
|
|
57
|
+
private readonly runtime: PiRuntime | undefined;
|
|
44
58
|
private readonly generateBindingCode: (() => string) | undefined;
|
|
45
59
|
private readonly queue = new SerialMessageQueue();
|
|
46
60
|
private readonly deduplicator = new MessageDeduplicator();
|
|
47
61
|
private gateway: FeishuGateway | undefined;
|
|
48
62
|
private credentials: FeishuCredentials | undefined;
|
|
49
63
|
private binding: OwnerBinding | undefined;
|
|
64
|
+
private environment: Environment = {};
|
|
65
|
+
private readonly pendingTasks = new Map<string, PendingTask>();
|
|
50
66
|
private readonly activeReplies = new Set<FeishuReply>();
|
|
67
|
+
private readonly managedGroupIds = new Set<string>();
|
|
51
68
|
|
|
52
69
|
constructor(options: FeishuControllerOptions) {
|
|
53
70
|
this.store = options.store;
|
|
54
71
|
this.gatewayFactory = options.gatewayFactory;
|
|
55
72
|
this.validateCredentials = options.validateCredentials;
|
|
56
73
|
this.agent = options.agent;
|
|
74
|
+
this.runtime = options.runtime;
|
|
57
75
|
this.generateBindingCode = options.generateBindingCode;
|
|
58
76
|
}
|
|
59
77
|
|
|
@@ -67,9 +85,13 @@ export class FeishuController {
|
|
|
67
85
|
}
|
|
68
86
|
|
|
69
87
|
const existing = await this.store.load();
|
|
70
|
-
const next =
|
|
71
|
-
existing?.appId === credentials.appId
|
|
72
|
-
? {
|
|
88
|
+
const next: FeishuCredentials =
|
|
89
|
+
existing?.appId === credentials.appId
|
|
90
|
+
? {
|
|
91
|
+
...credentials,
|
|
92
|
+
...(existing.ownerOpenId ? { ownerOpenId: existing.ownerOpenId } : {}),
|
|
93
|
+
...(existing.managedGroupIds ? { managedGroupIds: existing.managedGroupIds } : {}),
|
|
94
|
+
}
|
|
73
95
|
: credentials;
|
|
74
96
|
await this.store.save(next);
|
|
75
97
|
this.credentials = next;
|
|
@@ -89,8 +111,11 @@ export class FeishuController {
|
|
|
89
111
|
const binding = new OwnerBinding(credentials.ownerOpenId, this.generateBindingCode);
|
|
90
112
|
const gateway = this.gatewayFactory(credentials);
|
|
91
113
|
this.credentials = credentials;
|
|
114
|
+
this.managedGroupIds.clear();
|
|
115
|
+
for (const chatId of credentials.managedGroupIds ?? []) this.managedGroupIds.add(chatId);
|
|
92
116
|
this.binding = binding;
|
|
93
117
|
this.gateway = gateway;
|
|
118
|
+
this.environment = environment;
|
|
94
119
|
this.deduplicator.clear();
|
|
95
120
|
|
|
96
121
|
try {
|
|
@@ -101,14 +126,37 @@ export class FeishuController {
|
|
|
101
126
|
await gateway.disconnect().catch(() => undefined);
|
|
102
127
|
throw new CredentialError(`启动飞书长连接失败:${errorMessage(error, credentials)}`);
|
|
103
128
|
}
|
|
129
|
+
gateway.onReaction((event) => this.handleReaction(event));
|
|
104
130
|
|
|
105
131
|
const bindingCode = binding.getOrCreateCode();
|
|
106
132
|
return bindingCode ? { alreadyRunning: false, bindingCode } : { alreadyRunning: false };
|
|
107
133
|
}
|
|
108
134
|
|
|
135
|
+
async createGroupChat(name: string): Promise<string> {
|
|
136
|
+
const gateway = this.gateway;
|
|
137
|
+
const ownerOpenId = this.credentials?.ownerOpenId;
|
|
138
|
+
if (!gateway) throw new CredentialError("飞书尚未连接,请先执行 /feishu start。");
|
|
139
|
+
if (!ownerOpenId) throw new CredentialError("飞书尚未绑定 Owner,无法创建群聊。");
|
|
140
|
+
const chatId = await gateway.createGroupChat(name, ownerOpenId);
|
|
141
|
+
this.managedGroupIds.add(chatId);
|
|
142
|
+
const credentials = this.credentials;
|
|
143
|
+
if (credentials) {
|
|
144
|
+
const updated = { ...credentials, managedGroupIds: [...this.managedGroupIds] };
|
|
145
|
+
await this.store.save(updated);
|
|
146
|
+
this.credentials = updated;
|
|
147
|
+
}
|
|
148
|
+
await gateway.sendText(
|
|
149
|
+
chatId,
|
|
150
|
+
`👋 群聊「${name}」已创建,当前绑定的 Pi 飞书机器人已就绪。直接在群内 @机器人即可开始协作。`,
|
|
151
|
+
);
|
|
152
|
+
return chatId;
|
|
153
|
+
}
|
|
154
|
+
|
|
109
155
|
async stop(): Promise<boolean> {
|
|
110
156
|
const gateway = this.gateway;
|
|
111
157
|
if (!gateway) return false;
|
|
158
|
+
for (const entry of this.pendingTasks.values()) entry.cancelled = true;
|
|
159
|
+
this.pendingTasks.clear();
|
|
112
160
|
await Promise.all([...this.activeReplies].map((reply) => reply.cancel().catch(() => undefined)));
|
|
113
161
|
this.activeReplies.clear();
|
|
114
162
|
this.gateway = undefined;
|
|
@@ -146,65 +194,145 @@ export class FeishuController {
|
|
|
146
194
|
async handleIncoming(message: FeishuIncomingMessage): Promise<void> {
|
|
147
195
|
const gateway = this.gateway;
|
|
148
196
|
const binding = this.binding;
|
|
149
|
-
if (!gateway || !binding || message.
|
|
197
|
+
if (!gateway || !binding || message.contentType !== "text") return;
|
|
198
|
+
if (message.chatType === "group" && !this.managedGroupIds.has(message.chatId)) return;
|
|
150
199
|
if (!message.messageId || !this.deduplicator.accept(message.messageId)) return;
|
|
151
200
|
|
|
201
|
+
const reactionId = await this.ackRead(gateway, message.messageId);
|
|
152
202
|
const authorization = binding.authorize(message.senderOpenId, message.text);
|
|
153
203
|
switch (authorization.kind) {
|
|
154
204
|
case "binding-required":
|
|
155
|
-
await this.
|
|
205
|
+
await this.acknowledge(gateway, message, reactionId, "Bot 尚未绑定,请在本地 Pi 查看一次性绑定码。");
|
|
156
206
|
return;
|
|
157
207
|
case "invalid-binding-code":
|
|
158
|
-
await this.
|
|
208
|
+
await this.acknowledge(gateway, message, reactionId, "绑定码无效,请检查本地 Pi 显示的一次性绑定码。");
|
|
159
209
|
return;
|
|
160
210
|
case "unauthorized":
|
|
161
|
-
await this.
|
|
211
|
+
await this.acknowledge(gateway, message, reactionId, "未授权:此 Bot 仅响应已绑定的 Owner。");
|
|
162
212
|
return;
|
|
163
213
|
case "bound":
|
|
164
214
|
await this.persistOwner(authorization.ownerOpenId);
|
|
165
|
-
await this.
|
|
215
|
+
await this.acknowledge(gateway, message, reactionId, "绑定成功,现在可以直接发送问题。");
|
|
166
216
|
return;
|
|
167
217
|
case "authorized":
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
218
|
+
if (parseRemoteCommand(authorization.text)) {
|
|
219
|
+
// 斜杠命令快速通道:不进入消息队列,也不进入 LLM 上下文。
|
|
220
|
+
await this.runRemoteCommand(gateway, message, reactionId, authorization.text);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
void this.enqueueAgentTask(gateway, message, authorization.text, reactionId);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private enqueueAgentTask(
|
|
228
|
+
gateway: FeishuGateway,
|
|
229
|
+
message: FeishuIncomingMessage,
|
|
230
|
+
text: string,
|
|
231
|
+
reactionId: string,
|
|
232
|
+
): void {
|
|
233
|
+
const entry: PendingTask = {
|
|
234
|
+
messageId: message.messageId,
|
|
235
|
+
chatId: message.chatId,
|
|
236
|
+
reactionId,
|
|
237
|
+
tipText: "",
|
|
238
|
+
tipMessageId: undefined,
|
|
239
|
+
started: false,
|
|
240
|
+
cancelled: false,
|
|
241
|
+
};
|
|
242
|
+
// Map 保持插入顺序:首位是正在处理的任务,其余按排队先后排列。
|
|
243
|
+
const position = this.pendingTasks.size + 1;
|
|
244
|
+
this.pendingTasks.set(message.messageId, entry);
|
|
245
|
+
|
|
246
|
+
if (position > 1) {
|
|
247
|
+
entry.tipText = queueTipText(position);
|
|
248
|
+
void gateway
|
|
249
|
+
.sendText(message.chatId, entry.tipText, message.messageId)
|
|
250
|
+
.then((tipId) => {
|
|
251
|
+
if (!tipId) return;
|
|
252
|
+
// 提示送达时任务已开工或已取消:提示已无意义,直接撤回。
|
|
253
|
+
if (entry.cancelled || entry.started) {
|
|
254
|
+
void gateway.recallMessage(tipId).catch(() => undefined);
|
|
255
|
+
return;
|
|
203
256
|
}
|
|
204
|
-
|
|
257
|
+
entry.tipMessageId = tipId;
|
|
258
|
+
this.refreshQueueTips(gateway);
|
|
259
|
+
})
|
|
260
|
+
.catch(() => undefined);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
void this.queue.enqueue(async () => {
|
|
264
|
+
entry.started = true;
|
|
265
|
+
try {
|
|
266
|
+
this.recallTip(gateway, entry);
|
|
267
|
+
if (this.gateway !== gateway) return;
|
|
268
|
+
// 被取消的任务静默跳过:回执与提示已由取消方清理。
|
|
269
|
+
if (entry.cancelled) return;
|
|
270
|
+
await this.processWithAgent(gateway, message, text);
|
|
271
|
+
} finally {
|
|
272
|
+
this.pendingTasks.delete(message.messageId);
|
|
273
|
+
this.refreshQueueTips(gateway);
|
|
274
|
+
await this.clearRead(gateway, message.messageId, reactionId);
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private recallTip(gateway: FeishuGateway, entry: PendingTask): void {
|
|
280
|
+
const tipId = entry.tipMessageId;
|
|
281
|
+
if (!tipId) return;
|
|
282
|
+
entry.tipMessageId = undefined;
|
|
283
|
+
void gateway.recallMessage(tipId).catch(() => undefined);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** 排队提示动态改位:前方的任务完成或被移除后,剩余提示更新为新位数。 */
|
|
287
|
+
private refreshQueueTips(gateway: FeishuGateway): void {
|
|
288
|
+
let position = 0;
|
|
289
|
+
for (const entry of this.pendingTasks.values()) {
|
|
290
|
+
position += 1;
|
|
291
|
+
// 首位要么正在处理、要么即将开工(提示随即撤回),无需改写。
|
|
292
|
+
if (position === 1 || entry.started || !entry.tipMessageId) continue;
|
|
293
|
+
const text = queueTipText(position);
|
|
294
|
+
if (text === entry.tipText) continue;
|
|
295
|
+
entry.tipText = text;
|
|
296
|
+
void gateway.editText(entry.tipMessageId, text).catch(() => undefined);
|
|
205
297
|
}
|
|
206
298
|
}
|
|
207
299
|
|
|
300
|
+
/** 中止当前任务之后的所有排队消息,返回跳过数量。 */
|
|
301
|
+
private stopQueuedMessages(gateway: FeishuGateway): number {
|
|
302
|
+
let skipped = 0;
|
|
303
|
+
for (const entry of [...this.pendingTasks.values()]) {
|
|
304
|
+
if (entry.started) continue;
|
|
305
|
+
entry.cancelled = true;
|
|
306
|
+
skipped += 1;
|
|
307
|
+
this.recallTip(gateway, entry);
|
|
308
|
+
void this.clearRead(gateway, entry.messageId, entry.reactionId);
|
|
309
|
+
entry.reactionId = "";
|
|
310
|
+
// 立即移出登记表:后续提示改位不再把它算作在队。
|
|
311
|
+
this.pendingTasks.delete(entry.messageId);
|
|
312
|
+
}
|
|
313
|
+
return skipped;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Owner 在排队中的消息上点 ❌(或 NO)表情:取消该消息并清理提示与回执。 */
|
|
317
|
+
handleReaction(event: FeishuReactionEvent): void {
|
|
318
|
+
const gateway = this.gateway;
|
|
319
|
+
const binding = this.binding;
|
|
320
|
+
if (!gateway || !binding) return;
|
|
321
|
+
if (event.action !== "added") return;
|
|
322
|
+
const normalized = event.emojiType.toLowerCase();
|
|
323
|
+
if (normalized !== "crossmark" && normalized !== "no") return;
|
|
324
|
+
if (event.operatorOpenId !== binding.ownerOpenId) return;
|
|
325
|
+
const entry = this.pendingTasks.get(event.messageId);
|
|
326
|
+
if (!entry || entry.started || entry.cancelled) return;
|
|
327
|
+
|
|
328
|
+
entry.cancelled = true;
|
|
329
|
+
this.recallTip(gateway, entry);
|
|
330
|
+
void this.clearRead(gateway, entry.messageId, entry.reactionId);
|
|
331
|
+
entry.reactionId = "";
|
|
332
|
+
this.pendingTasks.delete(entry.messageId);
|
|
333
|
+
this.refreshQueueTips(gateway);
|
|
334
|
+
}
|
|
335
|
+
|
|
208
336
|
async waitForIdle(): Promise<void> {
|
|
209
337
|
await this.queue.waitForIdle();
|
|
210
338
|
}
|
|
@@ -221,6 +349,82 @@ export class FeishuController {
|
|
|
221
349
|
this.credentials = next;
|
|
222
350
|
}
|
|
223
351
|
|
|
352
|
+
private async processWithAgent(gateway: FeishuGateway, message: FeishuIncomingMessage, text: string): Promise<void> {
|
|
353
|
+
if (this.gateway !== gateway) return;
|
|
354
|
+
const reply = await gateway.beginReply(message.chatId, message.messageId).catch(() => undefined);
|
|
355
|
+
if (reply) this.activeReplies.add(reply);
|
|
356
|
+
let latestText = "";
|
|
357
|
+
try {
|
|
358
|
+
const response = await this.agent.run(text, {
|
|
359
|
+
onText: (latest) => {
|
|
360
|
+
latestText = latest;
|
|
361
|
+
reply?.update({ text: latest, status: "正在生成回复" });
|
|
362
|
+
},
|
|
363
|
+
onActivity: (activity) => {
|
|
364
|
+
reply?.update({
|
|
365
|
+
text: latestText,
|
|
366
|
+
status: activity.kind === "tool" ? "正在执行工具" : "正在思考",
|
|
367
|
+
});
|
|
368
|
+
},
|
|
369
|
+
});
|
|
370
|
+
if (this.gateway === gateway) {
|
|
371
|
+
if (reply) {
|
|
372
|
+
await reply.complete({ text: response, status: "已完成" });
|
|
373
|
+
} else {
|
|
374
|
+
await gateway.sendText(message.chatId, response, message.messageId);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
} catch {
|
|
378
|
+
if (this.gateway === gateway) {
|
|
379
|
+
if (reply) {
|
|
380
|
+
await reply.fail();
|
|
381
|
+
} else {
|
|
382
|
+
await this.safeSend(gateway, message, "处理消息失败,请稍后再试。");
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
} finally {
|
|
386
|
+
if (reply) this.activeReplies.delete(reply);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
private async runRemoteCommand(
|
|
391
|
+
gateway: FeishuGateway,
|
|
392
|
+
message: FeishuIncomingMessage,
|
|
393
|
+
reactionId: string,
|
|
394
|
+
text: string,
|
|
395
|
+
): Promise<void> {
|
|
396
|
+
const replyText = await executeRemoteCommand(text, {
|
|
397
|
+
runtime: this.runtime,
|
|
398
|
+
status: () => this.status(this.environment),
|
|
399
|
+
stopQueue: () => this.stopQueuedMessages(gateway),
|
|
400
|
+
}).catch((error: unknown) => `执行命令出错:${this.sanitizeError(error)}`);
|
|
401
|
+
await this.acknowledge(gateway, message, reactionId, replyText);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
private async ackRead(gateway: FeishuGateway, messageId: string): Promise<string> {
|
|
405
|
+
try {
|
|
406
|
+
return await gateway.addReaction(messageId, READ_REACTION_EMOJI);
|
|
407
|
+
} catch {
|
|
408
|
+
// 表情权限缺失时静默降级:不影响正常的收发消息流程。
|
|
409
|
+
return "";
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
private async clearRead(gateway: FeishuGateway, messageId: string, reactionId: string): Promise<void> {
|
|
414
|
+
if (!reactionId) return;
|
|
415
|
+
await gateway.removeReaction(messageId, reactionId).catch(() => undefined);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
private async acknowledge(
|
|
419
|
+
gateway: FeishuGateway,
|
|
420
|
+
message: FeishuIncomingMessage,
|
|
421
|
+
reactionId: string,
|
|
422
|
+
text: string,
|
|
423
|
+
): Promise<void> {
|
|
424
|
+
await this.safeSend(gateway, message, text);
|
|
425
|
+
await this.clearRead(gateway, message.messageId, reactionId);
|
|
426
|
+
}
|
|
427
|
+
|
|
224
428
|
private async safeSend(gateway: FeishuGateway, message: FeishuIncomingMessage, text: string): Promise<void> {
|
|
225
429
|
try {
|
|
226
430
|
await gateway.sendText(message.chatId, text, message.messageId);
|
package/src/credentials.ts
CHANGED
|
@@ -61,8 +61,13 @@ export function resolveRuntimeCredentials(
|
|
|
61
61
|
throw new CredentialError("FEISHU_APP_ID 和 FEISHU_APP_SECRET 必须同时设置。");
|
|
62
62
|
}
|
|
63
63
|
validateCredentialShape({ appId, appSecret });
|
|
64
|
-
const
|
|
65
|
-
return
|
|
64
|
+
const matching = stored?.appId === appId ? stored : undefined;
|
|
65
|
+
return {
|
|
66
|
+
appId,
|
|
67
|
+
appSecret,
|
|
68
|
+
...(matching?.ownerOpenId ? { ownerOpenId: matching.ownerOpenId } : {}),
|
|
69
|
+
...(matching?.managedGroupIds ? { managedGroupIds: matching.managedGroupIds } : {}),
|
|
70
|
+
};
|
|
66
71
|
}
|
|
67
72
|
return stored;
|
|
68
73
|
}
|
|
@@ -122,9 +127,14 @@ export class FileCredentialStore implements CredentialStore {
|
|
|
122
127
|
throw new CredentialError("飞书凭据文件格式无效,请执行 /feishu logout 后重新配置。");
|
|
123
128
|
}
|
|
124
129
|
validateCredentialShape(value);
|
|
125
|
-
return
|
|
126
|
-
|
|
127
|
-
|
|
130
|
+
return {
|
|
131
|
+
appId: value.appId,
|
|
132
|
+
appSecret: value.appSecret,
|
|
133
|
+
...(value.ownerOpenId ? { ownerOpenId: value.ownerOpenId } : {}),
|
|
134
|
+
...(Array.isArray(value.managedGroupIds)
|
|
135
|
+
? { managedGroupIds: value.managedGroupIds.filter((id): id is string => typeof id === "string") }
|
|
136
|
+
: {}),
|
|
137
|
+
};
|
|
128
138
|
}
|
|
129
139
|
|
|
130
140
|
async save(credentials: FeishuCredentials): Promise<void> {
|
|
@@ -159,7 +169,8 @@ function isCredentialRecord(value: unknown): value is FeishuCredentials {
|
|
|
159
169
|
return (
|
|
160
170
|
typeof record.appId === "string" &&
|
|
161
171
|
typeof record.appSecret === "string" &&
|
|
162
|
-
(record.ownerOpenId === undefined || typeof record.ownerOpenId === "string")
|
|
172
|
+
(record.ownerOpenId === undefined || typeof record.ownerOpenId === "string") &&
|
|
173
|
+
(record.managedGroupIds === undefined || Array.isArray(record.managedGroupIds))
|
|
163
174
|
);
|
|
164
175
|
}
|
|
165
176
|
|
package/src/extension.ts
CHANGED
|
@@ -1,10 +1,71 @@
|
|
|
1
|
-
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
1
|
+
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import type { AgentBridge, FeishuStatus, PiModelInfo, PiRuntime, PiRuntimeSnapshot } from "./contracts.js";
|
|
4
|
+
import { FeishuController } from "./controller.js";
|
|
3
5
|
import { CredentialError, FileCredentialStore } from "./credentials.js";
|
|
4
6
|
import { SdkFeishuGateway, validateSdkCredentials } from "./gateway.js";
|
|
5
7
|
import { PiAgentBridge } from "./pi-agent-bridge.js";
|
|
8
|
+
import { THINKING_LEVELS } from "./remote-commands.js";
|
|
6
9
|
|
|
7
10
|
type FeishuCommandName = "help" | "setup" | "start" | "stop" | "status" | "logout";
|
|
11
|
+
type PiModel = NonNullable<ExtensionContext["model"]>;
|
|
12
|
+
type PiThinkingLevel = NonNullable<ExtensionContext["thinkingLevel"]>;
|
|
13
|
+
|
|
14
|
+
interface SessionLink {
|
|
15
|
+
bridge: PiAgentBridge;
|
|
16
|
+
runtime: PiRuntime;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface SharedFeishuState {
|
|
20
|
+
controller: FeishuController;
|
|
21
|
+
current: SessionLink | undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const SHARED_STATE_KEY = "__piFeishuSharedState__";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Pi 会在每次会话切换(含远程 /new)后重新执行扩展工厂并重新求值模块,
|
|
28
|
+
* 因此只有挂在 globalThis 上的状态能跨会话存活:长连接、凭据和队列都在
|
|
29
|
+
* 这里,工厂重跑时只替换当前会话的 bridge/runtime。
|
|
30
|
+
*/
|
|
31
|
+
function getSharedState(): SharedFeishuState {
|
|
32
|
+
const host = globalThis as typeof globalThis & { [SHARED_STATE_KEY]?: SharedFeishuState };
|
|
33
|
+
const existing = host[SHARED_STATE_KEY];
|
|
34
|
+
if (existing) return existing;
|
|
35
|
+
|
|
36
|
+
const state: SharedFeishuState = { current: undefined, controller: undefined as unknown as FeishuController };
|
|
37
|
+
const proxyAgent: AgentBridge = {
|
|
38
|
+
run: (text, observer) => {
|
|
39
|
+
const link = state.current;
|
|
40
|
+
if (!link) return Promise.reject(new Error("Pi 会话尚未就绪,请稍后再试。"));
|
|
41
|
+
return link.bridge.run(text, observer);
|
|
42
|
+
},
|
|
43
|
+
cancel: (reason) => state.current?.bridge.cancel(reason),
|
|
44
|
+
};
|
|
45
|
+
const proxyRuntime: PiRuntime = {
|
|
46
|
+
isIdle: () => state.current?.runtime.isIdle() ?? true,
|
|
47
|
+
abort: () => state.current?.runtime.abort(),
|
|
48
|
+
compact: () => state.current?.runtime.compact(),
|
|
49
|
+
newSession: () => state.current?.runtime.newSession() ?? Promise.resolve(false),
|
|
50
|
+
setThinkingLevel: (level) => state.current?.runtime.setThinkingLevel(level) ?? Promise.resolve(false),
|
|
51
|
+
listModels: () => state.current?.runtime.listModels() ?? [],
|
|
52
|
+
switchModel: async (query) => {
|
|
53
|
+
const link = state.current;
|
|
54
|
+
if (!link) throw new Error("Pi 会话尚未就绪,请稍后再试。");
|
|
55
|
+
return link.runtime.switchModel(query);
|
|
56
|
+
},
|
|
57
|
+
snapshot: () => state.current?.runtime.snapshot() ?? { streaming: false },
|
|
58
|
+
};
|
|
59
|
+
state.controller = new FeishuController({
|
|
60
|
+
store: new FileCredentialStore(),
|
|
61
|
+
gatewayFactory: (credentials) => new SdkFeishuGateway(credentials),
|
|
62
|
+
validateCredentials: validateSdkCredentials,
|
|
63
|
+
agent: proxyAgent,
|
|
64
|
+
runtime: proxyRuntime,
|
|
65
|
+
});
|
|
66
|
+
host[SHARED_STATE_KEY] = state;
|
|
67
|
+
return state;
|
|
68
|
+
}
|
|
8
69
|
|
|
9
70
|
export interface ParsedFeishuCommand {
|
|
10
71
|
name: FeishuCommandName;
|
|
@@ -67,46 +128,180 @@ export function renderFeishuStatus(status: FeishuStatus): string {
|
|
|
67
128
|
}
|
|
68
129
|
|
|
69
130
|
export default function feishuExtension(pi: ExtensionAPI): void {
|
|
70
|
-
const
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
131
|
+
const state = getSharedState();
|
|
132
|
+
const bridge = new PiAgentBridge((text) => pi.sendUserMessage(text));
|
|
133
|
+
let latestContext: ExtensionContext | undefined;
|
|
134
|
+
let latestCommandContext: ExtensionCommandContext | undefined;
|
|
135
|
+
|
|
136
|
+
const runtime: PiRuntime = {
|
|
137
|
+
isIdle: () => withRuntimeContext(() => latestContext?.isIdle()) ?? true,
|
|
138
|
+
abort: () => withRuntimeContext(() => latestContext?.abort()),
|
|
139
|
+
compact: () => withRuntimeContext(() => latestContext?.compact()),
|
|
140
|
+
newSession: async () => {
|
|
141
|
+
const context = latestCommandContext;
|
|
142
|
+
if (!context) return false;
|
|
143
|
+
try {
|
|
144
|
+
const result = await context.newSession();
|
|
145
|
+
return !result.cancelled;
|
|
146
|
+
} catch {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
setThinkingLevel: async (level) => {
|
|
151
|
+
if (!isThinkingLevel(level)) return false;
|
|
152
|
+
try {
|
|
153
|
+
pi.setThinkingLevel(level);
|
|
154
|
+
return true;
|
|
155
|
+
} catch {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
listModels: () => {
|
|
160
|
+
return availableModels(latestContext).map(toModelInfo);
|
|
161
|
+
},
|
|
162
|
+
switchModel: async (query) => {
|
|
163
|
+
const model = resolveModel(query, latestContext);
|
|
164
|
+
try {
|
|
165
|
+
const applied = await pi.setModel(model);
|
|
166
|
+
if (!applied) throw new Error(`模型 ${model.name} 缺少可用的 API Key。`);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
169
|
+
}
|
|
170
|
+
return toModelInfo(model);
|
|
171
|
+
},
|
|
172
|
+
snapshot: () => {
|
|
173
|
+
const model = latestContext?.model;
|
|
174
|
+
const usage = withRuntimeContext(() => latestContext?.getContextUsage());
|
|
175
|
+
const snapshot: PiRuntimeSnapshot = {
|
|
176
|
+
streaming: !(withRuntimeContext(() => latestContext?.isIdle()) ?? true),
|
|
177
|
+
};
|
|
178
|
+
if (model) snapshot.model = model.name || model.id;
|
|
179
|
+
if (latestContext?.thinkingLevel) snapshot.thinkingLevel = latestContext.thinkingLevel;
|
|
180
|
+
if (typeof usage?.percent === "number") snapshot.contextPercent = usage.percent;
|
|
181
|
+
return snapshot;
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
state.current = { bridge, runtime };
|
|
77
185
|
|
|
78
|
-
|
|
79
|
-
|
|
186
|
+
// 每个事件都会带来新的 ExtensionContext;持续刷新,保证远程命令拿到的能力不失效。
|
|
187
|
+
const trackContext = (_event: unknown, context: ExtensionContext): void => {
|
|
188
|
+
latestContext = context;
|
|
189
|
+
};
|
|
190
|
+
pi.on("session_start", trackContext);
|
|
191
|
+
pi.on("agent_start", trackContext);
|
|
192
|
+
pi.on("message_end", (event, context) => {
|
|
193
|
+
trackContext(event, context);
|
|
194
|
+
bridge.captureMessage(event.message);
|
|
80
195
|
});
|
|
81
|
-
pi.on("message_update", (event) => {
|
|
82
|
-
|
|
196
|
+
pi.on("message_update", (event, context) => {
|
|
197
|
+
trackContext(event, context);
|
|
198
|
+
bridge.captureStreamingMessage(event.message);
|
|
83
199
|
});
|
|
84
|
-
pi.on("tool_execution_start", (event) => {
|
|
85
|
-
|
|
200
|
+
pi.on("tool_execution_start", (event, context) => {
|
|
201
|
+
trackContext(event, context);
|
|
202
|
+
bridge.captureToolStart(event.toolName);
|
|
86
203
|
});
|
|
87
|
-
pi.on("tool_execution_end", () => {
|
|
88
|
-
|
|
204
|
+
pi.on("tool_execution_end", (event, context) => {
|
|
205
|
+
trackContext(event, context);
|
|
206
|
+
bridge.captureToolEnd();
|
|
89
207
|
});
|
|
90
|
-
pi.on("agent_settled", () => {
|
|
91
|
-
|
|
208
|
+
pi.on("agent_settled", (event, context) => {
|
|
209
|
+
trackContext(event, context);
|
|
210
|
+
bridge.settle();
|
|
92
211
|
});
|
|
93
|
-
pi.on("session_shutdown",
|
|
94
|
-
|
|
95
|
-
|
|
212
|
+
pi.on("session_shutdown", () => {
|
|
213
|
+
// 只解绑本会话的桥:长连接归全局 controller 保管,本地 /new 切换会话后自动延续。
|
|
214
|
+
bridge.cancel("Pi 会话已关闭。");
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
pi.registerTool({
|
|
218
|
+
name: "feishu_create_group",
|
|
219
|
+
label: "Create Feishu Group",
|
|
220
|
+
description:
|
|
221
|
+
"Create a Feishu group chat and invite the currently bound owner. Use when asked to 拉群、建群、创建飞书群 or create a group. This creates only the group (not a local project directory).",
|
|
222
|
+
parameters: Type.Object({
|
|
223
|
+
name: Type.String({ description: "Name for the new Feishu group chat" }),
|
|
224
|
+
}),
|
|
225
|
+
async execute(_toolCallId, params) {
|
|
226
|
+
try {
|
|
227
|
+
const chatId = await state.controller.createGroupChat(params.name.trim());
|
|
228
|
+
return {
|
|
229
|
+
content: [
|
|
230
|
+
{
|
|
231
|
+
type: "text",
|
|
232
|
+
text: `群聊「${params.name.trim()}」已创建,群聊 ID:${chatId}。已邀请当前绑定的飞书用户,并发送群欢迎消息。`,
|
|
233
|
+
},
|
|
234
|
+
],
|
|
235
|
+
details: { chatId, name: params.name.trim() },
|
|
236
|
+
};
|
|
237
|
+
} catch (error) {
|
|
238
|
+
return {
|
|
239
|
+
content: [{ type: "text", text: `创建飞书群失败:${state.controller.sanitizeError(error)}` }],
|
|
240
|
+
details: undefined,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
},
|
|
96
244
|
});
|
|
97
245
|
|
|
98
246
|
pi.registerCommand("feishu", {
|
|
99
247
|
description: "配置和管理飞书私聊连接",
|
|
100
248
|
handler: async (args, context) => {
|
|
249
|
+
latestCommandContext = context;
|
|
101
250
|
try {
|
|
102
|
-
await handleFeishuCommand(parseFeishuCommand(args), controller, context);
|
|
251
|
+
await handleFeishuCommand(parseFeishuCommand(args), state.controller, context);
|
|
103
252
|
} catch (error) {
|
|
104
|
-
context.ui.notify(`飞书操作失败:${controller.sanitizeError(error)}`, "error");
|
|
253
|
+
context.ui.notify(`飞书操作失败:${state.controller.sanitizeError(error)}`, "error");
|
|
105
254
|
}
|
|
106
255
|
},
|
|
107
256
|
});
|
|
108
257
|
}
|
|
109
258
|
|
|
259
|
+
function availableModels(context: ExtensionContext | undefined): PiModel[] {
|
|
260
|
+
const scoped = context?.scopedModels ?? [];
|
|
261
|
+
if (scoped.length > 0) return scoped.map((entry) => entry.model);
|
|
262
|
+
return withRuntimeContext(() => context?.modelRegistry.getAvailable()) ?? [];
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function resolveModel(query: string, context: ExtensionContext | undefined): PiModel {
|
|
266
|
+
const candidates = availableModels(context);
|
|
267
|
+
const needle = query.trim().toLowerCase();
|
|
268
|
+
if (!needle) throw new Error("请提供模型 ID 或名称,例如 /model sonnet。");
|
|
269
|
+
if (candidates.length === 0) throw new Error("当前没有可用模型,请先在本地 Pi 配置模型或 API Key。");
|
|
270
|
+
|
|
271
|
+
const exact = candidates.filter((model) => model.id.toLowerCase() === needle);
|
|
272
|
+
if (exact.length === 1 && exact[0]) return exact[0];
|
|
273
|
+
if (exact.length > 1) throw new Error(`“${query}”匹配到多个模型,请输入更完整的模型 ID。`);
|
|
274
|
+
|
|
275
|
+
const fuzzy = candidates.filter(
|
|
276
|
+
(model) => model.id.toLowerCase().includes(needle) || model.name.toLowerCase().includes(needle),
|
|
277
|
+
);
|
|
278
|
+
if (fuzzy.length === 1 && fuzzy[0]) return fuzzy[0];
|
|
279
|
+
if (fuzzy.length === 0) throw new Error(`未找到匹配“${query}”的模型。`);
|
|
280
|
+
throw new Error(
|
|
281
|
+
`“${query}”匹配到多个模型:${fuzzy
|
|
282
|
+
.slice(0, 3)
|
|
283
|
+
.map((model) => model.id)
|
|
284
|
+
.join("、")}…请输入更完整的模型 ID。`,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function toModelInfo(model: PiModel): PiModelInfo {
|
|
289
|
+
return { id: model.id, name: model.name || model.id, provider: String(model.provider) };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function isThinkingLevel(value: string): value is PiThinkingLevel {
|
|
293
|
+
return (THINKING_LEVELS as readonly string[]).includes(value);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function withRuntimeContext<T>(action: () => T): T | undefined {
|
|
297
|
+
try {
|
|
298
|
+
return action();
|
|
299
|
+
} catch {
|
|
300
|
+
// 会话切换后捕获的 ctx 会失效,等下一个事件刷新即可。
|
|
301
|
+
return undefined;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
110
305
|
async function handleFeishuCommand(
|
|
111
306
|
command: ParsedFeishuCommand,
|
|
112
307
|
controller: FeishuController,
|
package/src/gateway.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type {
|
|
|
3
3
|
FeishuCredentials,
|
|
4
4
|
FeishuGateway,
|
|
5
5
|
FeishuMessageHandler,
|
|
6
|
+
FeishuReactionHandler,
|
|
6
7
|
FeishuReply,
|
|
7
8
|
FeishuReplySnapshot,
|
|
8
9
|
} from "./contracts.js";
|
|
@@ -18,10 +19,16 @@ export interface NormalizedChannelMessage {
|
|
|
18
19
|
|
|
19
20
|
export interface ChannelLike {
|
|
20
21
|
onMessage(handler: (message: NormalizedChannelMessage) => Promise<void> | void): () => void;
|
|
22
|
+
onReaction(handler: FeishuReactionHandler): () => void;
|
|
21
23
|
connect(): Promise<void>;
|
|
22
24
|
disconnect(): Promise<void>;
|
|
23
|
-
|
|
25
|
+
/** Sends text and resolves with the sent Feishu message id (for later recall). */
|
|
26
|
+
sendText(to: string, text: string, replyTo?: string): Promise<string | undefined>;
|
|
24
27
|
startCardStream(to: string, card: object, replyTo?: string): Promise<ChannelCardStream>;
|
|
28
|
+
editText(messageId: string, text: string): Promise<void>;
|
|
29
|
+
addReaction(messageId: string, emojiType: string): Promise<string>;
|
|
30
|
+
removeReaction(messageId: string, reactionId: string): Promise<void>;
|
|
31
|
+
recallMessage(messageId: string): Promise<void>;
|
|
25
32
|
}
|
|
26
33
|
|
|
27
34
|
export interface ChannelCardStream {
|
|
@@ -34,8 +41,10 @@ export type ChannelFactory = (credentials: FeishuCredentials) => ChannelLike;
|
|
|
34
41
|
export class SdkFeishuGateway implements FeishuGateway {
|
|
35
42
|
private readonly credentials: FeishuCredentials;
|
|
36
43
|
private readonly channelFactory: ChannelFactory;
|
|
44
|
+
private readonly reactionHandlers = new Set<FeishuReactionHandler>();
|
|
37
45
|
private channel: ChannelLike | undefined;
|
|
38
46
|
private unsubscribe: (() => void) | undefined;
|
|
47
|
+
private reactionUnsubscribe: (() => void) | undefined;
|
|
39
48
|
|
|
40
49
|
constructor(credentials: FeishuCredentials, channelFactory: ChannelFactory = createOfficialChannel) {
|
|
41
50
|
this.credentials = credentials;
|
|
@@ -55,14 +64,20 @@ export class SdkFeishuGateway implements FeishuGateway {
|
|
|
55
64
|
text: message.content,
|
|
56
65
|
}),
|
|
57
66
|
);
|
|
67
|
+
const reactionUnsubscribe = channel.onReaction((event) => {
|
|
68
|
+
for (const reactionHandler of this.reactionHandlers) reactionHandler(event);
|
|
69
|
+
});
|
|
58
70
|
this.channel = channel;
|
|
59
71
|
this.unsubscribe = unsubscribe;
|
|
72
|
+
this.reactionUnsubscribe = reactionUnsubscribe;
|
|
60
73
|
try {
|
|
61
74
|
await channel.connect();
|
|
62
75
|
} catch (error) {
|
|
63
76
|
this.channel = undefined;
|
|
64
77
|
this.unsubscribe = undefined;
|
|
78
|
+
this.reactionUnsubscribe = undefined;
|
|
65
79
|
unsubscribe();
|
|
80
|
+
reactionUnsubscribe();
|
|
66
81
|
await channel.disconnect().catch(() => undefined);
|
|
67
82
|
throw error;
|
|
68
83
|
}
|
|
@@ -73,13 +88,69 @@ export class SdkFeishuGateway implements FeishuGateway {
|
|
|
73
88
|
this.channel = undefined;
|
|
74
89
|
this.unsubscribe?.();
|
|
75
90
|
this.unsubscribe = undefined;
|
|
91
|
+
this.reactionUnsubscribe?.();
|
|
92
|
+
this.reactionUnsubscribe = undefined;
|
|
76
93
|
if (channel) await channel.disconnect();
|
|
77
94
|
}
|
|
78
95
|
|
|
79
|
-
|
|
96
|
+
onReaction(handler: FeishuReactionHandler): () => void {
|
|
97
|
+
this.reactionHandlers.add(handler);
|
|
98
|
+
return () => this.reactionHandlers.delete(handler);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async createGroupChat(name: string, ownerOpenId: string): Promise<string> {
|
|
102
|
+
const response = await fetch("https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal", {
|
|
103
|
+
method: "POST",
|
|
104
|
+
headers: { "Content-Type": "application/json" },
|
|
105
|
+
body: JSON.stringify({ app_id: this.credentials.appId, app_secret: this.credentials.appSecret }),
|
|
106
|
+
});
|
|
107
|
+
const tokenResult = (await response.json()) as { code?: number; msg?: string; tenant_access_token?: string };
|
|
108
|
+
if (!response.ok || tokenResult.code !== 0 || !tokenResult.tenant_access_token) {
|
|
109
|
+
throw new Error(`获取飞书 tenant token 失败:${tokenResult.msg ?? response.statusText}`);
|
|
110
|
+
}
|
|
111
|
+
const createResponse = await fetch(`https://open.feishu.cn/open-apis/im/v1/chats?uuid=${crypto.randomUUID()}`, {
|
|
112
|
+
method: "POST",
|
|
113
|
+
headers: {
|
|
114
|
+
Authorization: `Bearer ${tokenResult.tenant_access_token}`,
|
|
115
|
+
"Content-Type": "application/json",
|
|
116
|
+
},
|
|
117
|
+
body: JSON.stringify({ name, description: "Pi Feishu 创建的协作群", user_id_list: [ownerOpenId] }),
|
|
118
|
+
});
|
|
119
|
+
const result = (await createResponse.json()) as { code?: number; msg?: string; data?: { chat_id?: string } };
|
|
120
|
+
if (!createResponse.ok || result.code !== 0 || !result.data?.chat_id) {
|
|
121
|
+
throw new Error(`飞书创建群聊失败:${result.msg ?? createResponse.statusText}`);
|
|
122
|
+
}
|
|
123
|
+
return result.data.chat_id;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async sendText(chatId: string, text: string, replyTo?: string): Promise<string | undefined> {
|
|
80
127
|
const channel = this.channel;
|
|
81
128
|
if (!channel) throw new Error("飞书长连接尚未启动。");
|
|
82
|
-
|
|
129
|
+
return channel.sendText(chatId, text, replyTo);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async addReaction(messageId: string, emojiType: string): Promise<string> {
|
|
133
|
+
const channel = this.channel;
|
|
134
|
+
if (!channel) throw new Error("飞书长连接尚未启动。");
|
|
135
|
+
return channel.addReaction(messageId, emojiType);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async removeReaction(messageId: string, reactionId: string): Promise<void> {
|
|
139
|
+
const channel = this.channel;
|
|
140
|
+
if (!channel) throw new Error("飞书长连接尚未启动。");
|
|
141
|
+
await channel.removeReaction(messageId, reactionId);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async recallMessage(messageId: string): Promise<void> {
|
|
145
|
+
const channel = this.channel;
|
|
146
|
+
if (!channel) throw new Error("飞书长连接尚未启动。");
|
|
147
|
+
await channel.recallMessage(messageId);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async editText(messageId: string, text: string): Promise<void> {
|
|
151
|
+
const channel = this.channel;
|
|
152
|
+
if (!channel) throw new Error("飞书长连接尚未启动。");
|
|
153
|
+
await channel.editText(messageId, text);
|
|
83
154
|
}
|
|
84
155
|
|
|
85
156
|
async beginReply(chatId: string, replyTo: string): Promise<FeishuReply> {
|
|
@@ -118,6 +189,7 @@ function createOfficialChannel(credentials: FeishuCredentials): ChannelLike {
|
|
|
118
189
|
},
|
|
119
190
|
policy: {
|
|
120
191
|
dmMode: "open",
|
|
192
|
+
// Controller enforces owner identity and only accepts groups it created.
|
|
121
193
|
groupAllowlist: [],
|
|
122
194
|
requireMention: true,
|
|
123
195
|
},
|
|
@@ -139,6 +211,17 @@ class OfficialChannelAdapter implements ChannelLike {
|
|
|
139
211
|
return this.channel.on("message", (message) => handler(toChannelMessage(message)));
|
|
140
212
|
}
|
|
141
213
|
|
|
214
|
+
onReaction(handler: FeishuReactionHandler): () => void {
|
|
215
|
+
return this.channel.on("reaction", (event) =>
|
|
216
|
+
handler({
|
|
217
|
+
messageId: event.messageId,
|
|
218
|
+
operatorOpenId: event.operator.openId,
|
|
219
|
+
emojiType: event.emojiType,
|
|
220
|
+
action: event.action,
|
|
221
|
+
}),
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
142
225
|
async connect(): Promise<void> {
|
|
143
226
|
await this.channel.connect();
|
|
144
227
|
}
|
|
@@ -147,8 +230,25 @@ class OfficialChannelAdapter implements ChannelLike {
|
|
|
147
230
|
await this.channel.disconnect();
|
|
148
231
|
}
|
|
149
232
|
|
|
150
|
-
async sendText(to: string, text: string, replyTo?: string): Promise<
|
|
151
|
-
await this.channel.send(to, { text }, replyTo ? { replyTo } : undefined);
|
|
233
|
+
async sendText(to: string, text: string, replyTo?: string): Promise<string | undefined> {
|
|
234
|
+
const result = await this.channel.send(to, { text }, replyTo ? { replyTo } : undefined);
|
|
235
|
+
return result.messageId;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async editText(messageId: string, text: string): Promise<void> {
|
|
239
|
+
await this.channel.editMessage(messageId, text);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async addReaction(messageId: string, emojiType: string): Promise<string> {
|
|
243
|
+
return this.channel.addReaction(messageId, emojiType);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async removeReaction(messageId: string, reactionId: string): Promise<void> {
|
|
247
|
+
await this.channel.removeReaction(messageId, reactionId);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async recallMessage(messageId: string): Promise<void> {
|
|
251
|
+
await this.channel.recallMessage(messageId);
|
|
152
252
|
}
|
|
153
253
|
|
|
154
254
|
async startCardStream(to: string, card: object, replyTo?: string): Promise<ChannelCardStream> {
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import type { FeishuStatus, PiRuntime, PiRuntimeSnapshot } from "./contracts.js";
|
|
2
|
+
|
|
3
|
+
export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
4
|
+
|
|
5
|
+
export const REMOTE_SLASH_COMMANDS: Record<string, string> = {
|
|
6
|
+
"/help": "显示远程命令帮助",
|
|
7
|
+
"/new": "新建 Pi 会话(丢弃当前对话上下文)",
|
|
8
|
+
"/stop": "中止当前任务,并跳过排队中的消息",
|
|
9
|
+
"/status": "查看飞书连接与 Pi 运行状态",
|
|
10
|
+
"/compact": "压缩当前会话上下文",
|
|
11
|
+
"/thinking": "查看或设置思考档位:/thinking [off|minimal|low|medium|high|xhigh|max]",
|
|
12
|
+
"/model": "查看当前模型,或切换:/model <模型ID或名称>",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export interface ParsedRemoteCommand {
|
|
16
|
+
name: string;
|
|
17
|
+
args: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface RemoteCommandContext {
|
|
21
|
+
runtime: PiRuntime | undefined;
|
|
22
|
+
status: () => Promise<FeishuStatus>;
|
|
23
|
+
/** Aborts queued messages after the current one; returns how many were skipped. */
|
|
24
|
+
stopQueue?: () => number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function parseRemoteCommand(input: string): ParsedRemoteCommand | undefined {
|
|
28
|
+
const trimmed = input.trim();
|
|
29
|
+
if (!trimmed.startsWith("/")) return undefined;
|
|
30
|
+
const separator = trimmed.search(/\s/);
|
|
31
|
+
const name = separator === -1 ? trimmed : trimmed.slice(0, separator);
|
|
32
|
+
const args = separator === -1 ? "" : trimmed.slice(separator + 1).trim();
|
|
33
|
+
return { name: name.toLowerCase(), args };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function renderRemoteHelp(): string {
|
|
37
|
+
const lines = Object.entries(REMOTE_SLASH_COMMANDS).map(([name, description]) => ` ${name} — ${description}`);
|
|
38
|
+
return ["飞书远程命令:", ...lines, "", "其余消息会直接发送给当前 Pi 会话处理。"].join("\n");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function renderRemoteStatus(status: FeishuStatus, snapshot: PiRuntimeSnapshot | undefined): string {
|
|
42
|
+
const lines = ["飞书状态", ` 连接:${status.running ? "已连接" : "未连接"}`, ` 队列:${status.pendingMessages}`];
|
|
43
|
+
if (status.configured) {
|
|
44
|
+
lines.push(` Owner:${status.ownerOpenId ? "已绑定" : "未绑定"}`);
|
|
45
|
+
lines.push("Pi 状态");
|
|
46
|
+
lines.push(` 模型:${snapshot?.model ?? "未知"}`);
|
|
47
|
+
lines.push(` 思考:${snapshot?.thinkingLevel ?? "未知"}`);
|
|
48
|
+
lines.push(` 上下文:${formatContextPercent(snapshot?.contextPercent)}`);
|
|
49
|
+
lines.push(` 运行:${snapshot?.streaming ? "运行中" : "空闲"}`);
|
|
50
|
+
}
|
|
51
|
+
return lines.join("\n");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function executeRemoteCommand(input: string, context: RemoteCommandContext): Promise<string> {
|
|
55
|
+
const command = parseRemoteCommand(input);
|
|
56
|
+
if (!command) return unknownCommandReply(input);
|
|
57
|
+
switch (command.name) {
|
|
58
|
+
case "/help":
|
|
59
|
+
return renderRemoteHelp();
|
|
60
|
+
case "/status": {
|
|
61
|
+
const status = await context.status();
|
|
62
|
+
return renderRemoteStatus(status, context.runtime?.snapshot());
|
|
63
|
+
}
|
|
64
|
+
case "/stop":
|
|
65
|
+
return executeStop(context);
|
|
66
|
+
case "/compact":
|
|
67
|
+
return executeCompact(context.runtime);
|
|
68
|
+
case "/new":
|
|
69
|
+
return executeNewSession(context.runtime);
|
|
70
|
+
case "/thinking":
|
|
71
|
+
return executeThinking(command.args, context.runtime);
|
|
72
|
+
case "/model":
|
|
73
|
+
return executeModel(command.args, context.runtime);
|
|
74
|
+
default:
|
|
75
|
+
return unknownCommandReply(command.name);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function executeStop(context: RemoteCommandContext): string {
|
|
80
|
+
const runtime = context.runtime;
|
|
81
|
+
const running = Boolean(runtime && !runtime.isIdle());
|
|
82
|
+
if (running) runtime?.abort();
|
|
83
|
+
const skipped = context.stopQueue?.() ?? 0;
|
|
84
|
+
if (!running && skipped === 0) return "当前没有正在运行的 Pi 任务。";
|
|
85
|
+
const suffix = skipped > 0 ? `,并跳过队列中的 ${skipped} 条消息` : "";
|
|
86
|
+
return `已发送中止信号${suffix}。`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function executeCompact(runtime: PiRuntime | undefined): string {
|
|
90
|
+
if (!runtime) return "Pi 运行时尚未就绪,请稍后再试。";
|
|
91
|
+
runtime.compact();
|
|
92
|
+
return "已开始压缩上下文,完成后继续对话即可。";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function executeNewSession(runtime: PiRuntime | undefined): Promise<string> {
|
|
96
|
+
if (!runtime) return "远程新建会话暂不可用:请先在本地 Pi 执行任意 /feishu 命令,再通过飞书发送 /new。";
|
|
97
|
+
const created = await runtime.newSession();
|
|
98
|
+
return created
|
|
99
|
+
? "已新建 Pi 会话,接下来是一个全新的对话。"
|
|
100
|
+
: "新建会话未完成(本地已取消或暂不可用)。请在本地 Pi 执行 /feishu 命令后重试。";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function executeThinking(args: string, runtime: PiRuntime | undefined): Promise<string> {
|
|
104
|
+
const levels = THINKING_LEVELS.join(" / ");
|
|
105
|
+
if (!args) {
|
|
106
|
+
const current = runtime?.snapshot().thinkingLevel;
|
|
107
|
+
const line = current ? `当前思考档位:${current}` : "当前思考档位未知。";
|
|
108
|
+
return `${line}\n可用档位:${levels}\n示例:/thinking high`;
|
|
109
|
+
}
|
|
110
|
+
if (!runtime) return "Pi 运行时尚未就绪,请稍后再试。";
|
|
111
|
+
const level = args.toLowerCase();
|
|
112
|
+
const applied = await runtime.setThinkingLevel(level);
|
|
113
|
+
return applied ? `已切换思考档位:${level}。` : `无法设置思考档位“${args}”。\n可用档位:${levels}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function executeModel(args: string, runtime: PiRuntime | undefined): Promise<string> {
|
|
117
|
+
if (!runtime) return "Pi 运行时尚未就绪,请稍后再试。";
|
|
118
|
+
if (!args) {
|
|
119
|
+
const current = runtime.snapshot().model;
|
|
120
|
+
const models = runtime.listModels();
|
|
121
|
+
const hint =
|
|
122
|
+
models.length > 0
|
|
123
|
+
? `共 ${models.length} 个可用模型,使用 /model <模型ID或名称> 切换。`
|
|
124
|
+
: "使用 /model <模型ID或名称> 切换。";
|
|
125
|
+
return current ? `当前模型:${current}\n${hint}` : `当前模型未知。\n${hint}`;
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
const model = await runtime.switchModel(args);
|
|
129
|
+
return `已切换模型:${model.name}(${model.id})。`;
|
|
130
|
+
} catch (error) {
|
|
131
|
+
return `切换模型失败:${error instanceof Error ? error.message : String(error)}`;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function unknownCommandReply(input: string): string {
|
|
136
|
+
return `❓ 未知命令:${input.trim().split(/\s+/)[0]}\n\n输入 /help 查看可用命令。`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function formatContextPercent(percent: number | undefined): string {
|
|
140
|
+
if (percent === undefined || percent === null || Number.isNaN(percent)) return "未知";
|
|
141
|
+
return `${Math.round(percent)}%`;
|
|
142
|
+
}
|