@sendevia/pi-ask-author 1.0.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/LICENSE +9 -0
- package/README.md +212 -0
- package/package.json +48 -0
- package/src/component.ts +1219 -0
- package/src/format.ts +201 -0
- package/src/index.ts +419 -0
- package/src/model.ts +748 -0
- package/src/novel-markdown.ts +673 -0
- package/src/sanitize.ts +279 -0
- package/src/schema.ts +108 -0
- package/src/texts.ts +636 -0
- package/src/theme.ts +15 -0
- package/src/view-rows.ts +217 -0
- package/src/view-summary.ts +110 -0
- package/tsconfig.json +16 -0
package/src/format.ts
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file format.ts
|
|
3
|
+
* @description 纯函数算法与排版工具层:视口计算、文本折行、ANSI 宽度测量、文本规整
|
|
4
|
+
*
|
|
5
|
+
* 核心架构特性:
|
|
6
|
+
* - 全部函数无副作用、不依赖运行时可变状态,供 sanitize / model / novel-markdown / view-* / component / index 复用
|
|
7
|
+
* - 终端宽度一律经 `visibleWidth` / `truncateToWidth` / `wrapTextWithAnsi`,禁止使用 `string.length` 直接计算
|
|
8
|
+
*
|
|
9
|
+
* 依赖方向:format.ts → texts.ts(布局常量与文案字典)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
13
|
+
import { LAYOUT_CONFIG, TEXTS } from "./texts.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 将数值收敛至闭区间 `[min, max]`
|
|
17
|
+
* @param value - 待收敛数值
|
|
18
|
+
* @param min - 区间下界
|
|
19
|
+
* @param max - 区间上界
|
|
20
|
+
* @returns 位于 `[min, max]` 区间内的数值;`min > max` 时恒返回 `min`
|
|
21
|
+
*/
|
|
22
|
+
export function clamp(value: number, min: number, max: number): number {
|
|
23
|
+
return Math.max(min, Math.min(value, max));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 将视口滚动位移收敛至合法区间,防止越界滚动造成空白视口
|
|
28
|
+
* @param offset - 待收敛的 0-based 起始行位移
|
|
29
|
+
* @param total - 内容总行数
|
|
30
|
+
* @param viewHeight - 可视行数(调用方必须传入非负值)
|
|
31
|
+
* @returns `[0, max(0, total - viewHeight)]` 内的位移;列表为空或内容不足一屏(`total <= viewHeight`)时恒为 0
|
|
32
|
+
*/
|
|
33
|
+
export function clampWindowOffset(offset: number, total: number, viewHeight: number): number {
|
|
34
|
+
return clamp(offset, 0, Math.max(0, total - viewHeight));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 截取视口可视行切片(仅复制可视区,不遍历非可视区数据)
|
|
39
|
+
* @param lines - 完整内容行数组(只读,不修改入参)
|
|
40
|
+
* @param offset - 0-based 起始行位移(须先经 {@link clampWindowOffset} 收敛;负值退化为从尾部切片)
|
|
41
|
+
* @param viewHeight - 可视行数
|
|
42
|
+
* @returns 至多 `viewHeight` 行的新数组;`lines` 为空、`viewHeight <= 0` 或 `offset` 越过末行时返回空数组
|
|
43
|
+
*/
|
|
44
|
+
export function windowSlice<T>(lines: readonly T[], offset: number, viewHeight: number): T[] {
|
|
45
|
+
return lines.slice(offset, offset + viewHeight);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 清除回车符并将制表符规整为双空格(纯文本规整,不剥离 ANSI 控制序列)
|
|
50
|
+
* @param str - 原始文本;缺省时按空串处理
|
|
51
|
+
* @returns 规整后的文本(入参为 undefined 时返回空串,永不返回 undefined)
|
|
52
|
+
*/
|
|
53
|
+
export function cleanText(str: string | undefined): string {
|
|
54
|
+
return str ? str.replace(/\t/g, " ").replace(/\r/g, "") : "";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 剥离终端转义与控制字符(防止 ANSI 注入污染 TUI 渲染管线)
|
|
59
|
+
* 覆盖 OSC(BEL/ST 终止)、CSI、双字符转义与残余 C0 控制字符;换行符保留,由调用方决定单行化时机
|
|
60
|
+
* @param str - 已由 `cleanText` 规整的字符串(无回车与制表符)
|
|
61
|
+
* @returns 剥离全部转义序列与残余 C0 控制字符(含 DEL `\x7f`)的字符串;换行符 `\n` 保留
|
|
62
|
+
*/
|
|
63
|
+
function stripControlSequences(str: string): string {
|
|
64
|
+
return str
|
|
65
|
+
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "")
|
|
66
|
+
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
|
|
67
|
+
.replace(/\x1b[@-_]/g, "")
|
|
68
|
+
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 裁剪空白并剥离终端控制序列;纯空白或剥离后为空的输入均返回 undefined
|
|
73
|
+
* 约定:返回值要么是非空字符串,要么是 undefined——调用方的 `?? 兜底` 不会漏接空串
|
|
74
|
+
* 边界:仅用于不可信输入边界(工具参数、编辑器文本);渲染路径用 `cleanText` / `safeLine`
|
|
75
|
+
* @param str - 原始不可信文本;缺省时返回 undefined
|
|
76
|
+
* @returns 清洗后的非空字符串;纯空白或剥离控制序列后为空时返回 undefined
|
|
77
|
+
*/
|
|
78
|
+
export function cleanOptional(str: string | undefined): string | undefined {
|
|
79
|
+
const t = str?.trim();
|
|
80
|
+
return t ? stripControlSequences(cleanText(t)) || undefined : undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 尝试 `JSON.parse`;非字符串原样返回,解析失败返回 undefined
|
|
85
|
+
* @param value - 待解析值
|
|
86
|
+
* @returns 解析结果或原值;字符串长度(UTF-16 码元)超过 1_000_000 时直接返回 undefined
|
|
87
|
+
*/
|
|
88
|
+
export function parseJson(value: unknown): unknown {
|
|
89
|
+
if (typeof value === "string") {
|
|
90
|
+
if (value.length > 1_000_000) return undefined;
|
|
91
|
+
try {
|
|
92
|
+
return JSON.parse(value);
|
|
93
|
+
} catch {
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return value;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 悬挂缩进折行:首行携带前缀,后续折行左缘与前缀可见宽度一致
|
|
102
|
+
* 宽度按 CJK 全角与 ANSI 转义精确计算(`visibleWidth` / `wrapTextWithAnsi`)
|
|
103
|
+
* @param prefix - 首行前缀(后续行以同宽空格悬挂缩进)
|
|
104
|
+
* @param text - 待折行文本
|
|
105
|
+
* @param width - 可视总宽度(列);调用方必须传入 `>= 1`,`width <= 0` 时不截断前缀
|
|
106
|
+
* @returns 折行后的行数组;`text` 无法产出任何行时返回 `[prefix]`(前缀非空)或空数组
|
|
107
|
+
*/
|
|
108
|
+
function hangText(prefix: string, text: string, width: number): string[] {
|
|
109
|
+
let prefixW = visibleWidth(prefix);
|
|
110
|
+
if (prefixW >= width && width > 0) {
|
|
111
|
+
prefix = truncateToWidth(prefix, width - 1);
|
|
112
|
+
prefixW = visibleWidth(prefix);
|
|
113
|
+
}
|
|
114
|
+
const availableW = Math.max(1, width - prefixW);
|
|
115
|
+
const pad = " ".repeat(prefixW);
|
|
116
|
+
const wrapped = wrapTextWithAnsi(text, availableW);
|
|
117
|
+
if (wrapped.length === 0) return prefix ? [prefix] : [];
|
|
118
|
+
return wrapped.map((line, i) => (i === 0 ? prefix + line : pad + line));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 将悬挂折行结果原地追加至目标行数组
|
|
123
|
+
* @param lines - 目标行数组(原地追加)
|
|
124
|
+
* @param text - 待追加文本
|
|
125
|
+
* @param width - 可视总宽度
|
|
126
|
+
* @param prefix - 首行前缀(后续折行等宽悬挂缩进)
|
|
127
|
+
*/
|
|
128
|
+
export function pushWrapped(lines: string[], text: string, width: number, prefix = ""): void {
|
|
129
|
+
lines.push(...hangText(prefix, text, width));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 单行安全截断,保证可见宽度不超过 `maxWidth`(超宽部分截断,并沿用 `truncateToWidth` 的默认省略号 `...`)
|
|
134
|
+
* @param str - 原始文本(先经 `cleanText` 清除回车与制表符)
|
|
135
|
+
* @param maxWidth - 最大可见宽度(列);`<= 0` 时返回空串
|
|
136
|
+
* @returns 可见宽度不超过 `maxWidth` 的单行文本(未超宽时原样返回,不做补齐)
|
|
137
|
+
*/
|
|
138
|
+
export function safeLine(str: string, maxWidth: number): string {
|
|
139
|
+
if (maxWidth <= 0) return "";
|
|
140
|
+
const cleaned = cleanText(str);
|
|
141
|
+
return visibleWidth(cleaned) > maxWidth ? truncateToWidth(cleaned, maxWidth) : cleaned;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* 按可见宽度填充至指定宽度(支持 CJK 与 ANSI)
|
|
146
|
+
* @param str - 原始文本(先经 `cleanText` 清除回车与制表符)
|
|
147
|
+
* @param targetWidth - 目标可见宽度(列);`<= 0` 时返回空串
|
|
148
|
+
* @returns 可见宽度不超过 `targetWidth` 的单行文本:不足则右补空格,超宽则截断并以默认省略号 `...` 收尾(含全角字符时结果可能略窄于 `targetWidth`)
|
|
149
|
+
*/
|
|
150
|
+
export function padToVisibleWidth(str: string, targetWidth: number): string {
|
|
151
|
+
if (targetWidth <= 0) return "";
|
|
152
|
+
const cleaned = cleanText(str);
|
|
153
|
+
const w = visibleWidth(cleaned);
|
|
154
|
+
if (w >= targetWidth) return truncateToWidth(cleaned, targetWidth);
|
|
155
|
+
return cleaned + " ".repeat(targetWidth - w);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* 生成指定宽度的横向分隔线
|
|
160
|
+
* @param width - 目标可见宽度(列)
|
|
161
|
+
* @param char - 填充字符(默认 `LAYOUT_CONFIG.dividerThin`,每字符宽度按 1 列计)
|
|
162
|
+
* @returns `width <= 0` 时返回空串,否则返回 `char` 重复 `width` 次
|
|
163
|
+
*/
|
|
164
|
+
export function divider(width: number, char: string = LAYOUT_CONFIG.dividerThin): string {
|
|
165
|
+
return width > 0 ? char.repeat(width) : "";
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* 极限压缩正文草稿/分镜头为高密度单行 LLM 指令:逐行剥离行首 Markdown 标题标识与引用符,折叠连续空白,各片段以 `TEXTS.markdown.draftSegmentSeparator` 连接
|
|
170
|
+
* @param preview - 原始草稿/分镜头文本;缺省或全空白时返回 undefined
|
|
171
|
+
* @returns 单行分镜头指令;无有效片段时返回 undefined
|
|
172
|
+
*/
|
|
173
|
+
export function compressDraftPreview(preview: string | undefined): string | undefined {
|
|
174
|
+
if (!preview) return undefined;
|
|
175
|
+
const cleaned = cleanText(preview);
|
|
176
|
+
if (!cleaned.trim()) return undefined;
|
|
177
|
+
|
|
178
|
+
const segments = cleaned
|
|
179
|
+
.split("\n")
|
|
180
|
+
.map((line) => {
|
|
181
|
+
let l = line.trim();
|
|
182
|
+
if (!l) return "";
|
|
183
|
+
// 单次交替剥离行首任意层级的 Markdown 标题标识与引用符(`> # 标题` 需同时去除两类前缀,故不可分成两次行首替换)
|
|
184
|
+
l = l.replace(/^(?:[>#]+\s*)+/, "");
|
|
185
|
+
l = l.replace(/\s+/g, " ");
|
|
186
|
+
return l;
|
|
187
|
+
})
|
|
188
|
+
.filter(Boolean);
|
|
189
|
+
|
|
190
|
+
if (segments.length === 0) return undefined;
|
|
191
|
+
return segments.join(TEXTS.markdown.draftSegmentSeparator);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* 折叠不可信文本的换行与连续空白为单空格:LLM 生成的描述/批注含换行会破坏返回封套的行结构
|
|
196
|
+
* @param text - 原始文本
|
|
197
|
+
* @returns 单行文本(所有 `\s+` 折叠为一个空格并去除首尾空白;纯空白输入返回空串)
|
|
198
|
+
*/
|
|
199
|
+
export function toSingleLine(text: string): string {
|
|
200
|
+
return text.replace(/\s+/g, " ").trim();
|
|
201
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file index.ts
|
|
3
|
+
* @description 作者请示插件入口:注册 `ask_author` 工具与 `/ask-author` 命令
|
|
4
|
+
*
|
|
5
|
+
* 核心架构特性:
|
|
6
|
+
* - 四类出口(取消 / 结构化错误 / 运行期异常 / 正常完成)由四个结果工厂统一产出;其中取消、结构化错误与运行期异常三类共享 `ResultContext` 的缺省收敛,完成结果由表单模型直接供给
|
|
7
|
+
* - `prepareArguments` 防御性清洗兼容批量问卷与单题平铺双模式;返回文本恒以 `TEXTS.markdown` 的固定封套前缀开头(`[Author Decision Finalized]` / `[Author Decision Cancelled]` / `[Author Consultation Error]`),前缀字节稳定利于 Prompt Cache 命中
|
|
8
|
+
* - 性能与生命周期:调用卡片按 args 身份记忆化派生、结果卡片复用槽位 `lastComponent`;AbortSignal 经组件级联取消;本文件不注册任何监听器与定时器,`AskAuthorComponent.dispose()`(幂等)负责回收定时器、Abort 监听与全部缓存池
|
|
9
|
+
*
|
|
10
|
+
* 依赖方向:index.ts 为 11 模块单向依赖图(theme → texts → {format, schema} → {model, sanitize} → {novel-markdown, view-rows, view-summary} → component → index)的入口;旁支 `sanitize.ts` 仅由本文件装配(`prepareArguments` 与 `renderCall` 两处)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { keyHint, type AgentToolResult, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import { Text, type Component } from "@earendil-works/pi-tui";
|
|
15
|
+
import { AskAuthorComponent } from "./component.js";
|
|
16
|
+
import { cleanOptional } from "./format.js";
|
|
17
|
+
import { AskAuthorFormModel, type AskAuthorResult, type AuthorAnswerItem, type NormalizedQuestion } from "./model.js";
|
|
18
|
+
import { AskAuthorParams, type RawParams } from "./schema.js";
|
|
19
|
+
import { sanitizeAskAuthorArgs } from "./sanitize.js";
|
|
20
|
+
import { TEXTS } from "./texts.js";
|
|
21
|
+
|
|
22
|
+
/** `ask_author` 工具执行结果结构(基于 Pi 官方 `AgentToolResult` 结构特化) */
|
|
23
|
+
export type AskAuthorToolResult = AgentToolResult<AskAuthorResult>;
|
|
24
|
+
|
|
25
|
+
/** 结果构造上下文:题目快照与标题/描述的可选覆盖(缺省值统一由 {@link resolveResultContext} 收敛) */
|
|
26
|
+
export interface ResultContext {
|
|
27
|
+
/** 题目快照(缺省空数组:取消/错误可能发生在模型构建前) */
|
|
28
|
+
questions?: NormalizedQuestion[];
|
|
29
|
+
/** 问卷总标题(缺省 `TEXTS.fallbacks.defaultFormTitle`) */
|
|
30
|
+
formTitle?: string;
|
|
31
|
+
/** 问卷全局描述(缺省 undefined,不携带该字段内容) */
|
|
32
|
+
formDescription?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 提取异常对象的用户可读消息(`Error` 取自 `message`,其余经 `String` 字符串化)
|
|
37
|
+
* @param err - 未知异常对象
|
|
38
|
+
* @returns `Error` 实例读取其 `message`(可为空串),其余类型取自 `String(err)`(对象为 `[object Object]`)
|
|
39
|
+
*/
|
|
40
|
+
function toErrorMessage(err: unknown): string {
|
|
41
|
+
return err instanceof Error ? err.message : String(err);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 收敛结果构造上下文的缺省值(唯一兜底点:`formTitle` 恒非空、`questions` 恒为数组,`formDescription` 保留 undefined 语义)
|
|
46
|
+
* @param ctx - 结果构造上下文(可为空对象或省略)
|
|
47
|
+
* @returns 收敛后的 `formTitle` / `formDescription` / `questions` 三元组(`formTitle` 缺省为 `TEXTS.fallbacks.defaultFormTitle`)
|
|
48
|
+
*/
|
|
49
|
+
function resolveResultContext(ctx: ResultContext = {}): {
|
|
50
|
+
formTitle: string;
|
|
51
|
+
formDescription: string | undefined;
|
|
52
|
+
questions: NormalizedQuestion[];
|
|
53
|
+
} {
|
|
54
|
+
return {
|
|
55
|
+
formTitle: ctx.formTitle ?? TEXTS.fallbacks.defaultFormTitle,
|
|
56
|
+
formDescription: ctx.formDescription,
|
|
57
|
+
questions: ctx.questions ?? [],
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 复用渲染槽位既有 Text 组件就地更新卡片内容(Pi 渲染器 `lastComponent` 复用最佳实践)
|
|
63
|
+
* @param content - 卡片文本内容
|
|
64
|
+
* @param lastComponent - 该槽位上帧返回的组件实例(非 Text 实例时新建零边距 Text)
|
|
65
|
+
* @returns 复用或新建的 `Text`(已 `setText`,其内部行缓存随 `setText` 失效)
|
|
66
|
+
*/
|
|
67
|
+
function renderCardText(content: string, lastComponent: Component | undefined): Text {
|
|
68
|
+
const text = lastComponent instanceof Text ? lastComponent : new Text("", 0, 0);
|
|
69
|
+
text.setText(content);
|
|
70
|
+
return text;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 构造取消结果(智能体封套 `[Author Decision Cancelled]` + `cancelled: true`,`answers` 恒为空数组)
|
|
75
|
+
* @param ctx - 结果构造上下文(题目快照与被取消时的表单快照)
|
|
76
|
+
* @returns 取消结果(`content` 为取消封套单行文本,`details` 不含 `error` 字段)
|
|
77
|
+
*/
|
|
78
|
+
function createCancelledResult(ctx: ResultContext = {}): AskAuthorToolResult {
|
|
79
|
+
const { formTitle, formDescription, questions } = resolveResultContext(ctx);
|
|
80
|
+
return {
|
|
81
|
+
content: [{ type: "text", text: TEXTS.markdown.envelopeCancelled }],
|
|
82
|
+
details: { formTitle, formDescription, questions, answers: [], cancelled: true },
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 构造错误结果(智能体错误封套 + 用户界面提示)
|
|
88
|
+
* @param reason - 面向智能体的原因
|
|
89
|
+
* @param guidance - 面向智能体的纠正指引
|
|
90
|
+
* @param userFacingError - 面向用户的界面提示
|
|
91
|
+
* @param ctx - 结果构造上下文(缺省值由 `resolveResultContext` 收敛)
|
|
92
|
+
* @returns 错误结果对象(`cancelled: false` + `error` 详情,无作答内容)
|
|
93
|
+
*/
|
|
94
|
+
function createErrorResult(
|
|
95
|
+
reason: string,
|
|
96
|
+
guidance: string,
|
|
97
|
+
userFacingError: string,
|
|
98
|
+
ctx: ResultContext = {},
|
|
99
|
+
): AskAuthorToolResult {
|
|
100
|
+
const { formTitle, formDescription, questions } = resolveResultContext(ctx);
|
|
101
|
+
return {
|
|
102
|
+
content: [{ type: "text", text: TEXTS.markdown.envelopeError(reason, guidance) }],
|
|
103
|
+
details: { formTitle, formDescription, questions, answers: [], cancelled: false, error: userFacingError },
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 构造运行期异常结果(模型构建抛出错误与 `execute` 异常捕获两条路径共用本工厂)
|
|
109
|
+
* @param err - 异常对象或消息(字符串经 {@link toErrorMessage} 原样返回)
|
|
110
|
+
* @param ctx - 结果构造上下文(题目快照与表单快照)
|
|
111
|
+
* @returns 运行期异常结果(封套原因携带 {@link toErrorMessage} 归一后的消息)
|
|
112
|
+
*/
|
|
113
|
+
function createRuntimeErrorResult(err: unknown, ctx: ResultContext = {}): AskAuthorToolResult {
|
|
114
|
+
const errorMsg = toErrorMessage(err);
|
|
115
|
+
return createErrorResult(
|
|
116
|
+
TEXTS.markdown.errorReasons.runtimeError(errorMsg),
|
|
117
|
+
TEXTS.markdown.errorGuidances.runtimeError,
|
|
118
|
+
TEXTS.status.errorExecutionFailed(errorMsg),
|
|
119
|
+
ctx,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 构造完成结果(`[Author Decision Finalized]` 封套正文由模型编译,空答卷渲染为空答卷提示)
|
|
125
|
+
* @param formModel - 本次请示的表单模型(详情表单快照来源,与组件回传结果同源)
|
|
126
|
+
* @param answers - 组件编译完成的答卷条目(封套正文与详情 `answers` 共用同一数组)
|
|
127
|
+
* @returns 完成结果(`cancelled: false`、无 `error` 字段;`answers` 为空数组时正文为 `TEXTS.markdown.envelopeEmpty`)
|
|
128
|
+
*/
|
|
129
|
+
function createSuccessResult(formModel: AskAuthorFormModel, answers: AuthorAnswerItem[]): AskAuthorToolResult {
|
|
130
|
+
return {
|
|
131
|
+
content: [{ type: "text", text: formModel.formatAnswers(answers) }],
|
|
132
|
+
details: {
|
|
133
|
+
formTitle: formModel.formTitle,
|
|
134
|
+
formDescription: formModel.formDescription,
|
|
135
|
+
questions: formModel.questions,
|
|
136
|
+
answers,
|
|
137
|
+
cancelled: false,
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ===== 宿主边界收窄与结果卡片数据派生 =====
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* 结果卡片宿主边界的**唯一**类型收窄点:把 `renderResult` 的 `details` 从不可信类型提升为 `AskAuthorResult`(工具入参边界另由 `sanitizeAskAuthorArgs` 做运行时守卫,与本函数互不覆盖)
|
|
146
|
+
* 会话回放时 `details` 由转录 JSON 反序列化而来(运行期形状不可信),故先做形状闸门再断言一次;数组元素由本扩展自身写入,不在闸门范围
|
|
147
|
+
* 校验项:普通对象、`cancelled`/`answers`/`questions`/`formTitle` 键存在性、`cancelled` 布尔、`answers`/`questions` 数组、`formTitle` 字符串
|
|
148
|
+
* 可选 `error` / `formDescription` 不设闸门:二者仅参与字符串插值,非字符串至多渲染为退化文本(`fg` 为模板拼接,不抛出错误)
|
|
149
|
+
* @param value - 宿主传入的不可信 `details` 值
|
|
150
|
+
* @returns 通过形状闸门的 `AskAuthorResult`
|
|
151
|
+
* @throws 任一校验项失败时抛出错误(就地崩溃)
|
|
152
|
+
*/
|
|
153
|
+
function narrowResultDetails(value: unknown): AskAuthorResult {
|
|
154
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
155
|
+
throw new Error(`ask_author 结果详情应为普通对象,实际收到 ${JSON.stringify(value)}`);
|
|
156
|
+
}
|
|
157
|
+
if (!("cancelled" in value) || !("answers" in value) || !("questions" in value) || !("formTitle" in value)) {
|
|
158
|
+
throw new Error("ask_author 结果详情缺少 cancelled/answers/questions/formTitle 字段");
|
|
159
|
+
}
|
|
160
|
+
if (typeof value.cancelled !== "boolean") throw new Error("ask_author 结果详情的 cancelled 字段应为布尔值");
|
|
161
|
+
if (!Array.isArray(value.answers) || !Array.isArray(value.questions)) {
|
|
162
|
+
throw new Error("ask_author 结果详情的 answers/questions 字段应为数组");
|
|
163
|
+
}
|
|
164
|
+
if (typeof value.formTitle !== "string") throw new Error("ask_author 结果详情的 formTitle 字段应为字符串");
|
|
165
|
+
return value as AskAuthorResult;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* 将单条答案展开为明细内容行(多选以 `TEXTS.common.optionSeparator` 连接并附选项批注角标,零勾选回退 `未选`;单选无选中项时回退题目补充说明,两者皆空再回退 `未选`)
|
|
170
|
+
* @param ans - 答卷条目
|
|
171
|
+
* @returns 单行明细文本(不含换行;题目级补充说明以 `TEXTS.card.questionNoteTag` 角标追加)
|
|
172
|
+
*/
|
|
173
|
+
function formatAnswerLine(ans: AuthorAnswerItem): string {
|
|
174
|
+
const questionNoteStr = ans.customText ? TEXTS.card.questionNoteTag(ans.customText) : "";
|
|
175
|
+
/**
|
|
176
|
+
* 把带专属批注的选项标签追加为卡片角标形式(`note` 缺省或空串时原样返回 `label`)
|
|
177
|
+
* @param label - 选项标签
|
|
178
|
+
* @param note - 选项专属批注(可选)
|
|
179
|
+
* @returns 携带角标的标签;无批注时即原 `label`
|
|
180
|
+
*/
|
|
181
|
+
const withNote = (label: string, note?: string): string => (note ? TEXTS.card.optionWithNote(label, note) : label);
|
|
182
|
+
|
|
183
|
+
if (ans.multiSelect) {
|
|
184
|
+
const optItems = (ans.selectedOptions ?? []).map((o) => withNote(o.label, o.customText));
|
|
185
|
+
return (optItems.join(TEXTS.common.optionSeparator) || TEXTS.fallbacks.unselectedOption) + questionNoteStr;
|
|
186
|
+
}
|
|
187
|
+
if (ans.selectedOption) {
|
|
188
|
+
return withNote(ans.selectedOption.label, ans.selectedOption.customText) + questionNoteStr;
|
|
189
|
+
}
|
|
190
|
+
return ans.customText || TEXTS.fallbacks.unselectedOption;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** 调用卡片派生数据:与主题无关的纯计算结果(颜色合成每帧按当前主题现场计算) */
|
|
194
|
+
export interface CallCardData {
|
|
195
|
+
/** 卡片标题(已清洗的 `formTitle`) */
|
|
196
|
+
title: string;
|
|
197
|
+
/** 计数标签(多题模式为 `2 题 • 6 选项`,单题模式仅 `6 选项`) */
|
|
198
|
+
countLabel: string;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* `renderCall` 记忆化状态载荷:宿主按工具执行行创建一次、跨帧复用的可变对象(`ToolRenderContext.state`,同一行的 `renderCall` 与 `renderResult` 共享该实例)
|
|
203
|
+
* 以 **args 对象身份**为键(`cachedArgs` 记录身份、`cachedCard` 记录派生数据),依赖宿主「流式补全整体替换 `args` 引用」的不变量(宿主经 `ToolExecutionComponent.updateArgs` 赋入新对象):身份变化即失效重新计算;若宿主原地改写同一对象,缓存将滞留旧数据
|
|
204
|
+
*/
|
|
205
|
+
export interface CallRenderState {
|
|
206
|
+
/** 上一次派生所用的 args 对象身份(仅作 `===` 引用比对,不读取任何属性) */
|
|
207
|
+
cachedArgs?: object;
|
|
208
|
+
/** 与 `cachedArgs` 配对的派生数据;两者身份同时命中时直接复用 */
|
|
209
|
+
cachedCard?: CallCardData;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* 派生调用卡片数据(标题 + 计数标签:题目数 > 1 时附带题量,单题模式仅选项数)
|
|
214
|
+
* 入参清洗、模型构建与计数归约仅在 **args 对象身份变化时**执行一次,同帧后续渲染只做一次引用比对
|
|
215
|
+
* 缓存只存储与主题无关的数据——`theme` 是身份稳定的 Proxy,缓存 ANSI 着色结果会在主题切换后滞留
|
|
216
|
+
* @param args - 本次渲染的原始工具入参(未经 `sanitizeAskAuthorArgs` 清洗,流式期间可能为不完整对象)
|
|
217
|
+
* @param state - 宿主按工具行复用、跨帧身份稳定的可变状态载荷
|
|
218
|
+
* @returns 派生卡片数据(`state` 身份命中时直接复用,否则重新计算并写入 `state.cachedCard`)
|
|
219
|
+
*/
|
|
220
|
+
function resolveCallCard(args: RawParams, state: CallRenderState): CallCardData {
|
|
221
|
+
if (state.cachedArgs === args && state.cachedCard) return state.cachedCard;
|
|
222
|
+
|
|
223
|
+
const model = new AskAuthorFormModel(sanitizeAskAuthorArgs(args));
|
|
224
|
+
const totalOpts = model.questions.reduce((sum, q) => sum + q.options.length, 0);
|
|
225
|
+
const card: CallCardData = {
|
|
226
|
+
title: model.formTitle,
|
|
227
|
+
countLabel:
|
|
228
|
+
model.questions.length > 1
|
|
229
|
+
? TEXTS.card.callQuestionAndOptionCount(model.questions.length, totalOpts)
|
|
230
|
+
: TEXTS.card.callOptionCountOnly(totalOpts),
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
state.cachedArgs = args;
|
|
234
|
+
state.cachedCard = card;
|
|
235
|
+
return card;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ===== 插件入口与生命周期集成 =====
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* `ask-author` 扩展插件主入口工厂(仅完成注册,启动期不执行任何交互)
|
|
242
|
+
* @param pi - Pi 扩展运行时 API 实例
|
|
243
|
+
*/
|
|
244
|
+
export default function askAuthorExtension(pi: ExtensionAPI): void {
|
|
245
|
+
// 注册 ask_author 工具:显式实例化 TState
|
|
246
|
+
pi.registerTool<typeof AskAuthorParams, AskAuthorResult, CallRenderState>({
|
|
247
|
+
name: TEXTS.tool.name,
|
|
248
|
+
label: TEXTS.tool.label,
|
|
249
|
+
description: TEXTS.tool.description,
|
|
250
|
+
promptSnippet: TEXTS.tool.promptSnippet,
|
|
251
|
+
// 宿主接口要求可变 `string[]`;TEXTS 为 `as const` 只读元组,故展开构造可变副本
|
|
252
|
+
promptGuidelines: [...TEXTS.tool.promptGuidelines],
|
|
253
|
+
parameters: AskAuthorParams,
|
|
254
|
+
// 交互式 UI 独占编辑器(`ctx.ui.custom`):串行执行
|
|
255
|
+
executionMode: "sequential",
|
|
256
|
+
// 宿主在 Schema 校验前调用:清洗为白名单形状,并兼容未按当前 Schema 书写的别名/包裹入参
|
|
257
|
+
prepareArguments: sanitizeAskAuthorArgs,
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* 工具执行主流程:归一化标题 → 守卫 → 构建模型 → 启动交互式 TUI → 映射四类结果
|
|
261
|
+
* 守卫顺序:信号已中止 → 取消;非 TUI 模式 → 非交互错误;模型构建抛出错误 → 运行期异常;无有效题目 → 空题目错误;`ui.custom` 回传结果映射为取消 / 完成;`finally` 兜底 `dispose()`
|
|
262
|
+
* @param _toolCallId - 宿主工具调用 ID(本实现不使用)
|
|
263
|
+
* @param rawParams - `prepareArguments` 清洗并经宿主 Schema 校验后的入参(宿主 `structuredClone` 副本,与清洗产物非同一引用)
|
|
264
|
+
* @param signal - 宿主取消信号(可缺省;Esc / Ctrl+C 级联终止交互界面)
|
|
265
|
+
* @param _onUpdate - 流式进度回调(本实现不使用;本工具不上报部分结果)
|
|
266
|
+
* @param ctx - Pi 扩展上下文(`mode` 守卫与 `ui.custom` 组件挂载)
|
|
267
|
+
* @returns 取消 / 结构化报错 / 运行期异常 / 完成四类结果之一;模型构建与 `ui.custom` 阶段的异常均被捕获并转换为运行期异常结果
|
|
268
|
+
*/
|
|
269
|
+
async execute(_toolCallId, rawParams, signal, _onUpdate, ctx) {
|
|
270
|
+
const initialFormTitle = cleanOptional(rawParams?.formTitle) ?? TEXTS.fallbacks.defaultFormTitle;
|
|
271
|
+
|
|
272
|
+
if (signal?.aborted) {
|
|
273
|
+
return createCancelledResult({ formTitle: initialFormTitle });
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (ctx.mode !== "tui") {
|
|
277
|
+
return createErrorResult(
|
|
278
|
+
TEXTS.markdown.errorReasons.nonTui,
|
|
279
|
+
TEXTS.markdown.errorGuidances.nonTui,
|
|
280
|
+
TEXTS.status.errorNonTui,
|
|
281
|
+
{ formTitle: initialFormTitle },
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
let formModel: AskAuthorFormModel;
|
|
286
|
+
try {
|
|
287
|
+
formModel = new AskAuthorFormModel(rawParams);
|
|
288
|
+
} catch (err) {
|
|
289
|
+
return createRuntimeErrorResult(err, { formTitle: initialFormTitle });
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (formModel.questions.length === 0) {
|
|
293
|
+
return createErrorResult(
|
|
294
|
+
TEXTS.markdown.errorReasons.noQuestions,
|
|
295
|
+
TEXTS.markdown.errorGuidances.noQuestions,
|
|
296
|
+
TEXTS.status.errorNoQuestions,
|
|
297
|
+
{ questions: [], formTitle: formModel.formTitle, formDescription: formModel.formDescription },
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
let comp: AskAuthorComponent | undefined;
|
|
302
|
+
try {
|
|
303
|
+
// 宿主回调约定 `(tui, theme, keybindings, done)`:按键分派由组件内部 `matchesKey` 自行持有,
|
|
304
|
+
// 注入的 keybindings 管理器未被消费,故以 `_kb` 标记未使用
|
|
305
|
+
const result = await ctx.ui.custom<AskAuthorResult>((tui, theme, _kb, done) => {
|
|
306
|
+
comp = new AskAuthorComponent(tui, theme, formModel, done);
|
|
307
|
+
comp.bindAbortSignal(signal);
|
|
308
|
+
return comp;
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
if (!result || result.cancelled) {
|
|
312
|
+
return createCancelledResult({
|
|
313
|
+
questions: result?.questions ?? formModel.questions,
|
|
314
|
+
formTitle: result?.formTitle ?? formModel.formTitle,
|
|
315
|
+
formDescription: result?.formDescription ?? formModel.formDescription,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return createSuccessResult(formModel, result.answers);
|
|
320
|
+
} catch (err) {
|
|
321
|
+
return createRuntimeErrorResult(err, {
|
|
322
|
+
questions: formModel.questions,
|
|
323
|
+
formTitle: formModel.formTitle,
|
|
324
|
+
formDescription: formModel.formDescription,
|
|
325
|
+
});
|
|
326
|
+
} finally {
|
|
327
|
+
// dispose() 幂等,宿主亦会卸载组件;此处仅兜住「组件工厂执行后、宿主接管前」的抛出错误路径
|
|
328
|
+
comp?.dispose();
|
|
329
|
+
}
|
|
330
|
+
},
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* 会话回放调用卡片渲染(复用槽位既有 Text;派生按 args 身份记忆化)
|
|
334
|
+
* `args` 由宿主原样透传(流式期间可能不完整,且未经 Schema 校验;此处 `RawParams` 类型只是宿主泛型的静态断言),故必须先经 `sanitizeAskAuthorArgs` 收敛再构建模型
|
|
335
|
+
* @param args - 原始工具入参(运行时未经清洗,可能为不完整对象)
|
|
336
|
+
* @param theme - 当前 TUI 主题实例
|
|
337
|
+
* @param context - 宿主渲染上下文(`state` 记忆化载荷 / `lastComponent` 槽位复用)
|
|
338
|
+
* @returns 调用卡片 `Text`(复用 `context.lastComponent` 就地更新)
|
|
339
|
+
*/
|
|
340
|
+
renderCall(args, theme, context) {
|
|
341
|
+
const card = resolveCallCard(args, context.state);
|
|
342
|
+
const label = theme.fg("accent", TEXTS.tool.label);
|
|
343
|
+
const body = `${theme.bold(card.title)} ${theme.fg("muted", `(${card.countLabel})`)}`;
|
|
344
|
+
return renderCardText(`${label} ${body}`, context.lastComponent);
|
|
345
|
+
},
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* 会话回放结果卡片渲染(错误 / 取消 / 完成三态,展开态追加答卷明细)
|
|
349
|
+
* 错误态优先于取消态判定:`details.error` 为真值时即使 `cancelled` 为 true 也按错误呈现(本扩展产出的 details 中二者互斥:`createErrorResult` 恒设置 `cancelled: false`)
|
|
350
|
+
* @param result - 工具执行结果(`details` 必须经由 {@link narrowResultDetails} 收窄)
|
|
351
|
+
* @param expanded - 是否展开答卷明细(同组的 `isPartial` 未使用:本工具不上报流式部分结果)
|
|
352
|
+
* @param theme - 当前 TUI 主题实例
|
|
353
|
+
* @param context - 宿主渲染上下文(`lastComponent` 槽位复用;`state` 由 `renderCall` 独占使用)
|
|
354
|
+
* @returns 结果卡片 `Text`(复用 `context.lastComponent` 就地更新)
|
|
355
|
+
*/
|
|
356
|
+
renderResult(result, { expanded }, theme, context) {
|
|
357
|
+
const details = narrowResultDetails(result.details);
|
|
358
|
+
|
|
359
|
+
if (details.error) {
|
|
360
|
+
const errorTitle = `${theme.fg("error", theme.bold(TEXTS.card.resultErrorShort))} ${theme.fg("muted", `${TEXTS.common.bulletPrefix}${details.error}`)}`;
|
|
361
|
+
if (!expanded) {
|
|
362
|
+
return renderCardText(
|
|
363
|
+
`${errorTitle} (${keyHint("app.tools.expand", TEXTS.card.expandHint)})`,
|
|
364
|
+
context.lastComponent,
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
return renderCardText(
|
|
368
|
+
`${errorTitle}\n${TEXTS.card.errorBullet(theme.fg("error", details.error))}`,
|
|
369
|
+
context.lastComponent,
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (details.cancelled) {
|
|
374
|
+
return renderCardText(theme.fg("warning", TEXTS.card.resultCancelled), context.lastComponent);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const countLabel = `${theme.fg("success", theme.bold(TEXTS.card.resultCompleted))} ${theme.fg(
|
|
378
|
+
"muted",
|
|
379
|
+
`${TEXTS.common.bulletPrefix}${TEXTS.card.resultCount(details.answers.length)}`,
|
|
380
|
+
)}`;
|
|
381
|
+
if (!expanded) {
|
|
382
|
+
return renderCardText(
|
|
383
|
+
`${countLabel} (${keyHint("app.tools.expand", TEXTS.card.expandHint)})`,
|
|
384
|
+
context.lastComponent,
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const lines: string[] = [
|
|
389
|
+
countLabel,
|
|
390
|
+
...details.answers.map((ans) =>
|
|
391
|
+
TEXTS.card.optionBullet(theme.bold(ans.questionTitle), theme.fg("accent", formatAnswerLine(ans))),
|
|
392
|
+
),
|
|
393
|
+
];
|
|
394
|
+
return renderCardText(lines.join("\n"), context.lastComponent);
|
|
395
|
+
},
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
// 注册 /ask-author 辅助命令
|
|
399
|
+
pi.registerCommand(TEXTS.command.name, {
|
|
400
|
+
description: TEXTS.command.description,
|
|
401
|
+
/**
|
|
402
|
+
* 命令处理函数:向作者回显 ask_author 使用提示(不解析参数,通知后立即完成)
|
|
403
|
+
* @param _args - 命令参数(本命令不接收参数,不使用)
|
|
404
|
+
* @param ctx - Pi 扩展上下文(经 `ui.notify` 投递 info 级通知)
|
|
405
|
+
*/
|
|
406
|
+
handler: async (_args, ctx) => {
|
|
407
|
+
ctx.ui.notify(TEXTS.command.notify, "info");
|
|
408
|
+
},
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ===== 外部与二次开发具名导出 =====
|
|
413
|
+
|
|
414
|
+
export { askAuthorExtension };
|
|
415
|
+
export { AskAuthorFormModel, type AskAuthorResult, type AuthorAnswerItem, type NormalizedQuestion } from "./model.js";
|
|
416
|
+
export { AskAuthorParams, type RawParams } from "./schema.js";
|
|
417
|
+
export { sanitizeAskAuthorArgs } from "./sanitize.js";
|
|
418
|
+
export { TEXTS, LAYOUT_CONFIG, ICONS } from "./texts.js";
|
|
419
|
+
|