opencode-feishu-plugin 0.2.13 → 0.2.14

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.en.md CHANGED
@@ -22,6 +22,7 @@ Connect [OpenCode](https://opencode.ai) to Feishu/Lark: **one Feishu topic = one
22
22
  | 📊 **Real-time visible** | "Thinking" receipt → live tool-call cards → streaming text updates; footer shows the current model |
23
23
  | ⏹ **Controllable** | Every reply card has a "force stop" button; a watchdog auto-interrupts stuck sessions; native queue with `/steer` `/now` to cut in |
24
24
  | 📎 **Images / files** | Images and files sent in Feishu are downloaded and attached to the session, so vision/file-capable models can see and read them |
25
+ | 🧭 **One-sentence session** | Send a task in the main chat — the AI judges intent, finds the right working directory, and creates + starts the session in one tap |
25
26
  | 🖱 **Main-window menu** | Two fixed quick buttons above the chat input — "New session" and "Session list" (bot custom menu) — one tap to fire them |
26
27
  | 🚫 **No ports** | Full long-connection; no inbound port needed on the server |
27
28
 
@@ -127,6 +128,22 @@ After configuring the menu (setup step 6), two quick buttons stay pinned **above
127
128
  - After changing the menu, **re-publish the app version** (per the console: takes effect within ~5 minutes after publishing);
128
129
  - Message the bot at least once before using the menu (the plugin remembers the p2p chat from messages; any normal usage satisfies this).
129
130
 
131
+ ### One-sentence session creation (AI finds the directory)
132
+
133
+ When you send plain text in the main chat, the AI first judges the **intent** and picks the best-matching working directory from candidates (recent dirs + local session dirs), then replies with a proposal card:
134
+
135
+ ```
136
+ You: fix the zlib download bug
137
+ → 🆕 Suggested new session
138
+ Task: fix download bug | Dir: `/Users/code/zlib`
139
+ [✅ Create & send] [❌ Cancel]
140
+ ```
141
+
142
+ "Create & send" = create the session + auto-open a topic + send your original message in to start working (permission defaults to "Editable"; adjust later with `/perm`).
143
+
144
+ - Chat/unrecognized/dir-not-in-candidates → falls back to the console hint card, **never creates a wrong session**; the model's path must match a candidate (anti-hallucination);
145
+ - Proposal cards are valid for 1 hour; set `quickNew: false` to go back to the console-only mode.
146
+
130
147
  ### Inside a topic (work)
131
148
 
132
149
  A topic = a session; sending plain text is giving the AI a command.
@@ -174,6 +191,7 @@ When the agent asks via `question` or other form tools, the form becomes a Feish
174
191
  | `logFile` | string \| boolean | — | `true` = write `<configDir>/plugins/feishu.log`; recommended in server mode |
175
192
  | `approvalTtlMs` | number | `600000` | Approval token / card validity |
176
193
  | `staleExecutionMs` | number | `300000` | Watchdog threshold (0–60 min; **0 = disabled**; sessions waiting on a form / pending approval are never killed) |
194
+ | `quickNew` | boolean | `true` | Main-chat "one-sentence session": AI judges intent + finds the directory, proposal card creates in one tap; `false` = console-only mode |
177
195
  | `gatewayLocation` | string | — | Only start the gateway at this location (or its subdirectories); empty = any location |
178
196
 
179
197
  Full config (including `cardMaxTables`, `topicStatus*`, `resumeSummary*`, `keepalive*`, `gatewayMatchGraceMs`) → [docs/advanced.en.md](./docs/advanced.en.md#full-configuration).
package/README.md CHANGED
@@ -22,6 +22,7 @@
22
22
  | 📊 **实时可见** | 「思考中」回执 → 工具调用实时上卡 → 文本流式更新,页脚显示当前模型 |
23
23
  | ⏹ **可控可停** | 每张回复卡带「强制停止」;看门狗自动中断卡死会话;忙时原生排队,`/steer` `/now` 可插队 |
24
24
  | 📎 **图片 / 文件** | 飞书里的图片 / 文件自动下载并挂进会话,支持视觉 / 文件的模型直接看图、读文件 |
25
+ | 🧭 **一句话建会话** | 主聊天流发任务文本,AI 自动判断意图、找好工作目录,一键建会话并开始处理 |
25
26
  | 🖱 **主窗口菜单** | 机器人输入框上固定「新建会话」「会话列表」快捷按钮(机器人自定义菜单),一点即用 |
26
27
  | 🚫 **无端口** | 全程长连接,服务器无需开放任何入站端口 |
27
28
 
@@ -127,6 +128,22 @@ tail -f ~/.config/opencode/plugins/feishu.log # 应看到「飞书长连接已
127
128
  - 菜单配置修改后需**重新发布版本**(官方说明:发版成功后约 5 分钟内生效);
128
129
  - 首次使用菜单前,先给机器人发过至少一条消息(插件据消息记住单聊会话;正常使用必然满足)。
129
130
 
131
+ ### 一句话建会话(AI 找目录)
132
+
133
+ 主聊天流发普通文本时,AI 会先判断**意图**,并从候选目录(最近使用 + 本机会话目录)里挑出最匹配的工作目录,回一张建议卡:
134
+
135
+ ```
136
+ 你:帮我修一下 zlib 的下载 bug
137
+ → 🆕 建议新建会话
138
+ 任务:修下载 bug | 目录:`/Users/code/zlib`
139
+ [✅ 创建并发送] [❌ 取消]
140
+ ```
141
+
142
+ 点「创建并发送」= 建会话 + 自动开话题 + 把原消息发进去开始处理(权限默认「可编辑」,进话题后可用 `/perm` 调整)。
143
+
144
+ - 闲聊 / 无法识别 / 目录不在候选中 → 回退为管理台提示卡,**不会误建会话**;模型给出的路径必须命中候选清单(防幻觉);
145
+ - 建议卡有效期 1 小时;不想要此功能可设 `quickNew: false` 回到纯管理台模式。
146
+
130
147
  ### 话题内(干活)
131
148
 
132
149
  一个话题 = 一个会话,发普通文本就是给 AI 下指令。
@@ -174,6 +191,7 @@ agent 反问时表单会变成飞书卡片,**点按钮或在话题里直接发
174
191
  | `logFile` | string \| boolean | — | `true` = 写 `<configDir>/plugins/feishu.log`,服务模式建议开启 |
175
192
  | `approvalTtlMs` | number | `600000` | 审批 token / 卡片有效期 |
176
193
  | `staleExecutionMs` | number | `300000` | 看门狗阈值(0–60 分钟;**0 = 关闭看门狗**;待答表单 / 未决审批期间不判卡死) |
194
+ | `quickNew` | boolean | `true` | 主聊天流「一句话建会话」:AI 判断意图 + 找目录,建议卡一键创建;`false` = 回到纯管理台模式 |
177
195
  | `gatewayLocation` | string | — | 只在该 location(及其子目录)启动网关;留空 = 任意 location 生效 |
178
196
 
179
197
  完整配置(含 `cardMaxTables`、`topicStatus*`、`resumeSummary*`、`keepalive*`、`gatewayMatchGraceMs` 等进阶项)见 [docs/advanced.md](./docs/advanced.md#完整配置项)。
package/dist/index.js CHANGED
@@ -144037,6 +144037,7 @@ function resolveConfig2(raw, env = process.env, deps = {}) {
144037
144037
  );
144038
144038
  const attachmentTimeoutMs = clamp4(asNumber(merged.attachmentTimeoutMs, 3e4), 5e3, 12e4);
144039
144039
  const attachmentsDirRaw = asString(merged.attachmentsDir).trim();
144040
+ const quickNew = asBoolean(merged.quickNew, true);
144040
144041
  const keepaliveIntervalMs = clamp4(
144041
144042
  asNumber(merged.keepaliveIntervalMs, 20 * 60 * 1e3),
144042
144043
  5 * 60 * 1e3,
@@ -144101,7 +144102,8 @@ function resolveConfig2(raw, env = process.env, deps = {}) {
144101
144102
  acceptAttachments,
144102
144103
  attachmentMaxBytes,
144103
144104
  attachmentTimeoutMs,
144104
- ...attachmentsDirRaw ? { attachmentsDir: attachmentsDirRaw } : {}
144105
+ ...attachmentsDirRaw ? { attachmentsDir: attachmentsDirRaw } : {},
144106
+ quickNew
144105
144107
  };
144106
144108
  }
144107
144109
  function loadConfigFile(env, deps, warnings) {
@@ -146078,6 +146080,14 @@ function withAssistantID(state, assistantMessageID) {
146078
146080
  if (!assistantMessageID || assistantMessageID === state.assistantMessageID) return state;
146079
146081
  return { ...state, assistantMessageID };
146080
146082
  }
146083
+ function lastPrefixTextIndex(blocks, text) {
146084
+ for (let i = blocks.length - 1; i >= 0; i -= 1) {
146085
+ const block = blocks[i];
146086
+ if (block.kind !== "text") continue;
146087
+ if (block.content && text.startsWith(block.content)) return i;
146088
+ }
146089
+ return -1;
146090
+ }
146081
146091
  function patchTool(state, id2, update2) {
146082
146092
  let found = false;
146083
146093
  const blocks = state.blocks.map((b) => {
@@ -146118,21 +146128,62 @@ function reduce6(state, event) {
146118
146128
  if (!event.delta) return state;
146119
146129
  const base2 = stepTransition(state, event.assistantMessageID);
146120
146130
  const last = base2[base2.length - 1];
146121
- const blocks = last && last.kind === "text" && last.streaming ? [...base2.slice(0, -1), { ...last, content: last.content + event.delta }] : [...base2, { kind: "text", content: event.delta, streaming: true }];
146131
+ const blocks = last && last.kind === "text" && last.streaming ? [...base2.slice(0, -1), { ...last, content: last.content + event.delta, ...event.assistantMessageID ? { msg: event.assistantMessageID } : {} }] : [...base2, { kind: "text", content: event.delta, streaming: true, ...event.assistantMessageID ? { msg: event.assistantMessageID } : {} }];
146122
146132
  const next = withAssistantID(state, event.assistantMessageID);
146123
146133
  return { ...next, blocks, footer: "streaming", terminal: "running" };
146124
146134
  }
146125
146135
  case "text.ended": {
146126
146136
  const blocks = state.blocks;
146127
- const last = blocks[blocks.length - 1];
146128
- let nextBlocks;
146129
- if (last && last.kind === "text" && last.streaming) {
146130
- nextBlocks = [...blocks.slice(0, -1), { ...last, content: event.text ?? last.content, streaming: false }];
146131
- } else if (event.text) {
146132
- nextBlocks = [...closeStreamingText(blocks), { kind: "text", content: event.text, streaming: false }];
146133
- } else {
146134
- nextBlocks = closeStreamingText(blocks);
146137
+ const endedText = event.text;
146138
+ const endedMsg = event.assistantMessageID;
146139
+ if (!endedMsg) {
146140
+ const last = blocks[blocks.length - 1];
146141
+ let nextBlocks2;
146142
+ if (last && last.kind === "text" && last.streaming) {
146143
+ nextBlocks2 = [...blocks.slice(0, -1), { ...last, content: endedText ?? last.content, streaming: false }];
146144
+ } else if (endedText) {
146145
+ const idx = lastPrefixTextIndex(blocks, endedText);
146146
+ nextBlocks2 = idx >= 0 ? blocks.map((b, i) => i === idx && b.kind === "text" ? { ...b, content: endedText, streaming: false } : b) : [...closeStreamingText(blocks), { kind: "text", content: endedText, streaming: false }];
146147
+ } else {
146148
+ nextBlocks2 = closeStreamingText(blocks);
146149
+ }
146150
+ return { ...state, blocks: nextBlocks2, footer: "streaming" };
146135
146151
  }
146152
+ let targetIdx = -1;
146153
+ for (let i = blocks.length - 1; i >= 0; i -= 1) {
146154
+ const b = blocks[i];
146155
+ if (b.kind === "text" && b.msg === endedMsg) {
146156
+ targetIdx = i;
146157
+ break;
146158
+ }
146159
+ }
146160
+ const tail = blocks[blocks.length - 1];
146161
+ if (targetIdx < 0 && tail && tail.kind === "text" && tail.streaming) targetIdx = blocks.length - 1;
146162
+ if (targetIdx < 0) {
146163
+ if (!endedText) return { ...state, blocks: closeStreamingText(blocks), footer: "streaming" };
146164
+ return {
146165
+ ...state,
146166
+ blocks: [
146167
+ ...closeStreamingText(blocks),
146168
+ { kind: "text", content: endedText, streaming: false, msg: endedMsg }
146169
+ ],
146170
+ footer: "streaming"
146171
+ };
146172
+ }
146173
+ const target = blocks[targetIdx];
146174
+ let earlierLen = 0;
146175
+ for (let i = 0; i < targetIdx; i += 1) {
146176
+ const b = blocks[i];
146177
+ if (b.kind === "text" && b.msg === endedMsg) earlierLen += b.content.length;
146178
+ }
146179
+ let content = target.kind === "text" ? target.content : "";
146180
+ if (endedText !== void 0 && endedText.length >= earlierLen) {
146181
+ const suffix = endedText.slice(earlierLen);
146182
+ if (suffix.length >= content.length) content = suffix;
146183
+ }
146184
+ const nextBlocks = blocks.map(
146185
+ (b, i) => i === targetIdx && b.kind === "text" ? { ...b, content, streaming: false } : b
146186
+ );
146136
146187
  return { ...state, blocks: nextBlocks, footer: "streaming" };
146137
146188
  }
146138
146189
  case "tool.input.started": {
@@ -146830,6 +146881,15 @@ function createRunController(deps) {
146830
146881
  }
146831
146882
  if (!card) return;
146832
146883
  update2(card, event, false);
146884
+ if (isActivityEvent(event.type)) {
146885
+ deps.log.debug("\u8FD0\u884C\u5361\u5757\u7ED3\u6784", {
146886
+ sessionID,
146887
+ event: event.type,
146888
+ blocks: card.state.blocks.map(
146889
+ (b) => b.kind === "text" ? `T${b.streaming ? "*" : ""}${b.content.length}` : `tool:${b.tool.name}`
146890
+ )
146891
+ });
146892
+ }
146833
146893
  },
146834
146894
  hasActive(sessionID) {
146835
146895
  return sessions.get(sessionID)?.active !== void 0;
@@ -148502,6 +148562,128 @@ function parsePage(value3) {
148502
148562
  const parsed = typeof value3 === "number" ? value3 : typeof value3 === "string" ? Number.parseInt(value3, 10) : Number.NaN;
148503
148563
  return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 0;
148504
148564
  }
148565
+
148566
+ // src/feishu/quick-new-cards.ts
148567
+ function button3(text, type, value3) {
148568
+ return {
148569
+ tag: "button",
148570
+ text: { tag: "plain_text", content: text },
148571
+ type,
148572
+ behaviors: [{ type: "callback", value: value3 }]
148573
+ };
148574
+ }
148575
+ function buildQuickNewThinkingCard() {
148576
+ return {
148577
+ schema: "2.0",
148578
+ config: { update_multi: true },
148579
+ header: { title: { tag: "plain_text", content: "\u{1F914} \u6B63\u5728\u8BC6\u522B\u610F\u56FE\u4E0E\u76EE\u5F55\u2026" }, template: "blue" },
148580
+ body: {
148581
+ elements: [
148582
+ { tag: "markdown", content: "\u6B63\u5728\u5224\u65AD\u8FD9\u6761\u6D88\u606F\u662F\u5426\u9700\u8981\u65B0\u5EFA\u4F1A\u8BDD\uFF0C\u4EE5\u53CA\u6700\u5408\u9002\u7684\u5DE5\u4F5C\u76EE\u5F55\u2026" }
148583
+ ]
148584
+ }
148585
+ };
148586
+ }
148587
+ function buildQuickNewProposalCard(input) {
148588
+ const preview = input.textPreview.trim().replace(/\n+/g, " ").slice(0, 120);
148589
+ const lines = [
148590
+ `**\u4EFB\u52A1**\uFF1A${input.title}`,
148591
+ `**\u76EE\u5F55**\uFF1A\`${input.directory}\``,
148592
+ ...input.reason ? [`**\u4F9D\u636E**\uFF1A${input.reason}`] : [],
148593
+ "",
148594
+ `\u539F\u6D88\u606F\uFF1A${preview}`
148595
+ ];
148596
+ const elements = [
148597
+ { tag: "markdown", content: truncateCardContent(lines.join("\n")) },
148598
+ {
148599
+ tag: "markdown",
148600
+ content: "\u70B9\u300C\u521B\u5EFA\u5E76\u53D1\u9001\u300D= \u5EFA\u4F1A\u8BDD + \u81EA\u52A8\u5F00\u8BDD\u9898 + \u628A\u539F\u6D88\u606F\u53D1\u8FDB\u53BB\u5F00\u59CB\u5904\u7406\u3002",
148601
+ text_size: "notation"
148602
+ },
148603
+ button3("\u2705 \u521B\u5EFA\u5E76\u53D1\u9001", "primary", { cmd: "quicknew", op: "create", id: input.id }),
148604
+ button3("\u274C \u53D6\u6D88", "default", { cmd: "quicknew", op: "cancel", id: input.id })
148605
+ ];
148606
+ return {
148607
+ schema: "2.0",
148608
+ config: { update_multi: true },
148609
+ header: { title: { tag: "plain_text", content: "\u{1F195} \u5EFA\u8BAE\u65B0\u5EFA\u4F1A\u8BDD" }, template: "turquoise" },
148610
+ body: { elements }
148611
+ };
148612
+ }
148613
+ function buildQuickNewResolvedCard(title, note2, template2 = "grey") {
148614
+ return {
148615
+ schema: "2.0",
148616
+ config: { update_multi: true },
148617
+ header: { title: { tag: "plain_text", content: title }, template: template2 },
148618
+ body: { elements: [{ tag: "markdown", content: truncateCardContent(note2) }] }
148619
+ };
148620
+ }
148621
+ function parseQuickNewActionValue(raw) {
148622
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
148623
+ const value3 = raw;
148624
+ if (value3.cmd !== "quicknew") return void 0;
148625
+ const op = value3.op === "create" || value3.op === "cancel" ? value3.op : void 0;
148626
+ const id2 = typeof value3.id === "string" ? value3.id.trim() : "";
148627
+ if (!op || !id2) return void 0;
148628
+ return { op, id: id2 };
148629
+ }
148630
+
148631
+ // src/session/quick-new.ts
148632
+ var QUICK_NEW_INSTRUCTION = [
148633
+ "\u4F60\u662F\u98DE\u4E66 AI \u52A9\u624B\u300C\u7BA1\u7406\u53F0\u300D\u7684\u610F\u56FE\u8BC6\u522B\u5668\u3002\u7528\u6237\u5728\u7BA1\u7406\u53F0\uFF08\u8FD8\u6CA1\u6709\u4F1A\u8BDD\uFF09\u53D1\u6765\u4E00\u6761\u6D88\u606F\u3002",
148634
+ "\u8BF7\u5224\u65AD\u5B83\u662F\u4E0D\u662F\u4E00\u4E2A\u9700\u8981\u65B0\u5EFA\u4F1A\u8BDD\u53BB\u6267\u884C\u7684\u5F00\u53D1/\u64CD\u4F5C\u4EFB\u52A1\uFF0C\u5E76\u4ECE\u5019\u9009\u76EE\u5F55\u4E2D\u9009\u51FA\u6700\u5408\u9002\u7684\u5DE5\u4F5C\u76EE\u5F55\u3002",
148635
+ "\u53EA\u8F93\u51FA\u4E00\u4E2A JSON \u5BF9\u8C61\uFF0C\u4E0D\u8981\u4EFB\u4F55\u5176\u4ED6\u6587\u5B57\uFF1A",
148636
+ '{"intent":"task|chat","dir":"<\u7EDD\u5BF9\u8DEF\u5F84\u6216\u7A7A\u5B57\u7B26\u4E32>","title":"<\u4E0D\u8D85\u8FC720\u5B57\u7684\u4F1A\u8BDD\u6807\u9898>","reason":"<\u4E00\u53E5\u8BDD\u7406\u7531>"}',
148637
+ "\u89C4\u5219\uFF1A",
148638
+ "- \u95F2\u804A\u3001\u95EE\u5019\u3001\u8BE2\u95EE\u7528\u6CD5 \u2192 intent=chat\uFF0Cdir \u7559\u7A7A\u3002",
148639
+ "- \u660E\u786E\u7684\u5F00\u53D1/\u64CD\u4F5C\u4EFB\u52A1\uFF08\u6539 bug\u3001\u5199\u4EE3\u7801\u3001\u6574\u7406\u6587\u4EF6\u3001\u8DD1\u811A\u672C\u7B49\uFF09\u2192 intent=task\u3002",
148640
+ "- dir \u53EA\u80FD\u4ECE\u5019\u9009\u76EE\u5F55\u4E2D\u9009\u62E9\uFF08\u8BED\u4E49\u6700\u5339\u914D\u7684\u90A3\u4E2A\uFF09\uFF1B\u627E\u4E0D\u5230\u5408\u9002\u7684\u5C31\u7559\u7A7A\u5B57\u7B26\u4E32\u3002"
148641
+ ].join("\n");
148642
+ function buildQuickNewPrompt(text, candidates) {
148643
+ const list = candidates.slice(0, 40).map((c) => `- ${c.path}${c.label ? `\uFF08${c.label}\uFF09` : ""}`).join("\n");
148644
+ return [
148645
+ QUICK_NEW_INSTRUCTION,
148646
+ "",
148647
+ "\u5019\u9009\u76EE\u5F55\uFF1A",
148648
+ list || "\uFF08\u65E0\uFF09",
148649
+ "",
148650
+ "\u7528\u6237\u6D88\u606F\uFF1A",
148651
+ text.slice(0, 2e3)
148652
+ ].join("\n");
148653
+ }
148654
+ function parseQuickNewDecision(raw) {
148655
+ if (!raw) return void 0;
148656
+ const text = raw.trim();
148657
+ const start2 = text.indexOf("{");
148658
+ const end = text.lastIndexOf("}");
148659
+ if (start2 < 0 || end <= start2) return void 0;
148660
+ let parsed;
148661
+ try {
148662
+ parsed = JSON.parse(text.slice(start2, end + 1));
148663
+ } catch {
148664
+ return void 0;
148665
+ }
148666
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
148667
+ const obj = parsed;
148668
+ const intent = obj.intent === "task" ? "task" : obj.intent === "chat" ? "chat" : void 0;
148669
+ if (!intent) return void 0;
148670
+ const dir = typeof obj.dir === "string" ? obj.dir.trim() : typeof obj.directory === "string" ? obj.directory.trim() : "";
148671
+ const title = typeof obj.title === "string" ? obj.title.trim().slice(0, 30) : "";
148672
+ const reason = typeof obj.reason === "string" ? obj.reason.trim().slice(0, 200) : "";
148673
+ return {
148674
+ intent,
148675
+ ...dir ? { directory: dir } : {},
148676
+ ...title ? { title } : {},
148677
+ ...reason ? { reason } : {}
148678
+ };
148679
+ }
148680
+ function matchCandidateDirectory(directory, candidates) {
148681
+ if (!directory) return void 0;
148682
+ const normalize2 = (p) => p.length > 1 && p.endsWith("/") ? p.slice(0, -1) : p;
148683
+ const target = normalize2(directory);
148684
+ const hit = candidates.find((c) => normalize2(c.path) === target);
148685
+ return hit?.path;
148686
+ }
148505
148687
  var SYSTEM_DIRS = [
148506
148688
  "/etc",
148507
148689
  "/usr",
@@ -149253,7 +149435,7 @@ var SETUP_FORM_FIELDS = {
149253
149435
  var SETUP_FORM_MAX_MODELS = 15;
149254
149436
  var SETUP_FORM_DEFAULT_PERM = "edit";
149255
149437
  var SETUP_FORM_DIR_CUSTOM = "__custom__";
149256
- function button3(text, type, value3) {
149438
+ function button4(text, type, value3) {
149257
149439
  return {
149258
149440
  tag: "button",
149259
149441
  text: { tag: "plain_text", content: text },
@@ -149296,28 +149478,28 @@ function buildModelCard(input) {
149296
149478
  if (input.page <= 0 && recent.length > 0) {
149297
149479
  for (const model of recent) {
149298
149480
  const isCurrent = input.current && refKey(input.current) === refKey(model);
149299
- elements.push(button3(`\u{1F9E0} ${shorten(modelLabel(model), 30)}`, isCurrent ? "primary" : "default", modelValue(model, input.sid)));
149481
+ elements.push(button4(`\u{1F9E0} ${shorten(modelLabel(model), 30)}`, isCurrent ? "primary" : "default", modelValue(model, input.sid)));
149300
149482
  }
149301
149483
  if (input.models.length > recent.length) {
149302
- elements.push(button3("\u66F4\u591A \u25B8", "default", moreValue(1, input.sid)));
149484
+ elements.push(button4("\u66F4\u591A \u25B8", "default", moreValue(1, input.sid)));
149303
149485
  }
149304
149486
  } else {
149305
149487
  const pageNum = input.page <= 0 ? 1 : input.page;
149306
149488
  const slice = input.models.slice((pageNum - 1) * pageSize, pageNum * pageSize);
149307
149489
  if (slice.length === 0 && recent.length > 0) {
149308
149490
  for (const model of recent) {
149309
- elements.push(button3(`\u{1F9E0} ${shorten(modelLabel(model), 30)}`, "default", modelValue(model, input.sid)));
149491
+ elements.push(button4(`\u{1F9E0} ${shorten(modelLabel(model), 30)}`, "default", modelValue(model, input.sid)));
149310
149492
  }
149311
149493
  } else {
149312
149494
  for (const model of slice) {
149313
149495
  const isCurrent = input.current && refKey(input.current) === refKey(model);
149314
149496
  elements.push(
149315
- button3(`\u{1F9E0} ${shorten(modelLabel(model), 30)}`, isCurrent ? "primary" : "default", modelValue(model, input.sid))
149497
+ button4(`\u{1F9E0} ${shorten(modelLabel(model), 30)}`, isCurrent ? "primary" : "default", modelValue(model, input.sid))
149316
149498
  );
149317
149499
  }
149318
- if (pageNum > 1) elements.push(button3("\u25C2 \u4E0A\u4E00\u9875", "default", moreValue(pageNum - 1, input.sid)));
149500
+ if (pageNum > 1) elements.push(button4("\u25C2 \u4E0A\u4E00\u9875", "default", moreValue(pageNum - 1, input.sid)));
149319
149501
  if (pageNum * pageSize < input.models.length) {
149320
- elements.push(button3("\u4E0B\u4E00\u9875 \u25B8", "default", moreValue(pageNum + 1, input.sid)));
149502
+ elements.push(button4("\u4E0B\u4E00\u9875 \u25B8", "default", moreValue(pageNum + 1, input.sid)));
149321
149503
  }
149322
149504
  }
149323
149505
  }
@@ -149331,7 +149513,7 @@ function buildPermCard(input) {
149331
149513
  }
149332
149514
  const elements = [{ tag: "markdown", content: truncateCardContent(lines.join("\n")) }];
149333
149515
  for (const info of PERMISSION_PRESETS) {
149334
- elements.push(button3(presetLabel(info.id), input.current === info.id ? "primary" : "default", permValue(info.id, input.sid)));
149516
+ elements.push(button4(presetLabel(info.id), input.current === info.id ? "primary" : "default", permValue(info.id, input.sid)));
149335
149517
  }
149336
149518
  return headerCard("\u{1F510} \u9009\u62E9\u6743\u9650\u6863\u4F4D", "orange", elements);
149337
149519
  }
@@ -149347,8 +149529,8 @@ function buildConfirmCard(input) {
149347
149529
  ];
149348
149530
  return headerCard("\u2705 \u786E\u8BA4\u521B\u5EFA\u4F1A\u8BDD", "green", [
149349
149531
  { tag: "markdown", content: truncateCardContent(lines.join("\n")) },
149350
- button3("\u2705 \u521B\u5EFA", "primary", { wizard: "confirm" }),
149351
- button3("\u2716\uFE0F \u53D6\u6D88", "default", { wizard: "cancel" })
149532
+ button4("\u2705 \u521B\u5EFA", "primary", { wizard: "confirm" }),
149533
+ button4("\u2716\uFE0F \u53D6\u6D88", "default", { wizard: "cancel" })
149352
149534
  ]);
149353
149535
  }
149354
149536
  function buildSetupDoneCard(title, lines, template2 = "grey") {
@@ -150908,6 +151090,7 @@ async function routeEvent(event, deps) {
150908
151090
  break;
150909
151091
  case "session.text.started": {
150910
151092
  const data = event.data;
151093
+ deps.log.debug("text.started", { sessionID: data.sessionID, msg: data.assistantMessageID });
150911
151094
  deps.applyRun(data.sessionID, {
150912
151095
  type: "text.started",
150913
151096
  ...data.assistantMessageID ? { assistantMessageID: data.assistantMessageID } : {}
@@ -150916,6 +151099,11 @@ async function routeEvent(event, deps) {
150916
151099
  }
150917
151100
  case "session.text.delta": {
150918
151101
  const data = event.data;
151102
+ deps.log.debug("text.delta", {
151103
+ sessionID: data.sessionID,
151104
+ msg: data.assistantMessageID,
151105
+ len: typeof data.delta === "string" ? data.delta.length : 0
151106
+ });
150919
151107
  deps.applyRun(data.sessionID, {
150920
151108
  type: "text.delta",
150921
151109
  delta: data.delta,
@@ -150925,6 +151113,12 @@ async function routeEvent(event, deps) {
150925
151113
  }
150926
151114
  case "session.text.ended": {
150927
151115
  const data = event.data;
151116
+ deps.log.debug("text.ended", {
151117
+ sessionID: data.sessionID,
151118
+ msg: data.assistantMessageID,
151119
+ len: typeof data.text === "string" ? data.text.length : -1,
151120
+ hasText: typeof data.text === "string"
151121
+ });
150928
151122
  deps.applyRun(data.sessionID, {
150929
151123
  type: "text.ended",
150930
151124
  ...data.text ? { text: data.text } : {},
@@ -151326,7 +151520,8 @@ function routeCardAction(action, deps) {
151326
151520
  return deps.handleCompact(action);
151327
151521
  }
151328
151522
  const hasForm = action.formValue !== void 0;
151329
- const routed = hasForm || isSetupFormAction(value3) || Boolean(parseSessionCardValue(value3)) || Boolean(parseSetupCardValue(value3));
151523
+ const routed = hasForm || isSetupFormAction(value3) || Boolean(parseSessionCardValue(value3)) || Boolean(parseSetupCardValue(value3)) || // 「一句话建会话」建议卡按钮(issue #2)。
151524
+ Boolean(parseQuickNewActionValue(value3));
151330
151525
  deps.log.debug("\u5361\u7247\u56DE\u8C03\u8DEF\u7531", {
151331
151526
  hasForm,
151332
151527
  hasValue: value3 !== void 0,
@@ -152088,6 +152283,10 @@ async function start(ctx, config, log2, logSink) {
152088
152283
  rootKnown: Boolean(rootLink)
152089
152284
  });
152090
152285
  if (decision.kind === "main-hint") {
152286
+ if (config.quickNew) {
152287
+ await handleQuickNew(message);
152288
+ return;
152289
+ }
152091
152290
  const res = await sender.sendCard(message.chatId, buildConsoleHintCard());
152092
152291
  if (!res.ok) log2.warn("\u7BA1\u7406\u53F0\u63D0\u793A\u5361\u53D1\u9001\u5931\u8D25", { error: res.error ?? "unknown" });
152093
152292
  return;
@@ -152183,6 +152382,242 @@ async function start(ctx, config, log2, logSink) {
152183
152382
  log2.info("\u673A\u5668\u4EBA\u83DC\u5355\u70B9\u51FB \u2192 \u6267\u884C\u547D\u4EE4", { eventKey: click.eventKey, cmd: entry.cmd, label: entry.label });
152184
152383
  await handleMessage(synthetic);
152185
152384
  }
152385
+ const QUICK_NEW_KEY_PREFIX = "feishu:v2:quicknew:";
152386
+ const QUICK_NEW_TTL_MS = 60 * 60 * 1e3;
152387
+ function parseQuickNewEntry(raw) {
152388
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
152389
+ const v = raw;
152390
+ const text = typeof v.text === "string" ? v.text : "";
152391
+ const dir = typeof v.dir === "string" ? v.dir : "";
152392
+ const chatId = typeof v.chatId === "string" ? v.chatId : "";
152393
+ const openId = typeof v.openId === "string" ? v.openId : "";
152394
+ const anchorMessageId = typeof v.anchorMessageId === "string" ? v.anchorMessageId : "";
152395
+ if (!text || !dir || !chatId || !openId || !anchorMessageId) return void 0;
152396
+ return {
152397
+ text,
152398
+ title: typeof v.title === "string" ? v.title : "",
152399
+ dir,
152400
+ chatId,
152401
+ openId,
152402
+ anchorMessageId,
152403
+ createdAt: typeof v.createdAt === "number" ? v.createdAt : 0
152404
+ };
152405
+ }
152406
+ async function handleQuickNew(message) {
152407
+ const hint = async () => {
152408
+ const res = await sender.sendCard(message.chatId, buildConsoleHintCard());
152409
+ if (!res.ok) log2.warn("\u7BA1\u7406\u53F0\u63D0\u793A\u5361\u53D1\u9001\u5931\u8D25", { error: res.error ?? "unknown" });
152410
+ };
152411
+ try {
152412
+ const candidates = [];
152413
+ const seen = /* @__PURE__ */ new Set();
152414
+ for (const dir2 of await recent.listDirs()) {
152415
+ if (!seen.has(dir2)) {
152416
+ seen.add(dir2);
152417
+ candidates.push({ path: dir2 });
152418
+ }
152419
+ }
152420
+ let entries3 = [];
152421
+ try {
152422
+ const raw = await listAllSessionsRaw() ?? await listSessionsOverHttp({}, { log: log2 });
152423
+ entries3 = normalizeSessionList(raw) ?? [];
152424
+ } catch (err2) {
152425
+ log2.debug("quick-new \u4F1A\u8BDD\u5217\u8868\u8BFB\u53D6\u5931\u8D25", { error: errorMessage(err2) });
152426
+ }
152427
+ for (const entry2 of entries3) {
152428
+ if (entry2.directory && !seen.has(entry2.directory)) {
152429
+ seen.add(entry2.directory);
152430
+ candidates.push({ path: entry2.directory, ...entry2.title ? { label: entry2.title } : {} });
152431
+ }
152432
+ }
152433
+ const routingSessionID = entries3[0]?.sessionID;
152434
+ if (candidates.length === 0 || !routingSessionID) {
152435
+ log2.debug("quick-new \u65E0\u5019\u9009\u76EE\u5F55\u6216\u4F1A\u8BDD\uFF0C\u56DE\u9000\u63D0\u793A\u5361", {
152436
+ candidates: candidates.length,
152437
+ hasRouting: Boolean(routingSessionID)
152438
+ });
152439
+ await hint();
152440
+ return;
152441
+ }
152442
+ const ack = await sender.sendCard(message.chatId, buildQuickNewThinkingCard());
152443
+ if (!ack.ok || !ack.messageId) {
152444
+ await hint();
152445
+ return;
152446
+ }
152447
+ const generateText = async (prompt, requestOptions) => {
152448
+ const api = ctx.generate.text;
152449
+ if (typeof api !== "function") throw new Error("generate.text unavailable");
152450
+ return api({ prompt }, requestOptions);
152451
+ };
152452
+ const outcome = await quickGenerateWithSession(
152453
+ { log: log2, generateText },
152454
+ { prompt: buildQuickNewPrompt(message.text, candidates), sessionID: routingSessionID }
152455
+ );
152456
+ const decision = parseQuickNewDecision(extractGeneratedText(outcome.result));
152457
+ const dir = decision?.intent === "task" ? matchCandidateDirectory(decision.directory, candidates) : void 0;
152458
+ const validation = dir ? validateDir(dir) : void 0;
152459
+ if (!decision || decision.intent !== "task" || !dir || !validation?.ok) {
152460
+ log2.debug("quick-new \u5224\u5B9A\u4E3A\u975E\u4EFB\u52A1\u6216\u672A\u5339\u914D\u5230\u76EE\u5F55", {
152461
+ intent: decision?.intent,
152462
+ dir: decision?.directory,
152463
+ reason: decision?.reason
152464
+ });
152465
+ await sender.patchCard(ack.messageId, buildConsoleHintCard());
152466
+ return;
152467
+ }
152468
+ const id2 = `qn_${message.messageId}`;
152469
+ const title = decision.title || topicTitle(message.text);
152470
+ const entry = {
152471
+ text: message.text,
152472
+ title,
152473
+ dir: validation.path,
152474
+ chatId: message.chatId,
152475
+ openId: message.senderOpenId,
152476
+ anchorMessageId: message.messageId,
152477
+ createdAt: Date.now()
152478
+ };
152479
+ await ctx.storage.set(
152480
+ QUICK_NEW_KEY_PREFIX + id2,
152481
+ entry
152482
+ );
152483
+ await sender.patchCard(
152484
+ ack.messageId,
152485
+ buildQuickNewProposalCard({
152486
+ id: id2,
152487
+ title,
152488
+ directory: validation.path,
152489
+ textPreview: message.text,
152490
+ ...decision.reason ? { reason: decision.reason } : {}
152491
+ })
152492
+ );
152493
+ log2.info("quick-new \u5EFA\u8BAE\u5361\u5DF2\u53D1\u9001", { dir: validation.path, title, reason: decision.reason });
152494
+ } catch (err2) {
152495
+ log2.warn("quick-new \u5904\u7406\u5931\u8D25\uFF0C\u56DE\u9000\u7BA1\u7406\u53F0\u63D0\u793A", { error: errorMessage(err2) });
152496
+ await hint();
152497
+ }
152498
+ }
152499
+ async function handleQuickNewCardAction(action, value3) {
152500
+ if (!owner.isAllowed(action.operatorOpenId)) {
152501
+ return { toast: { type: "error", content: "\u65E0\u64CD\u4F5C\u6743\u9650" } };
152502
+ }
152503
+ const key = QUICK_NEW_KEY_PREFIX + value3.id;
152504
+ let raw;
152505
+ try {
152506
+ raw = await ctx.storage.get(key);
152507
+ } catch {
152508
+ raw = void 0;
152509
+ }
152510
+ const entry = parseQuickNewEntry(raw);
152511
+ if (!entry || Date.now() - entry.createdAt > QUICK_NEW_TTL_MS) {
152512
+ try {
152513
+ await ctx.storage.remove(key);
152514
+ } catch {
152515
+ }
152516
+ if (action.messageId) {
152517
+ await sender.patchCard(
152518
+ action.messageId,
152519
+ buildQuickNewResolvedCard("\u231B\uFE0F \u5EFA\u8BAE\u5DF2\u8FC7\u671F", "\u8BF7\u91CD\u65B0\u53D1\u9001\u6D88\u606F\u3002", "orange")
152520
+ );
152521
+ }
152522
+ return { toast: { type: "warning", content: "\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u53D1\u6D88\u606F" } };
152523
+ }
152524
+ if (value3.op === "cancel") {
152525
+ try {
152526
+ await ctx.storage.remove(key);
152527
+ } catch {
152528
+ }
152529
+ if (action.messageId) {
152530
+ await sender.patchCard(action.messageId, buildQuickNewResolvedCard("\u274C \u5DF2\u53D6\u6D88", "\u672A\u521B\u5EFA\u4F1A\u8BDD\u3002"));
152531
+ }
152532
+ return { toast: { type: "info", content: "\u5DF2\u53D6\u6D88" } };
152533
+ }
152534
+ const validation = validateDir(entry.dir);
152535
+ if (!validation.ok) {
152536
+ if (action.messageId) {
152537
+ await sender.patchCard(
152538
+ action.messageId,
152539
+ buildQuickNewResolvedCard("\u26A0\uFE0F \u76EE\u5F55\u4E0D\u53EF\u7528", `${entry.dir}\uFF1A${validation.message}`, "red")
152540
+ );
152541
+ }
152542
+ return { toast: { type: "error", content: "\u76EE\u5F55\u4E0D\u53EF\u7528" } };
152543
+ }
152544
+ try {
152545
+ await ctx.storage.remove(key);
152546
+ } catch {
152547
+ }
152548
+ const perm = "edit";
152549
+ const title = entry.title || topicTitle(entry.text);
152550
+ void (async () => {
152551
+ const created = await createSessionInternal({
152552
+ title,
152553
+ chatId: entry.chatId,
152554
+ openId: entry.openId,
152555
+ setActive: false,
152556
+ directory: validation.path,
152557
+ permissions: presetToRuleset(perm),
152558
+ perm,
152559
+ gateMode: presetGateMode(perm)
152560
+ });
152561
+ await recent.addDir(validation.path);
152562
+ const readyCard = buildSessionReadyCard({
152563
+ title,
152564
+ sessionID: created.id,
152565
+ dir: validation.path,
152566
+ perm: presetLabel(perm)
152567
+ });
152568
+ const res = await sender.replyCard(entry.anchorMessageId, readyCard, { replyInThread: true });
152569
+ if (res.ok && res.messageId) {
152570
+ await sessionMap.bindRoot(entry.anchorMessageId, created.id);
152571
+ const meta = res.threadId ? void 0 : await sender.getMessageMeta(res.messageId);
152572
+ const threadId = res.threadId ?? meta?.threadId;
152573
+ if (threadId) {
152574
+ await sessionMap.bindThread(threadId, created.id, entry.chatId, entry.openId, entry.anchorMessageId);
152575
+ } else {
152576
+ log2.warn("quick-new \u5F00\u8BDD\u9898\u540E\u672A\u8BFB\u5230 thread_id", { sessionID: created.id });
152577
+ }
152578
+ await sessionMap.setRootCard(created.id, {
152579
+ style: "created",
152580
+ sessionID: created.id,
152581
+ title,
152582
+ dir: validation.path,
152583
+ perm: presetLabel(perm)
152584
+ });
152585
+ } else {
152586
+ log2.warn("quick-new \u5F00\u8BDD\u9898\u5931\u8D25", { sessionID: created.id, error: res.error ?? "unknown" });
152587
+ }
152588
+ await runInSession(
152589
+ {
152590
+ eventId: `quicknew_${value3.id}`,
152591
+ messageId: entry.anchorMessageId,
152592
+ chatId: entry.chatId,
152593
+ text: entry.text,
152594
+ senderOpenId: entry.openId
152595
+ },
152596
+ created.id,
152597
+ entry.anchorMessageId
152598
+ );
152599
+ if (action.messageId) {
152600
+ await sender.patchCard(
152601
+ action.messageId,
152602
+ buildQuickNewResolvedCard(
152603
+ "\u2705 \u5DF2\u521B\u5EFA",
152604
+ `\u76EE\u5F55\uFF1A\`${validation.path}\`
152605
+ \u5DF2\u5F00\u8BDD\u9898\u5E76\u5F00\u59CB\u5904\u7406\u4F60\u7684\u6D88\u606F\u3002`,
152606
+ "green"
152607
+ )
152608
+ );
152609
+ }
152610
+ })().catch((err2) => {
152611
+ log2.warn("quick-new \u5EFA\u4F1A\u8BDD\u5931\u8D25", { error: errorMessage(err2) });
152612
+ if (action.messageId) {
152613
+ void sender.patchCard(
152614
+ action.messageId,
152615
+ buildQuickNewResolvedCard("\u26A0\uFE0F \u521B\u5EFA\u5931\u8D25", errorMessage(err2).slice(0, 160), "red")
152616
+ );
152617
+ }
152618
+ });
152619
+ return { toast: { type: "success", content: "\u5DF2\u521B\u5EFA\uFF0C\u6B63\u5728\u6253\u5F00\u8BDD\u9898\u2026" } };
152620
+ }
152186
152621
  async function runInActiveSession(message) {
152187
152622
  let active = await sessionMap.getActive(message.chatId);
152188
152623
  if (!active) {
@@ -152257,7 +152692,11 @@ ${downloadedAttachmentPrompt(message.attachment, outcome)}`;
152257
152692
  handleForm: (a) => formRelay.handleCardAction(a),
152258
152693
  handleStop: (a) => stop.handleCardAction(a),
152259
152694
  handleCompact: (a) => compact2.handleCardAction(a),
152260
- handleCommands: (a) => commands.handleCardAction(a),
152695
+ handleCommands: (a) => {
152696
+ const quick = parseQuickNewActionValue(a.rawValue);
152697
+ if (quick) return handleQuickNewCardAction(a, quick);
152698
+ return commands.handleCardAction(a);
152699
+ },
152261
152700
  handleApprovals: (a) => approvals.handleCardAction(a)
152262
152701
  })
152263
152702
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-feishu-plugin",
3
- "version": "0.2.13",
3
+ "version": "0.2.14",
4
4
  "description": "Feishu/Lark plugin for OpenCode V2 — single-user topics as sessions, in-card permission approvals, streaming cards. Minimal permissions, no public endpoint.",
5
5
  "type": "module",
6
6
  "main": "index.js",