mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-20 16:10:49 +08:00
865 lines
33 KiB
JavaScript
865 lines
33 KiB
JavaScript
/**
|
||
* Feishu interactive-card builders for the dsh-im menu / session-list /
|
||
* workspace-list UX. All builders return the JSON string the
|
||
* `im.message.create` API expects as `content` for `msg_type: interactive`
|
||
* (card schema 2.0; callback buttons live inside a column_set/column layout).
|
||
*
|
||
* The session list lays each row out as a `column_set` (fixed-width ⭐
|
||
* watch-toggle column + weighted session-button column), which is how V2
|
||
* expresses a row of buttons.
|
||
*
|
||
* Buttons carry a small `{ action }` value object that `card.action.trigger`
|
||
* events echo back (when the app subscribes that event); every numbered
|
||
* button also has a numeric label so the number-reply fallback stays usable
|
||
* without button callbacks.
|
||
*/
|
||
|
||
import { t } from '../shared/i18n.mjs';
|
||
|
||
export const MENU_PAGE_SIZE = 10;
|
||
|
||
/** 预设下拉中「跟随默认」(null) 的哨兵值,供 initial_index 命中与回调识别。 */
|
||
export const PRESET_FOLLOW_DEFAULT_SENTINEL = '__preset_follow_default__';
|
||
|
||
function plainText(content) {
|
||
return { tag: 'plain_text', content: String(content) };
|
||
}
|
||
|
||
function markdown(content) {
|
||
return { tag: 'lark_md', content: String(content) };
|
||
}
|
||
|
||
/** Full-width button wrapped in a column_set. */
|
||
function button(content, actionValue) {
|
||
return {
|
||
tag: 'column_set',
|
||
flex_mode: 'none',
|
||
columns: [{
|
||
tag: 'column',
|
||
width: 'weighted',
|
||
weight: 1,
|
||
elements: [{
|
||
tag: 'button',
|
||
text: plainText(content),
|
||
type: 'default',
|
||
width: 'fill',
|
||
behaviors: [{ type: 'callback', value: { action: actionValue } }],
|
||
}],
|
||
}],
|
||
};
|
||
}
|
||
|
||
/** The raw button element (without the full-width column_set wrapper). */
|
||
function buttonElement(content, actionValue) {
|
||
return {
|
||
tag: 'button',
|
||
text: plainText(content),
|
||
type: 'default',
|
||
width: 'fill',
|
||
behaviors: [{ type: 'callback', value: { action: actionValue } }],
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Two buttons side by side in a `column_set`.
|
||
* `leftContent` / `leftAction` and `rightContent` / `rightAction` define
|
||
* each button's label and callback action value.
|
||
*/
|
||
function buttonPair(leftContent, leftAction, rightContent, rightAction) {
|
||
return {
|
||
tag: 'column_set',
|
||
flex_mode: 'none',
|
||
columns: [
|
||
{ tag: 'column', width: 'weighted', weight: 1, elements: [buttonElement(leftContent, leftAction)] },
|
||
{ tag: 'column', width: 'weighted', weight: 1, elements: [buttonElement(rightContent, rightAction)] },
|
||
],
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Feishu schema 2.0 single-select (`select_static`) does NOT support a
|
||
* `selected` field on options (that triggers 230099 parse json err). The
|
||
* supported way to show a default value is `initial_index` (1-based option
|
||
* order; 0 = show none). Build it from the option array + current value.
|
||
*/
|
||
function initialIndex(options, currentValue) {
|
||
const idx = options.findIndex((o) => o.value === currentValue);
|
||
return idx >= 0 ? idx + 1 : 0;
|
||
}
|
||
|
||
function safeTitle(value) {
|
||
const title = String(value ?? '').replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/gu, ' ').replace(/\s+/gu, ' ').trim();
|
||
return title || t('暂无标题');
|
||
}
|
||
|
||
function cardWith(headerText, elements) {
|
||
return JSON.stringify({
|
||
schema: '2.0',
|
||
header: { title: plainText(headerText), template: 'blue' },
|
||
body: { elements },
|
||
});
|
||
}
|
||
|
||
// ── Shared helper: a "back to menu" button ────────────────────────────────
|
||
|
||
function backButton() {
|
||
return button(t('🔙 返回菜单'), 'back_to_menu');
|
||
}
|
||
|
||
// ── Main menu card ────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* The hub menu card — a single entry point covering every command, organized
|
||
* by operating flow:
|
||
* 1. 会话 · 工作区(高频率)→ 会话下拉 · 工作区下拉 · 新会话 · 全部会话
|
||
* 2. 任务控制 → 停止 · 压缩 · 补充指令下拉
|
||
* 3. 配置 → 预设下拉 · 模型下拉 · 归档切换
|
||
* 4. 系统 → 状态 · 帮助
|
||
* `ctx` bundle:
|
||
* - workspaces: string[] (工作区下拉选项)
|
||
* - currentWorkspace: string|null (当前工作区,下拉高亮)
|
||
* - currentSession: {id,title}|null (当前绑定会话,续写目标)
|
||
* - sessions: {id,title}[] (最近会话,供会话下拉切换)
|
||
* - archiveVisible: boolean (归档显隐开关当前值)
|
||
* - presetCatalog: object|null (预设目录, {items,defaultId,_currentId})
|
||
* - modelCatalog: object|null (模型目录, {groups,current})
|
||
* Number fallback: 1=续写 2=新会话 3=会话列表 4=状态
|
||
* 5=补全权限 6=帮助.
|
||
*/
|
||
export function menuCard(ctx) {
|
||
const {
|
||
workspaces = [],
|
||
currentWorkspace = null,
|
||
currentSession = null,
|
||
sessions = [],
|
||
archiveVisible = false,
|
||
presetCatalog = null,
|
||
modelCatalog = null,
|
||
} = ctx || {};
|
||
|
||
// currentSession 提供 id/title;为兼容旧调用,也接受 currentSessionTitle
|
||
const currentSessionId = currentSession?.id ?? null;
|
||
const currentSessionTitle = currentSession?.title ?? ctx?.currentSessionTitle ?? null;
|
||
|
||
const sessionOptions = (Array.isArray(sessions) ? sessions : []).slice(0, 20);
|
||
const hasSessions = sessionOptions.length > 0;
|
||
const hasWorkspaces = Array.isArray(workspaces) && workspaces.length > 0;
|
||
const elements = [];
|
||
|
||
// ── 设置区 ──────────────────────────────────────────────────
|
||
elements.push({ tag: 'div', text: markdown(t('**设置**')) });
|
||
|
||
// ── 四个下拉菜单 2×2 网格 ────────────────────────────────────
|
||
|
||
// 第 1 行:会话 + 工作区
|
||
let sessionDropdown = null;
|
||
if (hasSessions) {
|
||
const sessionPickOptions = sessionOptions.map((s) => ({
|
||
text: { tag: 'plain_text', content: `${s.id === currentSessionId ? '✓ ' : ''}${safeTitle(s.title)}` },
|
||
value: s.id,
|
||
}));
|
||
sessionDropdown = {
|
||
tag: 'select_static',
|
||
name: 'session_pick',
|
||
placeholder: {
|
||
tag: 'plain_text',
|
||
content: currentSessionId ? t('切换会话') : t('选择会话(当前未绑定)'),
|
||
},
|
||
initial_index: initialIndex(sessionPickOptions, currentSessionId),
|
||
options: sessionPickOptions,
|
||
behaviors: [{ type: 'callback', value: { action: 'session_pick' } }],
|
||
};
|
||
}
|
||
|
||
let workspaceDropdown = null;
|
||
if (hasWorkspaces) {
|
||
const wsOptions = workspaces.slice(0, 20).map((path) => ({
|
||
text: { tag: 'plain_text', content: `${path === currentWorkspace ? '✓ ' : ''}${path}` },
|
||
value: path,
|
||
}));
|
||
workspaceDropdown = {
|
||
tag: 'select_static',
|
||
name: 'workspace_pick',
|
||
placeholder: { tag: 'plain_text', content: t('切换工作区') },
|
||
initial_index: initialIndex(wsOptions, currentWorkspace),
|
||
options: wsOptions,
|
||
behaviors: [{ type: 'callback', value: { action: 'workspace_pick' } }],
|
||
};
|
||
}
|
||
|
||
// 第 2 行:预设 + 模型
|
||
const presetItems = Array.isArray(presetCatalog?.items) ? presetCatalog.items : [];
|
||
const curPresetId = presetCatalog?._currentId ?? null;
|
||
const presetFollowDefault = curPresetId === null;
|
||
let presetDropdown = null;
|
||
if (presetItems.length >= 1) {
|
||
const setPresetOptions = presetItems.slice(0, 30).map((item) => ({
|
||
text: { tag: 'plain_text', content: `${item.id === curPresetId ? '✓ ' : ''}${item.label}` },
|
||
value: item.id,
|
||
}));
|
||
const presetSelected = presetFollowDefault ? PRESET_FOLLOW_DEFAULT_SENTINEL : curPresetId;
|
||
setPresetOptions.unshift({
|
||
text: { tag: 'plain_text', content: `${presetFollowDefault ? '✓ ' : ''}${t('跟随默认')}` },
|
||
value: PRESET_FOLLOW_DEFAULT_SENTINEL,
|
||
});
|
||
presetDropdown = {
|
||
tag: 'select_static',
|
||
name: 'preset_pick',
|
||
placeholder: { tag: 'plain_text', content: t('切换预设') },
|
||
initial_index: initialIndex(setPresetOptions, presetSelected),
|
||
options: setPresetOptions,
|
||
behaviors: [{ type: 'callback', value: { action: 'preset_pick' } }],
|
||
};
|
||
}
|
||
|
||
const groups = Array.isArray(modelCatalog?.groups) ? modelCatalog.groups : [];
|
||
const curModel = modelCatalog?.current;
|
||
const curModelId = curModel ? `${curModel.provider}/${curModel.model}` : null;
|
||
const flat = [];
|
||
for (const group of groups) {
|
||
for (const model of (group.models || [])) flat.push({ id: `${group.id}/${model.id}`, name: `${group.name} - ${model.name}` });
|
||
}
|
||
let modelDropdown = null;
|
||
if (flat.length > 1) {
|
||
const setModelOptions = flat.slice(0, 30).map((opt) => ({
|
||
text: { tag: 'plain_text', content: `${opt.id === curModelId ? '✓ ' : ''}${opt.name}` },
|
||
value: opt.id,
|
||
}));
|
||
modelDropdown = {
|
||
tag: 'select_static',
|
||
name: 'model_pick',
|
||
placeholder: { tag: 'plain_text', content: t('切换模型') },
|
||
initial_index: initialIndex(setModelOptions, curModelId),
|
||
options: setModelOptions,
|
||
behaviors: [{ type: 'callback', value: { action: 'model_pick' } }],
|
||
};
|
||
}
|
||
|
||
// 渲染 2×2 网格:图标 + 下拉并列
|
||
// 每一行用 4 列 column_set:图标 | 下拉 | 图标 | 下拉
|
||
function iconCol(icon) {
|
||
return { tag: 'column', width: 'weighted', weight: 0.1, vertical_align: 'center', elements: [{ tag: 'div', text: { tag: 'plain_text', content: icon } }] };
|
||
}
|
||
function dropdownCol(el) {
|
||
return { tag: 'column', width: 'weighted', weight: 1, elements: [el] };
|
||
}
|
||
|
||
const row1 = [];
|
||
if (sessionDropdown) row1.push(sessionDropdown);
|
||
if (workspaceDropdown) row1.push(workspaceDropdown);
|
||
if (row1.length === 2) {
|
||
elements.push({
|
||
tag: 'column_set', flex_mode: 'none',
|
||
columns: [iconCol('💬'), dropdownCol(row1[0]), iconCol('📂'), dropdownCol(row1[1])],
|
||
});
|
||
} else if (row1[0]) {
|
||
elements.push(row1[0]);
|
||
}
|
||
if (!hasSessions) {
|
||
elements.push({ tag: 'div', text: markdown(t('当前工作区暂无可用会话。')) });
|
||
}
|
||
|
||
const row2 = [];
|
||
const presetBtn = button(t('🤖 切换预设'), 'presets');
|
||
const modelBtn = button(t('🧠 切换模型'), 'models');
|
||
if (presetDropdown) row2.push(presetDropdown); else row2.push(presetBtn);
|
||
if (modelDropdown) row2.push(modelDropdown); else row2.push(modelBtn);
|
||
elements.push({
|
||
tag: 'column_set', flex_mode: 'none',
|
||
columns: [iconCol('🤖'), dropdownCol(row2[0]), iconCol('🧠'), dropdownCol(row2[1])],
|
||
});
|
||
|
||
// 新会话 + 全部会话按钮
|
||
elements.push(buttonPair(t('🆕 新会话'), 'new', t('📋 会话/关注'), 'sessions'));
|
||
if (!hasSessions && !hasWorkspaces) {
|
||
elements.push(button(t('🗂 工作区列表'), 'workspaces'));
|
||
}
|
||
elements.push({ tag: 'hr' });
|
||
|
||
// ── 任务控制(对运行中任务的操作)────────────────────────
|
||
elements.push({ tag: 'div', text: markdown(t('**任务控制**')) });
|
||
elements.push(buttonPair(t('⏹ 停止'), 'stop', t('📐 压缩'), 'compact'));
|
||
elements.push({ tag: 'hr' });
|
||
|
||
// ── 补充指令 + 归档切换(并列)────────────────────────────
|
||
elements.push({
|
||
tag: 'column_set', flex_mode: 'none',
|
||
columns: [
|
||
{
|
||
tag: 'column', width: 'weighted', weight: 1,
|
||
elements: [
|
||
{ tag: 'div', text: markdown(t('**补充指令**')) },
|
||
{
|
||
tag: 'select_static',
|
||
name: 'steer_pick',
|
||
placeholder: { tag: 'plain_text', content: t('选择补充指令') },
|
||
initial_index: 0,
|
||
options: [
|
||
...QUICK_STEER_OPTIONS.map((source) => ({
|
||
text: { tag: 'plain_text', content: t(source) },
|
||
value: t(source),
|
||
})),
|
||
{ text: { tag: 'plain_text', content: t('✏️ 更多 / 自定义…') }, value: 'custom' },
|
||
],
|
||
behaviors: [{ type: 'callback', value: { action: 'steer_pick' } }],
|
||
},
|
||
],
|
||
},
|
||
{
|
||
tag: 'column', width: 'weighted', weight: 1,
|
||
elements: [
|
||
{ tag: 'div', text: markdown(t('🗄 归档:{state}', {
|
||
state: archiveVisible ? t('已显示') : t('已隐藏'),
|
||
})) },
|
||
button(t('切换归档显示'), 'archive_toggle'),
|
||
],
|
||
},
|
||
],
|
||
});
|
||
elements.push({ tag: 'hr' });
|
||
|
||
// ── 底部操作(系统功能)────────────────────────────────────
|
||
elements.push(buttonPair(t('📊 状态'), 'status', t('📖 帮助'), 'help'));
|
||
|
||
// 命令与数字兜底说明
|
||
elements.push({ tag: 'div', text: markdown(t(
|
||
'**数字兜底**\n**1**工作区列表 · **2**新会话 · **3**会话列表 · **4**状态\n**5**🔧补全权限 · **6**帮助',
|
||
)) });
|
||
return cardWith(t('🤖 助手中心'), elements);
|
||
}
|
||
|
||
// ── Preset management card ────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Preset selection card with a dropdown of available presets.
|
||
* The catalog comes from `harness.agentPresetSettings()`.
|
||
*/
|
||
export function presetCard(catalog) {
|
||
const { items, defaultId } = catalog;
|
||
const current = catalog._currentId; // injected by the caller
|
||
const currentItem = items.find((i) => i.id === current);
|
||
const defaultItem = items.find((i) => i.id === defaultId);
|
||
|
||
const currentText = current === null
|
||
? t('跟随 Host 默认{default}', { default: defaultItem ? `(${defaultItem.label})` : '' })
|
||
: currentItem
|
||
? `${currentItem.label}(${currentItem.id})`
|
||
: t('{id}(已不可用)', { id: current });
|
||
|
||
const elements = [
|
||
{ tag: 'div', text: markdown(t('**当前**:{value}', { value: currentText })) },
|
||
{ tag: 'div', text: markdown(t('**Host 默认**:{value}', {
|
||
value: defaultItem ? `${defaultItem.label}(${defaultItem.id})` : t('未设置'),
|
||
})) },
|
||
{ tag: 'hr' },
|
||
];
|
||
|
||
if (items.length >= 1) {
|
||
const presetCardOptions = items.map((item) => ({
|
||
text: { tag: 'plain_text', content: `${item.label}(${item.id})${item.id === current ? ' ✓' : ''}` },
|
||
value: item.id,
|
||
}));
|
||
elements.push(
|
||
{
|
||
tag: 'select_static',
|
||
name: 'preset_pick',
|
||
placeholder: { tag: 'plain_text', content: t('选择预设') },
|
||
initial_index: current === null ? 0 : initialIndex(presetCardOptions, current),
|
||
options: presetCardOptions,
|
||
behaviors: [{ type: 'callback', value: { action: 'preset_pick' } }],
|
||
},
|
||
{ tag: 'hr' },
|
||
buttonPair(t('🔄 跟随默认'), 'preset_default', t('🔙 返回菜单'), 'back_to_menu'),
|
||
);
|
||
} else {
|
||
elements.push(
|
||
{ tag: 'div', text: markdown(t('当前没有可选择的预设。')) },
|
||
backButton(),
|
||
);
|
||
}
|
||
return cardWith(t('🤖 预设列表'), elements);
|
||
}
|
||
|
||
// ── Model management card ─────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Model selection card with a dropdown of available models.
|
||
* The catalog comes from `harness.listModels()` or `session.models()`.
|
||
* Groups are shown as text, then a flat dropdown for selection.
|
||
*/
|
||
export function modelCard(catalog) {
|
||
const { groups, current } = catalog;
|
||
const currentId = current ? `${current.provider}/${current.model}` : null;
|
||
|
||
const elements = [
|
||
{ tag: 'div', text: markdown(t('**当前模型**:{model}', { model: currentId || t('未设置') })) },
|
||
{ tag: 'hr' },
|
||
];
|
||
|
||
// Count total models
|
||
let total = 0;
|
||
for (const group of groups) total += group.models.length;
|
||
|
||
if (total === 0) {
|
||
elements.push(
|
||
{ tag: 'div', text: markdown(t('当前没有可用模型。')) },
|
||
backButton(),
|
||
);
|
||
} else {
|
||
// Show groups as text
|
||
for (const group of groups) {
|
||
const modelLines = group.models.map((m) => {
|
||
const id = `${group.id}/${m.id}`;
|
||
return `${id}${id === currentId ? ' ✓' : ''}`;
|
||
});
|
||
elements.push(
|
||
{ tag: 'div', text: markdown(`**${group.name}**\n${modelLines.join('\n')}`) },
|
||
);
|
||
}
|
||
|
||
// Flatten all models into a dropdown
|
||
const allOptions = [];
|
||
for (const group of groups) {
|
||
for (const model of group.models) {
|
||
allOptions.push({
|
||
value: `${group.id}/${model.id}`,
|
||
text: { tag: 'plain_text', content: `${group.name} - ${model.name}` },
|
||
});
|
||
}
|
||
}
|
||
|
||
elements.push(
|
||
{ tag: 'hr' },
|
||
{
|
||
tag: 'select_static',
|
||
name: 'model_pick',
|
||
placeholder: { tag: 'plain_text', content: t('选择模型') },
|
||
initial_index: currentId === null ? 0 : initialIndex(allOptions, currentId),
|
||
options: allOptions.map((opt) => ({
|
||
text: { tag: 'plain_text', content: `${opt.text.content}${opt.value === currentId ? ' ✓' : ''}` },
|
||
value: opt.value,
|
||
})),
|
||
behaviors: [{ type: 'callback', value: { action: 'model_pick' } }],
|
||
},
|
||
{ tag: 'hr' },
|
||
backButton(),
|
||
);
|
||
}
|
||
return cardWith(t('🧠 模型列表'), elements);
|
||
}
|
||
|
||
// ── Status card ───────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* System status card showing connection, workspace, preset, model and session
|
||
* count. `info` is an object with keys:
|
||
* - connected: boolean
|
||
* - workspace: string | null
|
||
* - preset: string | null
|
||
* - model: string | null
|
||
* - sessionCount: number
|
||
*/
|
||
export function statusCard(info) {
|
||
const elements = [
|
||
{ tag: 'div', text: markdown(t('{icon} 飞书机器人{state}', {
|
||
icon: info.connected ? '✅' : '❌',
|
||
state: info.connected ? t('已连接') : t('未连接'),
|
||
})) },
|
||
{ tag: 'div', text: markdown(t('📂 工作区:`{workspace}`', { workspace: info.workspace || t('未设置') })) },
|
||
{ tag: 'div', text: markdown(t('🤖 预设:{preset}', { preset: info.preset || t('未设置') })) },
|
||
{ tag: 'div', text: markdown(t('🧠 模型:{model}', { model: info.model || t('未设置') })) },
|
||
{ tag: 'div', text: markdown(t('💬 会话:{count} 个', { count: info.sessionCount })) },
|
||
{ tag: 'hr' },
|
||
backButton(),
|
||
];
|
||
return cardWith(t('📊 系统状态'), elements);
|
||
}
|
||
|
||
// ── Help card ─────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Plain-text command reference advertised when no interactive card is
|
||
* available (e.g. non-interactive clients), so every command stays
|
||
* discoverable without card callbacks.
|
||
*/
|
||
export function menuHelpText() {
|
||
return [
|
||
'🤖 助手菜单(回复数字即可,无需记命令)',
|
||
'',
|
||
'📋 会话 / 工作区',
|
||
'/sessionlist 列出工作区会话',
|
||
'/session ID 绑定已有会话',
|
||
'/workspacelist 列出工作区',
|
||
'/workspace 路径 切换工作区',
|
||
'/new 开启全新会话',
|
||
'',
|
||
'📊 状态 / 压缩',
|
||
'/status 连接状态',
|
||
'/version 查看插件版本',
|
||
'/compact 压缩当前会话上下文',
|
||
'/archived on/off 会话列表显示/隐藏归档',
|
||
'',
|
||
'👁 关注',
|
||
'/watch ID 关注会话(完成后推送)',
|
||
'/watchlist 关注列表',
|
||
'/unwatch ID 取消关注',
|
||
'',
|
||
'🤖 预设 / 模型',
|
||
'/presetlist 列出可用 Agent Preset',
|
||
'/preset [序号或完整ID] 查看或设置当前机器人 Agent Preset',
|
||
'纯数字 ID:/preset id:<ID>',
|
||
'/preset --default 跟随 Host 默认',
|
||
'/models 列出模型',
|
||
'/reasoninglist 或 /reasonings 按序号列出当前模型可用推理等级',
|
||
'/reasoning [序号、等级ID或 --default] 查看或切换当前推理等级',
|
||
'/model [序号或完整模型ID] [推理等级ID] 查看或切换当前会话模型',
|
||
'',
|
||
'📦 批量输入(仅私聊)',
|
||
'/batch 开始批量输入(仅私聊,最多 10 条文字)',
|
||
'/send 提交当前批次',
|
||
'/cancel 取消当前批次',
|
||
'',
|
||
'🎮 任务控制',
|
||
'/stop 停止当前任务',
|
||
'/steer 指令 给 Agent 补充指令',
|
||
'/repair 补全飞书权限与卡片回调',
|
||
].map((line) => t(line)).join('\n');
|
||
}
|
||
|
||
/**
|
||
* Help card with all available commands and their descriptions.
|
||
*/
|
||
const HELP_CARD_FEATURES = [
|
||
'**📋 卡片功能**',
|
||
'',
|
||
'1. 会话下拉 — 切换当前绑定会话',
|
||
'2. 工作区下拉 — 切换工作区',
|
||
'3. 🤖 预设下拉 — 切换 Agent 预设',
|
||
'4. 🧠 模型下拉 — 切换模型',
|
||
'5. 🆕 新会话 — 开启全新会话',
|
||
'6. 📋 会话/关注 — 查看/绑定会话,管理关注',
|
||
'7. ⏹ 停止 — 停止当前任务',
|
||
'8. 📐 压缩 — 压缩当前会话上下文',
|
||
'9. 补充指令 — 给 Agent 发送指令',
|
||
'10. 🗄 归档切换 — 显示/隐藏归档会话',
|
||
'11. 📊 状态 — 查看系统连接状态',
|
||
'12. 📖 帮助 — 查看本帮助',
|
||
].join('\n');
|
||
|
||
const HELP_TEXT_COMMANDS = [
|
||
'**⌨️ 文本命令**',
|
||
'',
|
||
'`/m` — 打开菜单卡片',
|
||
'`/new` — 开启全新会话',
|
||
'`/session ID` — 绑定已有会话',
|
||
'`/sessionlist [工作区]` — 列出会话',
|
||
'`/workspace 路径` — 切换工作区',
|
||
'`/workspacelist` — 列出工作区',
|
||
'`/status` — 查看连接状态',
|
||
'`/compact` — 压缩上下文',
|
||
'`/stop` — 停止当前任务',
|
||
'`/steer 指令` — 补充指令',
|
||
'`/watch ID` — 关注会话',
|
||
'`/watchlist` — 关注列表',
|
||
'`/unwatch ID` — 取消关注',
|
||
'`/archived on/off` — 归档显隐',
|
||
'`/presetlist` — 列出预设',
|
||
'`/preset [序号/ID]` — 切换预设',
|
||
'`/preset --default` — 跟随默认',
|
||
'`/models` — 列出模型',
|
||
'`/reasoninglist` 或 `/reasonings` — 按序号列出当前模型可用推理等级',
|
||
'`/reasoning [序号、等级ID或 --default]` — 查看或切换当前推理等级',
|
||
'`/model [序号或完整模型ID] [推理等级ID]` — 查看或切换当前会话模型',
|
||
'`/batch` — 开启批量输入(仅私聊,最多 10 条文字)',
|
||
'`/send` — 提交当前批次',
|
||
'`/cancel` — 取消当前批次',
|
||
'`/repair` — 补全飞书权限与卡片回调',
|
||
].join('\n');
|
||
|
||
const HELP_NUMBER_FALLBACK = [
|
||
'**💡 数字兜底**',
|
||
'回复数字快速操作:',
|
||
'**1**工作区列表 · **2**新会话 · **3**会话/关注',
|
||
'**4**状态 · **5**补全权限 · **6**帮助',
|
||
].join('\n');
|
||
|
||
export function helpCard(extraTextLines = []) {
|
||
const extraText = Array.isArray(extraTextLines) && extraTextLines.length > 0
|
||
? ('\n' + extraTextLines.join('\n'))
|
||
: '';
|
||
const elements = [
|
||
{ tag: 'div', text: markdown(t(HELP_CARD_FEATURES)) },
|
||
{ tag: 'hr' },
|
||
{ tag: 'div', text: markdown([
|
||
t(HELP_TEXT_COMMANDS),
|
||
t('`/version` — 查看插件版本'),
|
||
].join('\n') + extraText) },
|
||
{ tag: 'hr' },
|
||
{ tag: 'div', text: markdown(t(HELP_NUMBER_FALLBACK)) },
|
||
{ tag: 'hr' },
|
||
backButton(),
|
||
];
|
||
return cardWith(t('📖 帮助'), elements);
|
||
}
|
||
|
||
// ── One-shot callback probe (repair verification) ─────────────────────────
|
||
|
||
/** One-shot callback probe used only after an existing app was re-authorized. */
|
||
export function cardActionProbeCard(nonce) {
|
||
if (typeof nonce !== 'string' || !/^[A-Za-z0-9_-]{16,128}$/.test(nonce)) {
|
||
throw new TypeError('A safe card-action probe nonce is required');
|
||
}
|
||
return cardWith(t('🧪 验证卡片按钮'), [
|
||
{
|
||
tag: 'div',
|
||
text: markdown(t('授权已提交。请点击下方按钮;机器人真实收到回调后才会判定修复成功。')),
|
||
},
|
||
{
|
||
tag: 'column_set',
|
||
flex_mode: 'none',
|
||
columns: [{
|
||
tag: 'column',
|
||
width: 'weighted',
|
||
weight: 1,
|
||
elements: [{
|
||
tag: 'button',
|
||
text: plainText(t('完成验证')),
|
||
type: 'primary',
|
||
width: 'fill',
|
||
behaviors: [{
|
||
type: 'callback',
|
||
value: { action: 'repair_verify', nonce },
|
||
}],
|
||
}],
|
||
}],
|
||
},
|
||
]);
|
||
}
|
||
|
||
// ── Session list card (preserved, with back button) ───────────────────────
|
||
|
||
/**
|
||
* One page of the workspace's sessions. Each row is a `column_set` pair:
|
||
* the fixed-width ⭐ watch toggle (`⭐关注` / `⭐取关` for already-watched
|
||
* sessions) followed by the session button that carries the page-local
|
||
* number label (reply-number fallback = bind). Archived sessions are marked
|
||
* in the label. `watchedSessionIds` is a Set-like of ids this conversation
|
||
* already watches.
|
||
*/
|
||
export function sessionListCard(workspace, sessions, page, total, watchedSessionIds = new Set()) {
|
||
const start = page * MENU_PAGE_SIZE;
|
||
const slice = sessions.slice(start, start + MENU_PAGE_SIZE);
|
||
const pageCount = Math.max(1, Math.ceil(total / MENU_PAGE_SIZE));
|
||
const watched = (id) => typeof watchedSessionIds?.has === 'function' && watchedSessionIds.has(id);
|
||
/** One row: fixed 90px watch toggle + the session button filling the rest. */
|
||
const row = (watchButton, sessionButton) => ({
|
||
tag: 'column_set',
|
||
flex_mode: 'none',
|
||
horizontal_spacing: 'default',
|
||
columns: [
|
||
{ tag: 'column', width: '90px', vertical_align: 'center', elements: [watchButton] },
|
||
{ tag: 'column', width: 'weighted', weight: 1, vertical_align: 'center', elements: [sessionButton] },
|
||
],
|
||
});
|
||
const elements = [
|
||
{ tag: 'div', text: markdown(t('**工作区**:{workspace}\n共 **{total}** 个会话{paging}', {
|
||
workspace: `\`${workspace}\``,
|
||
total,
|
||
paging: total > MENU_PAGE_SIZE
|
||
? t('(第 {page}/{pageCount} 页)', { page: page + 1, pageCount })
|
||
: '',
|
||
})) },
|
||
...slice.map((session, offset) => {
|
||
// Page-local numbering: number replies resolve against this page.
|
||
const label = `${offset + 1}. ${safeTitle(session.title)}${session.archived === true ? t('(已归档)') : ''}`;
|
||
const watching = watched(session.sessionId);
|
||
return row(
|
||
buttonElement(watching ? t('⭐ 取消关注') : t('☆ 关注'), watching ? `unwatch:${session.sessionId}` : `watch:${session.sessionId}`),
|
||
buttonElement(label, `use:${session.sessionId}`),
|
||
);
|
||
}),
|
||
];
|
||
if (page > 0) elements.push(button(t('◀ 上一页'), `sessions:${page - 1}`));
|
||
if (page + 1 < pageCount) elements.push(button(t('下一页 ▶'), `sessions:${page + 1}`));
|
||
elements.push(
|
||
{ tag: 'hr' },
|
||
buttonPair(t('🔙 返回菜单'), 'back_to_menu', t('🔍 关注列表'), 'watchlist'),
|
||
{ tag: 'div', text: markdown(t('回复数字(1~N)绑定本页会话。')) },
|
||
);
|
||
return cardWith(t('📂 会话列表'), elements);
|
||
}
|
||
|
||
// ── Workspace list card (preserved, with back button) ─────────────────────
|
||
|
||
/** The workspace list card (switch-workspace buttons + reply fallback). */
|
||
export function workspaceListCard(paths, current) {
|
||
const elements = paths.length === 0
|
||
? [
|
||
{ tag: 'div', text: markdown(t('当前 Host 上没有已登记的工作区。')) },
|
||
backButton(),
|
||
]
|
||
: [
|
||
{ tag: 'div', text: markdown(t('回复数字切换工作区,或点击按钮:')) },
|
||
...paths.map((path, index) => button(
|
||
`${index + 1}. ${path}${path === current ? t('(当前)') : ''}`,
|
||
`workspace:${path}`,
|
||
)),
|
||
{ tag: 'hr' },
|
||
backButton(),
|
||
];
|
||
return cardWith(t('🗂 工作区'), elements);
|
||
}
|
||
|
||
// ── Watch list card (multi-select add/remove + back button) ──────────────
|
||
|
||
/** Dropdown (multi-select) of sessions not yet watched, to add them in bulk. */
|
||
function watchAddSelect(entries, availableSessions) {
|
||
const watched = new Set(entries.map((e) => e.sessionId));
|
||
const options = (Array.isArray(availableSessions) ? availableSessions : [])
|
||
.filter((s) => s?.sessionId && !watched.has(s.sessionId))
|
||
.slice(0, 30)
|
||
.map((s) => ({
|
||
text: { tag: 'plain_text', content: safeTitle(s.title ?? s.sessionId) },
|
||
value: s.sessionId,
|
||
}));
|
||
if (options.length === 0) return null;
|
||
return {
|
||
tag: 'multi_select_static',
|
||
name: 'watch_add',
|
||
placeholder: { tag: 'plain_text', content: t('勾选要关注的会话') },
|
||
options,
|
||
behaviors: [{ type: 'callback', value: { action: 'watch_add', kind: 'multi' } }],
|
||
};
|
||
}
|
||
|
||
/** Dropdown (multi-select) of currently watched sessions, to remove in bulk. */
|
||
function watchRemoveSelect(entries) {
|
||
const options = entries.slice(0, 30).map((entry) => ({
|
||
text: { tag: 'plain_text', content: safeTitle(entry.title) },
|
||
value: entry.sessionId,
|
||
}));
|
||
if (options.length === 0) return null;
|
||
return {
|
||
tag: 'multi_select_static',
|
||
name: 'watch_remove',
|
||
placeholder: { tag: 'plain_text', content: t('勾选要取消关注的会话') },
|
||
options,
|
||
behaviors: [{ type: 'callback', value: { action: 'watch_remove', kind: 'multi' } }],
|
||
};
|
||
}
|
||
|
||
/** The watch list for one conversation (multi-select add/remove + buttons). */
|
||
export function watchListCard(entries, availableSessions) {
|
||
const watching = Array.isArray(entries) ? entries : [];
|
||
const addSelect = watchAddSelect(watching, availableSessions);
|
||
const removeSelect = watchRemoveSelect(watching);
|
||
|
||
const elements = [];
|
||
if (watching.length === 0) {
|
||
elements.push({ tag: 'div', text: markdown(t('当前没有关注的会话。任务完成会自动推送结果。')) });
|
||
} else {
|
||
elements.push({ tag: 'div', text: markdown(t('当前关注 **{count}** 个会话:', { count: watching.length })) });
|
||
}
|
||
|
||
if (addSelect) {
|
||
elements.push({ tag: 'hr' }, { tag: 'div', text: markdown(t('**➕ 添加关注**(多选下拉勾选)')) }, addSelect);
|
||
}
|
||
if (removeSelect) {
|
||
elements.push({ tag: 'hr' }, { tag: 'div', text: markdown(t('**➖ 取消关注**(多选下拉勾选)')) }, removeSelect);
|
||
}
|
||
elements.push({ tag: 'hr' }, buttonPair(t('📋 会话列表'), 'sessions', t('🔙 返回菜单'), 'back_to_menu'));
|
||
return cardWith(t('👁 关注列表'), elements);
|
||
}
|
||
|
||
// ── Completion push card ──────────────────────────────────────────────────
|
||
|
||
/**
|
||
* The completion push card. `title` is the session title, `reason` the
|
||
* turn-end kind (completed / stopped / aborted).
|
||
*/
|
||
export function completionCard(sessionId, title, reason) {
|
||
const reasonText = reason === 'completed'
|
||
? t('已完成')
|
||
: reason === 'stopped'
|
||
? t('已停止')
|
||
: reason === 'aborted'
|
||
? t('已中止')
|
||
: reason === 'cancelled'
|
||
? t('已取消')
|
||
: t('已结束');
|
||
return cardWith(t('✅ 任务完成'), [
|
||
{ tag: 'div', text: markdown(`**${safeTitle(title)}**\n\`${sessionId}\``) },
|
||
{ tag: 'div', text: markdown(t('**状态**:{reason}', { reason: reasonText })) },
|
||
{ tag: 'hr' },
|
||
buttonPair(t('📋 会话列表'), 'sessions', t('🔙 返回菜单'), 'back_to_menu'),
|
||
{ tag: 'div', text: markdown(t('绑定该会话后可继续追问,输入文字即可。')) },
|
||
]);
|
||
}
|
||
|
||
// ── Steer card (补充指令) ─────────────────────────────────────────────────
|
||
|
||
/** 常用补充指令的快捷选项,供下拉秒选。 */
|
||
const QUICK_STEER_OPTIONS = ['继续', '加速运行', '总结当前进展', '更简洁些', '更详细些'];
|
||
|
||
/** 快捷下拉的最后一项:切换到自定义输入卡片。 */
|
||
export const STEER_CUSTOM_SENTINEL = '__steer_custom__';
|
||
|
||
/** 补充指令卡片:单个下拉,快捷指令 + 「更多/自定义」。 */
|
||
export function steerCard({ hasSession }) {
|
||
const hint = hasSession
|
||
? t('从下方下拉选择补充指令;最后一项可自定义输入。')
|
||
: t('当前没有绑定会话,请先绑定会话再补充指令。');
|
||
const options = QUICK_STEER_OPTIONS.map((source) => ({
|
||
text: { tag: 'plain_text', content: t(source) },
|
||
value: t(source),
|
||
}));
|
||
options.push({ text: { tag: 'plain_text', content: t('✏️ 更多 / 自定义…') }, value: STEER_CUSTOM_SENTINEL });
|
||
const elements = [
|
||
{ tag: 'div', text: markdown(hint) },
|
||
{ tag: 'hr' },
|
||
{
|
||
tag: 'select_static',
|
||
name: 'steer_quick',
|
||
placeholder: { tag: 'plain_text', content: t('选择补充指令') },
|
||
initial_index: 0,
|
||
options,
|
||
behaviors: [{ type: 'callback', value: { action: 'steer', source: 'quick' } }],
|
||
},
|
||
{ tag: 'hr' },
|
||
button(t('🔙 返回菜单'), 'back_to_menu'),
|
||
];
|
||
return cardWith(t('➕ 补充指令'), elements);
|
||
}
|
||
|
||
/** 自定义输入卡:输入框 + 提交(从下拉「更多/自定义」切过来)。 */
|
||
export function customSteerCard() {
|
||
const elements = [
|
||
{ tag: 'div', text: markdown(t('输入补充指令后点「提交」,发送给当前运行的任务。')) },
|
||
{ tag: 'hr' },
|
||
{
|
||
tag: 'form',
|
||
name: 'steer_form',
|
||
elements: [
|
||
{
|
||
tag: 'input',
|
||
name: 'steer_text',
|
||
placeholder: { tag: 'plain_text', content: t('输入你的补充指令') },
|
||
},
|
||
{
|
||
tag: 'button',
|
||
// Card 2.0 form controls need stable names so the client can
|
||
// construct a submit action (and expose action.name) reliably.
|
||
name: 'steer_submit',
|
||
text: { tag: 'plain_text', content: t('提交') },
|
||
type: 'primary',
|
||
width: 'fill',
|
||
form_action_type: 'submit',
|
||
behaviors: [{ type: 'callback', value: { action: 'steer', source: 'form' } }],
|
||
},
|
||
],
|
||
},
|
||
{ tag: 'hr' },
|
||
button(t('🔙 返回菜单'), 'back_to_menu'),
|
||
];
|
||
return cardWith(t('➕ 自定义指令'), elements);
|
||
}
|