qi-coding-agent 0.1.0__py3-none-any.whl
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.
- qi_agent/__init__.py +18 -0
- qi_agent/abort.py +40 -0
- qi_agent/auth.py +216 -0
- qi_agent/cli.py +2178 -0
- qi_agent/compaction.py +475 -0
- qi_agent/config.py +409 -0
- qi_agent/docs/cli.md +188 -0
- qi_agent/docs/compaction.md +148 -0
- qi_agent/docs/configuration.md +89 -0
- qi_agent/docs/containerization.md +80 -0
- qi_agent/docs/docs.json +1 -0
- qi_agent/docs/environment-variables.md +47 -0
- qi_agent/docs/extensions.md +382 -0
- qi_agent/docs/how-qi-works.md +93 -0
- qi_agent/docs/index.md +61 -0
- qi_agent/docs/json.md +112 -0
- qi_agent/docs/keybindings.md +83 -0
- qi_agent/docs/models.md +138 -0
- qi_agent/docs/packages.md +155 -0
- qi_agent/docs/providers.md +288 -0
- qi_agent/docs/quickstart.md +109 -0
- qi_agent/docs/security.md +69 -0
- qi_agent/docs/session-format.md +299 -0
- qi_agent/docs/sessions.md +127 -0
- qi_agent/docs/settings.md +138 -0
- qi_agent/docs/skills.md +124 -0
- qi_agent/docs/slash-commands.md +55 -0
- qi_agent/docs/themes.md +78 -0
- qi_agent/docs/tui.md +109 -0
- qi_agent/docs/usage.md +112 -0
- qi_agent/export_html.py +95 -0
- qi_agent/extensions.py +2267 -0
- qi_agent/llm.py +609 -0
- qi_agent/loader.py +299 -0
- qi_agent/model_catalog.py +108 -0
- qi_agent/models.py +103 -0
- qi_agent/packages.py +430 -0
- qi_agent/paths.py +242 -0
- qi_agent/pep723.py +74 -0
- qi_agent/presets.py +293 -0
- qi_agent/prompt.py +150 -0
- qi_agent/registry.py +485 -0
- qi_agent/runner.py +584 -0
- qi_agent/runtime.py +1940 -0
- qi_agent/session.py +739 -0
- qi_agent/settings.py +501 -0
- qi_agent/share.py +111 -0
- qi_agent/system_prompt.py +278 -0
- qi_agent/theme.py +389 -0
- qi_agent/themes/dark.json +90 -0
- qi_agent/themes/light.json +89 -0
- qi_agent/titling.py +90 -0
- qi_agent/tools/__init__.py +349 -0
- qi_agent/tools/shell.py +276 -0
- qi_agent/trust.py +106 -0
- qi_agent/tui.py +6224 -0
- qi_agent/workspaces.py +116 -0
- qi_coding_agent-0.1.0.dist-info/METADATA +151 -0
- qi_coding_agent-0.1.0.dist-info/RECORD +62 -0
- qi_coding_agent-0.1.0.dist-info/WHEEL +4 -0
- qi_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
- qi_coding_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
qi_agent/docs/skills.md
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# 技能
|
|
2
|
+
|
|
3
|
+
技能是**按需加载的能力说明**:一个目录 + 一份 `SKILL.md`。它的正文**不进系统提示词** —— 提示词里
|
|
4
|
+
只放名字、描述与位置,模型在任务匹配时再用 `read` 去读全文。这叫**渐进披露**:一个技能占提示词的
|
|
5
|
+
成本是两三行,而不是一整篇。
|
|
6
|
+
|
|
7
|
+
`SKILL.md` 用 YAML frontmatter 开头:
|
|
8
|
+
|
|
9
|
+
```markdown
|
|
10
|
+
---
|
|
11
|
+
name: code-review-checklist
|
|
12
|
+
description: 审代码时按这份清单逐项检查;涉及并发、错误处理与测试覆盖时使用。
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
(正文:具体怎么做。可以很长。)
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
| frontmatter 字段 | 必需 | 说明 |
|
|
19
|
+
| --- | --- | --- |
|
|
20
|
+
| `description` | **是** | **没有 description 的技能不会被装载**(静默跳过,不是报错) |
|
|
21
|
+
| `name` | 否 | 缺省时用目录名(`SKILL.md` 所在目录)或文件名(根级 `*.md` 的 stem) |
|
|
22
|
+
|
|
23
|
+
**事实源:`src/qi_agent/loader.py`(发现与冲突)+ `src/qi_agent/system_prompt.py`(注入)。**
|
|
24
|
+
|
|
25
|
+
## 1. 放哪里:六层来源
|
|
26
|
+
|
|
27
|
+
按**优先级从低到高**排;同名时高的覆盖低的。
|
|
28
|
+
|
|
29
|
+
| # | 目录 | 层标签 | 说明 |
|
|
30
|
+
| --- | --- | --- | --- |
|
|
31
|
+
| 1 | `~/.agents/skills/` | `agents-global` | **跨工具**(Agent Skills 标准的 `.agents` 目录),全局共享 |
|
|
32
|
+
| 2 | `~/.qi/agent/skills/` | `qi-global` | qi 私有,全局 |
|
|
33
|
+
| 3 | user `settings.json` 的 `skills[]` | `settings-global` | 追加路径,相对 `~/.qi/agent` 解析 |
|
|
34
|
+
| 4 | `.agents/skills/`(cwd → git 根,远→近) | `agents-project` | 跨工具,项目级 |
|
|
35
|
+
| 5 | `<git根>/.qi/skills/` | `qi-project` | qi 私有,项目级 |
|
|
36
|
+
| 6 | project `settings.json` 的 `skills[]` | `settings-project` | 追加路径,相对 `<git根>/.qi` 解析 |
|
|
37
|
+
|
|
38
|
+
第 1、2、4、5 层的**具体目录**按"跨工具层与 qi 私有层交替"排列(跨工具的 `.agents` 与私有的
|
|
39
|
+
`~/.qi` / `<git根>/.qi` 各占两层)。跨工具目录(`.agents`)与 qi 私有目录的区别不只是名字:
|
|
40
|
+
**跨工具目录只认 `<name>/SKILL.md` 这种结构,不接受根目录下散着的 `*.md`**(见 §2)。
|
|
41
|
+
|
|
42
|
+
两条容易踩的:
|
|
43
|
+
|
|
44
|
+
- 第 3、6 层的相对路径**各按自己所在的目录解析**(user 的按 `~/.qi/agent`,project 的按
|
|
45
|
+
`<git根>/.qi`)。用合并后的配置去解析,会让一侧的路径落到另一侧的基准上。
|
|
46
|
+
- 第 4、5 层是**项目级**(随仓库走)。**注意:项目级技能不受信任门控** —— 未信任的项目里
|
|
47
|
+
`.agents/skills` 与 `<git根>/.qi/skills` **仍会被装载**。这与项目级*扩展*不同:后者未信任就不扫
|
|
48
|
+
(因为扩展是可执行代码)。界线画在“可执行 vs 文本”上,而技能是文本指令。详见
|
|
49
|
+
[security.md](security.md) §2。
|
|
50
|
+
|
|
51
|
+
## 2. 一个技能根的扫描规则
|
|
52
|
+
|
|
53
|
+
对每个来源目录:
|
|
54
|
+
|
|
55
|
+
- **含 `SKILL.md` 的目录即技能,不再向内递归** —— 也就是说技能目录里可以再放参考文件,不会
|
|
56
|
+
被误当成子技能。
|
|
57
|
+
- **没有 `SKILL.md` 的子目录继续向内找** —— 支持分组目录(如 `skills/lang/python/SKILL.md`)。
|
|
58
|
+
- **根目录下带 `description` 的 `*.md` 也算独立技能** —— 但**只对 qi 侧目录生效**
|
|
59
|
+
(`~/.qi/agent/skills`、`<git根>/.qi/skills`、以及 `settings.skills[]` 指定的路径与 CLI `--skill`)。
|
|
60
|
+
跨工具的 `.agents/skills` **不开**这一条,以贴合 Agent Skills 标准。子目录里散着的 `*.md`
|
|
61
|
+
一律不算。
|
|
62
|
+
- 一个 `--skill` / `settings.skills[]` 条目**可以直接指向一个 `.md` 文件**,也可以指向目录。
|
|
63
|
+
|
|
64
|
+
## 3. 优先级与冲突
|
|
65
|
+
|
|
66
|
+
两种同名,处置**刻意不同**:
|
|
67
|
+
|
|
68
|
+
| 情况 | 行为 |
|
|
69
|
+
| --- | --- |
|
|
70
|
+
| **同一层内**同名 | **报错**(`LoadError`),列出冲突文件。同一层出现两个同名技能,说明配置本身有问题,不猜 |
|
|
71
|
+
| **跨层**同名 | **高优先级静默覆盖**,不提示。这是"项目覆盖全局"的正常用法 |
|
|
72
|
+
|
|
73
|
+
覆盖发生在装载时:`merged[name]` 被替换。所以最终只有一个同名技能存在,且是优先级最高的那个。
|
|
74
|
+
|
|
75
|
+
**排除项**:`settings` 的 `skills[]` 里以 `!` 或 `-` 开头的条目是排除项,作用于**整个发现集** ——
|
|
76
|
+
不只是"数组里纳入的那些根"。判定方式是路径匹配:技能的 `SKILL.md` 路径等于该排除路径,**或**
|
|
77
|
+
位于其下。
|
|
78
|
+
|
|
79
|
+
## 4. 模型怎么看到它
|
|
80
|
+
|
|
81
|
+
`system_prompt.render_skills()` 生成的块长这样:
|
|
82
|
+
|
|
83
|
+
```xml
|
|
84
|
+
<available_skills>
|
|
85
|
+
<skill>
|
|
86
|
+
<name>code-review-checklist</name>
|
|
87
|
+
<description>审代码时按这份清单逐项检查…</description>
|
|
88
|
+
<location>/Users/x/proj/.qi/skills/code-review-checklist/SKILL.md</location>
|
|
89
|
+
</skill>
|
|
90
|
+
</available_skills>
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
三条规则:
|
|
94
|
+
|
|
95
|
+
1. **只给名字、描述、位置**,正文由模型按需 `read`。描述写得准不准,直接决定模型会不会去读它 ——
|
|
96
|
+
所以 `description` 里应写清"**什么时候用**",而不只是"这是什么"。
|
|
97
|
+
2. **没有能读文件的工具时(`read` / `bash` 都不在),整块不注入**。注入了也读不到,只会诱导模型
|
|
98
|
+
去调用不存在的工具。
|
|
99
|
+
3. 提示词里明确要求:**`SKILL.md` 里出现相对路径时,以 `SKILL.md` 所在目录为基准解析,并在工具
|
|
100
|
+
命令里用绝对路径**。
|
|
101
|
+
|
|
102
|
+
## 5. 开关
|
|
103
|
+
|
|
104
|
+
| 方式 | 作用 |
|
|
105
|
+
| --- | --- |
|
|
106
|
+
| `qi --skill <路径>` | 追加技能(可重复,**叠加**);优先级**高于全部六层**(标签 `cli`) |
|
|
107
|
+
| `qi --no-skills` / `-ns` | 关闭**自动发现**(六层全跳过),但 **`--skill` 仍然生效** |
|
|
108
|
+
| `settings.json` 的 `skillsEnabled: false` | 等价于 `--no-skills`(只关发现) |
|
|
109
|
+
| `settings.json` 的 `skills: [...]` | 追加来源路径(第 3 / 6 层),支持 glob、`~`、相对路径与排除项 |
|
|
110
|
+
|
|
111
|
+
`--no-skills` 与 `--skill` 的组合语义是刻意的:"把自动发现的都关掉,只跑我指定的这一个" ——
|
|
112
|
+
调试某个技能时最常用的姿势。
|
|
113
|
+
|
|
114
|
+
## 6. 强制加载:`/skill:<名>`
|
|
115
|
+
|
|
116
|
+
渐进披露的另一面是"模型不一定去读"(提示词里只有一行描述)。TUI 里可以自己把它叫起来:
|
|
117
|
+
|
|
118
|
+
```text
|
|
119
|
+
/skill:brave-search # 加载并执行该技能
|
|
120
|
+
/skill:pdf-tools extract # 带参数(作为技能的入参)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
它把 SKILL.md **全文**提交给模型,效果与用户手贴内容等价,只是不用手抄。`settings.enableSkillCommands`(默认 `true`)关掉后
|
|
124
|
+
这批命令不存在。
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# 斜杠命令
|
|
2
|
+
|
|
3
|
+
TUI 里输入 `/` 会补全全部命令(内置的、扩展注册的、技能命令都在里面)。命令**在回合进行中也立即执行**
|
|
4
|
+
(与 `!` bash 一样),否则 `/quit`、`/tree` 这类会按不下去。
|
|
5
|
+
|
|
6
|
+
## 模型与设置
|
|
7
|
+
|
|
8
|
+
| 命令 | 说明 |
|
|
9
|
+
| --- | --- |
|
|
10
|
+
| `/model [p/m]` | 不给参数 = 开选择器(与 `ctrl+l` 同一个);给 `p/m` 或唯一模型名 = 直接切。**只列解析得出凭证的模型**。切换会落一条 `model_change`,下次打开这条会话仍用它 |
|
|
11
|
+
| `/scoped-models` | 挑 `ctrl+p` 轮换哪些模型(空 = 全部,写回 `settings.enabledModels`);已不可用的条目画删除线 + `[unavailable]`,取消勾选才移出 |
|
|
12
|
+
| `/thinking` | 不给参数 = 开选择器;切换落一条 `thinking_level_change` entry,下次打开按它还原 |
|
|
13
|
+
| `/settings` | 偏好面板:主题 / 思考级别 / 交互开关(`enter` 换值、`ctrl+s` 保存到**用户级** settings) |
|
|
14
|
+
| `/login [provider]` | 不给 provider 先弹选择器,然后**遮罩输入** API key → 写 `auth.json`(0600)并重建客户端;key 不进 transcript。qi 的凭证层**只有 api_key**,没有订阅登录(OAuth) |
|
|
15
|
+
| `/logout [provider]` | 删 `auth.json` 里那条(**环境变量与 `models.json` 的 `apiKey` 不受影响**);此后那家的模型不再进 `/model` 与 `ctrl+p` 清单 |
|
|
16
|
+
|
|
17
|
+
## 会话与上下文
|
|
18
|
+
|
|
19
|
+
| 命令 | 说明 |
|
|
20
|
+
| --- | --- |
|
|
21
|
+
| `/new` | 新会话(**不落文件** —— 与裸 `qi` 一样是预留,说话才落盘) |
|
|
22
|
+
| `/resume [id]` | 不给 id = 打开会话选择器;给 id = 直接恢复 |
|
|
23
|
+
| `/name <name>` | 会话显示名(进 footer)。**命名即落盘**:预留中的会话会因此立刻写出 —— 否则 `/new` / 退出会让名字连会话一起消失 |
|
|
24
|
+
| `/session` | 会话信息(ID / 文件 / cwd / 消息数·仅当前分支 / 节点数与分支点 / 模型 / 用量) |
|
|
25
|
+
| `/tree` | 会话树:跳到本会话任意节点继续(同文件内分支) |
|
|
26
|
+
| `/fork [序号\|id]` | 从某条用户消息**之前**分叉出新会话,并把那条消息放回编辑器 |
|
|
27
|
+
| `/clone [名字]` | 把当前分支复制成新会话 |
|
|
28
|
+
| `/compact [提示]` | 压缩上下文:把旧消息压成结构化摘要(可给一句关注点) |
|
|
29
|
+
|
|
30
|
+
## 导出与分享
|
|
31
|
+
|
|
32
|
+
| 命令 | 说明 |
|
|
33
|
+
| --- | --- |
|
|
34
|
+
| `/export [file]` | 导出会话:`.html` → 自包含 HTML(只含当前分支),其余 → 原始 JSONL;默认 `./qi-<id>.jsonl` |
|
|
35
|
+
| `/import <file>` | 从 JSONL 导入并切换会话(重名给提示,不静默覆盖) |
|
|
36
|
+
| `/copy` | 复制最后一条回答到剪贴板(OSC 52) |
|
|
37
|
+
| `/share` | 把当前会话传成**私有** GitHub gist(一个自包含 `.html`)并复制链接。直调 API,不依赖 `gh`;token 顺序照 qi 的凭证原则(auth store → 环境变量):`qi auth login github` → `GITHUB_TOKEN` → `GH_TOKEN` |
|
|
38
|
+
|
|
39
|
+
## 运行时与项目
|
|
40
|
+
|
|
41
|
+
| 命令 | 说明 |
|
|
42
|
+
| --- | --- |
|
|
43
|
+
| `/hotkeys` | 快捷键(哪些键位还没做也如实写明) |
|
|
44
|
+
| `/reload` | 重载扩展 / 技能 / 配置(主题改动需重开) |
|
|
45
|
+
| `/trust [yes\|no\|forget]` | 按**目录**记住信任决定(`~/.qi/agent/trust.json`);连带记住上一层,写完要重启才生效 |
|
|
46
|
+
| `/changelog` | 显示 `CHANGELOG.md`(qi 仓库暂无该文件) |
|
|
47
|
+
| `/quit` | 退出 |
|
|
48
|
+
|
|
49
|
+
## 由资源注册的命令
|
|
50
|
+
|
|
51
|
+
- 扩展用 `api.register_command` 注册的命令 —— 重名时都留着并变成 `name:1` / `name:2`,一个都不丢。
|
|
52
|
+
- 技能注册的 **`/skill:<名> [参数]`** —— 加载并执行该技能的全文(强制加载的入口);
|
|
53
|
+
由 `settings.enableSkillCommands` 控制,默认开。见 [skills.md](skills.md)。
|
|
54
|
+
|
|
55
|
+
`/quit` 与 `/hotkeys` **扩展顶不掉**(顶掉 `/quit` 等于把用户锁在界面里)。
|
qi_agent/docs/themes.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# 主题
|
|
2
|
+
|
|
3
|
+
qi 的 TUI 只有**两份内置调色板**:`dark` 与 `light`,就是 `src/qi_agent/themes/` 下的两个 JSON
|
|
4
|
+
(`dark.json` / `light.json`)—— 键名与语义固定成一套,要调配色直接改这两个文件。
|
|
5
|
+
|
|
6
|
+
> **qi 不支持自定义主题文件**:只有 `dark` / `light` 两个内置值,未知主题名会**报错**,
|
|
7
|
+
> 不会去找用户目录里的 JSON。见 §5。
|
|
8
|
+
|
|
9
|
+
**事实源:`src/qi_agent/theme.py` + `src/qi_agent/themes/{dark,light}.json`。**
|
|
10
|
+
|
|
11
|
+
## 1. 选哪一份
|
|
12
|
+
|
|
13
|
+
优先级(高 → 低):
|
|
14
|
+
|
|
15
|
+
| 顺序 | 来源 | 取值 |
|
|
16
|
+
| --- | --- | --- |
|
|
17
|
+
| 1 | 环境变量 `QI_THEME` | `dark` / `light` / `auto` |
|
|
18
|
+
| 2 | `settings.json` 的 `theme` | 同上 |
|
|
19
|
+
| 3 | **探测终端背景色**(OSC 11) | 亮背景 → `light`,暗背景 → `dark` |
|
|
20
|
+
| 4 | 兜底 | `dark` |
|
|
21
|
+
|
|
22
|
+
`auto`(以及未设置)走第 3 步。探测是**查询终端**(OSC 11,超时 150ms),不是猜:
|
|
23
|
+
|
|
24
|
+
- 终端支持 OSC 11 且回了 `#rrggbb` → 按亮度(`> 0.5` 视为亮)选 `light` / `dark`;
|
|
25
|
+
- **不支持 / 超时 / 回复解析不了 → 落 `dark`**,不报错、不卡住。
|
|
26
|
+
|
|
27
|
+
探测到的那个背景色会一并带在调色板上(`Palette.terminal_bg`):Textual 的 inline 区域必须有不透明
|
|
28
|
+
底色,把它设成终端背景色才能在视觉上"不填色"(qi 不画底色)。
|
|
29
|
+
|
|
30
|
+
## 2. 调色板文件长什么样
|
|
31
|
+
|
|
32
|
+
```jsonc
|
|
33
|
+
{
|
|
34
|
+
"$schema": "…/theme-schema.json",
|
|
35
|
+
"name": "dark",
|
|
36
|
+
"vars": { "cyan": "#00d7ff", "blue": "#5f87ff", … }, // 命名色(dark 16 项 / light 15 项)
|
|
37
|
+
"colors": { "accent": "accent", "border": "blue", … }, // 语义键 → 变量名 或 #hex(55 项)
|
|
38
|
+
"export": { "pageBg": "#18181e", "cardBg": "#1e1e24", … }
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
解析规则(`load_palette`):
|
|
43
|
+
|
|
44
|
+
- `colors` 的值**不以 `#` 开头**时,去 `vars` 里查同名变量;查不到就原样当颜色用。
|
|
45
|
+
- **全部转小写**。调色板 JSON 里大小写是混用的(`#2D2838` / `#343841`),而 Textual 的
|
|
46
|
+
`Color.hex` 会**原样保留**传入字符串的大小写 —— 所以归一必须在 `load_palette` 里做。
|
|
47
|
+
- `export.pageBg` 缺省为 `#18181e`。
|
|
48
|
+
- 取值键(语义色)拼错时**直接报错**(`Palette.hex()`),不会静默变成白色 —— 拼错颜色名最怕
|
|
49
|
+
的就是"看着像没生效"。
|
|
50
|
+
|
|
51
|
+
## 3. 同一份调色板喂给三个渲染层
|
|
52
|
+
|
|
53
|
+
`theme.py` 从一份 `Palette` 导出三个适配对象:
|
|
54
|
+
|
|
55
|
+
| 导出 | 给谁用 |
|
|
56
|
+
| --- | --- |
|
|
57
|
+
| `rich_theme(palette)` | Rich(非 TUI 场景的输出) |
|
|
58
|
+
| `syntax_theme(palette)` | 代码高亮 |
|
|
59
|
+
| `textual_theme(palette)` | Textual(TUI 本体) |
|
|
60
|
+
|
|
61
|
+
所以**改主题 = 改那 55 个语义键**,三个渲染层同时跟着变;不需要分别调。
|
|
62
|
+
|
|
63
|
+
## 4. 主题之外:`theme.py` 里还住着 footer 格式化
|
|
64
|
+
|
|
65
|
+
同一个文件里还有几个纯格式化函数(`format_tokens` / `shorten_home` / `format_cwd_line` /
|
|
66
|
+
`git_branch`),它们负责状态行里 `~/proj (main)` 这种显示。
|
|
67
|
+
它们与调色板无关,只是碰巧在同一个模块。
|
|
68
|
+
|
|
69
|
+
## 5. 不能做的事
|
|
70
|
+
|
|
71
|
+
| 想法 | 现状 |
|
|
72
|
+
| --- | --- |
|
|
73
|
+
| 自定义主题名 | **不支持**。`theme` 只认 `dark` / `light` / `auto`(`auto` 与未设等价);其他值会走"未知 → 退回 dark" |
|
|
74
|
+
| 放一份自己的 JSON 到 `~/.qi/agent/themes/` | **不支持**,没有用户主题目录 |
|
|
75
|
+
| 运行中切换主题 | 交互式前端可切(`ctrl+t` 之外见 [usage.md](usage.md));`QI_THEME` / `settings.theme` 在启动时决定初始值 |
|
|
76
|
+
|
|
77
|
+
要加自定义主题,需要的是:主题目录的发现规则 + `ThemeError` 之外的加载路径。现在没有,所以本文不描述
|
|
78
|
+
一个不存在的机制。
|
qi_agent/docs/tui.md
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# 终端 UI 组件
|
|
2
|
+
|
|
3
|
+
扩展往界面里加东西的三种集成点。用哪个取决于你要的是"问一句话"还是"接管一块屏幕"。
|
|
4
|
+
|
|
5
|
+
| 集成点 | 用在哪 | 入口 |
|
|
6
|
+
| --- | --- | --- |
|
|
7
|
+
| **数据层** | 问用户、改状态、读外观 | `ctx.ui.confirm` / `select` / `input` / `editor` / `notify` / `set_status` … |
|
|
8
|
+
| **组件层**(TUI-only) | 往界面里塞自己的组件 | `ctx.ui.custom` / `set_widget` / `set_footer` / `set_header` / `set_editor_component` / `add_autocomplete_provider` / `on_terminal_input` |
|
|
9
|
+
| **渲染回调** | 换掉工具卡片 / 消息 / markdown 的渲染 | `register_entry_renderer` / `register_message_renderer` / `register_markdown_transformer` / `Tool.render_call` / `render_result` / `render_shell` |
|
|
10
|
+
|
|
11
|
+
`ctx.ui` **总是存在**:没有前端时按调用方给的 `default` 回答,`notify` 落进启动提示,永远不会挂住。
|
|
12
|
+
|
|
13
|
+
## 数据层
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
async def confirm(message, *, title=None, default=False) -> bool
|
|
17
|
+
async def select(message, options, *, title=None, default=None) -> str | None
|
|
18
|
+
async def input(message, *, title=None, default=None, secret=False) -> str | None
|
|
19
|
+
async def editor(prefill="", *, title=None) -> str | None # 多行
|
|
20
|
+
async def notify(message, *, level="info") -> None
|
|
21
|
+
# 状态与外观
|
|
22
|
+
async def set_status(key, text) / set_title(text)
|
|
23
|
+
await set_working_message(text) / set_working_visible(bool)
|
|
24
|
+
await set_working_indicator({"frames": [...], "intervalMs": int})
|
|
25
|
+
await set_hidden_thinking_label(label)
|
|
26
|
+
await get_tools_expanded() / set_tools_expanded(bool)
|
|
27
|
+
await get_all_themes() / get_theme(name) / set_theme(theme) / theme
|
|
28
|
+
await paste_to_editor / set_editor_text / get_editor_text
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
**两条硬规则**:
|
|
32
|
+
|
|
33
|
+
1. **没有后端时每个问答方法返回调用方给的 `default`** —— "交互"退化成"按事先声明好的策略走",
|
|
34
|
+
而**永远不会挂住**。`confirm` 默认 **False**(拒绝是安全边);`select` / `input` / `editor` 默认 None(取消)。
|
|
35
|
+
默认值由**调用方**给:只有它知道"这里拒绝安全还是继续安全"。
|
|
36
|
+
2. `notify` 没有后端时**落进 `notes`**,不丢弃。
|
|
37
|
+
|
|
38
|
+
后端自己抛异常也走 `default`(记一条 note):交互是辅助手段,不该成为新的失败点。
|
|
39
|
+
阻塞式问答还会发 `ui_prompt_start` / `ui_prompt_end`(`{reason, kind, title}`),扩展因此能知道"有人在等交互"。
|
|
40
|
+
|
|
41
|
+
## 组件层
|
|
42
|
+
|
|
43
|
+
- `set_widget(fn_or_lines, placement=…)` —— 挂进编辑器上方 / 下方的两个槽位。
|
|
44
|
+
- `custom(fn)` —— 放进模态:签名叫 `fn(ctx, done)` / `fn(ctx)` / `fn()`,返回组件;`done(value)` 结束并返回。
|
|
45
|
+
非 TUI 前端下返回 `None`。
|
|
46
|
+
- `set_footer(factory)` / `set_header(factory)` —— 内置的只藏起来不卸载,撤回立即恢复。
|
|
47
|
+
- `set_editor_component(factory)` —— 换掉 `#editor`。**必须返回 `TextArea`(或子类)**,否则记 note 并拒绝:
|
|
48
|
+
qi 的历史环 / kill-ring / 补全面板 / 光标定位都建立在这个接口上。写个 `TextArea` 子类就能做 vim 式模态编辑。
|
|
49
|
+
- `add_autocomplete_provider(factory)` —— `factory(text, cursor_offset) -> list[str | {value, label, description}]`,
|
|
50
|
+
与命令参数补全**同一套元素形状**;**返回退订函数**;支持 async(晚一拍回填,文本动过就丢弃)。
|
|
51
|
+
- `on_terminal_input(handler)` —— 只支持 `{"consume": True}`(吃掉按键),不支持"换成另一个键":
|
|
52
|
+
Textual 的 `events.Key` 不是为改写设计的,要改写就用 `set_editor_component` 接管编辑器。返回退订函数。
|
|
53
|
+
|
|
54
|
+
组件是 **textual widget**;`ctx.mode`(`tui` / `rpc` / `json` / `print`)是组件层的门控口
|
|
55
|
+
(`if ctx.mode == "tui":`)。**按形参个数适配**:声明三个形参(`x, theme, context`)会拿到
|
|
56
|
+
`(payload, ctx, None)`,不报错也不静默失效。
|
|
57
|
+
|
|
58
|
+
## 渲染回调
|
|
59
|
+
|
|
60
|
+
回调直接返回组件(或 markdown 字符串);**抛异常 / 返回 `None` → 退回内置渲染** + 在界面上说一句 ——
|
|
61
|
+
工具卡片与消息绝不会因为一个坏回调而消失。
|
|
62
|
+
|
|
63
|
+
| 钩子 | 签名 | 位置 |
|
|
64
|
+
| --- | --- | --- |
|
|
65
|
+
| `register_entry_renderer(customType, fn)` | `fn(entry, ctx) -> Widget \| None` | 回放遇到该 `custom_type` 时;未知 custom entry 会以 `[type] {json}` 原样显示,**绝不丢弃** |
|
|
66
|
+
| `register_message_renderer(customType, fn)` | `fn(entry, ctx) -> Widget \| None` | 同上,用于带 `custom_type` 的 **message** entry(即 `api.send_message` 以 dict 形状写的那些) |
|
|
67
|
+
| `register_markdown_transformer(fn)` | `fn(markdown, ctx) -> str` | user / assistant 的**最终文本**渲染前链式改写(流式增量不逐个改 —— 半截 markdown 改了更糟) |
|
|
68
|
+
| `Tool.render_call` | `fn(args, ctx) -> Widget \| None` | `tool_start` 时整个替换默认卡片 |
|
|
69
|
+
| `Tool.render_result` | `fn(result, ctx) -> Widget \| None` | `tool_end` 时替换结果组件 |
|
|
70
|
+
| `Tool.render_shell` | `"default"` / `"self"` | `"default"` 给组件套上工具卡片外壳(状态底色 + `(1,1)` 内边距);`"self"` = 自己画框 |
|
|
71
|
+
|
|
72
|
+
工具块可以是内置 `ToolBlock` 也可以是 `ExtensionToolBlock` —— 两者都实现 `set_state` / `set_output`,
|
|
73
|
+
所以展开 / 复制 / 会话回放不必分叉。
|
|
74
|
+
|
|
75
|
+
## 例:自定义组件 + 工具卡片
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from qi_agent.extensions import Tool
|
|
79
|
+
from textual.widgets import Static
|
|
80
|
+
|
|
81
|
+
def register(api):
|
|
82
|
+
# 1) 往编辑器上方挂一行字
|
|
83
|
+
api.on("session_start", lambda payload, ctx: ctx.ui.set_widget(lambda ctx: Static("my-ext 在看着"), placement="above"))
|
|
84
|
+
|
|
85
|
+
# 2) 一个模态:问一句话,把结果当返回
|
|
86
|
+
def ask_lot(ctx, done):
|
|
87
|
+
async def pick(value=None):
|
|
88
|
+
done(value)
|
|
89
|
+
return ctx.ui.custom(lambda ctx: Static("…")) # 真实组件按你的界面写
|
|
90
|
+
|
|
91
|
+
# 3) 工具用自己的卡片渲染
|
|
92
|
+
async def run(args, ctx):
|
|
93
|
+
return "ok"
|
|
94
|
+
|
|
95
|
+
api.register_tool(Tool(
|
|
96
|
+
name="my_tool", description="示例", parameters={"type": "object", "properties": {}},
|
|
97
|
+
execute=run,
|
|
98
|
+
render_call=lambda args, ctx: Static(f"my_tool {args}"),
|
|
99
|
+
render_result=lambda result, ctx: Static(str(result)),
|
|
100
|
+
))
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
配色请走主题的语义键(别写死 `#rrggbb`),否则换主题时你的组件不会跟着变 —— 见 [themes.md](themes.md)。
|
|
104
|
+
|
|
105
|
+
## 与其它页面的关系
|
|
106
|
+
|
|
107
|
+
- 扩展整体的写法(工具、事件、命令、旗标、依赖)见 [extensions.md](extensions.md)。
|
|
108
|
+
- 键位与编辑器行为见 [keybindings.md](keybindings.md)。
|
|
109
|
+
- 实现细节(Textual 的 priority / 焦点坑)见 [design/internals.md](../design/internals.md)。
|
qi_agent/docs/usage.md
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# 在终端里用 qi
|
|
2
|
+
|
|
3
|
+
交互形态的用法。一次性与脚本化用法在 [命令行](cli.md);斜杠命令清单在 [斜杠命令](slash-commands.md);
|
|
4
|
+
键位在 [键位](keybindings.md)。
|
|
5
|
+
|
|
6
|
+
## 输入第一句
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
qi # 进界面,什么都不发
|
|
10
|
+
qi "分析这个仓库" # 进界面,并把这句话作为首条消息发出
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
- `@path` 把文件内容带进第一条消息(`@xx` 会补全路径:装了 `fd` 走全树搜索,否则只扫当前目录一层)。
|
|
14
|
+
- `enter` 提交,`shift+enter` / `ctrl+j` 换行;输入框最多 8 行。
|
|
15
|
+
- `↑` / `↓` 在空编辑器或行首时翻输入历史(首次进入留住草稿,手动改动即退出)。
|
|
16
|
+
- **选项写在消息之前**(`qi -nt "问题"`):顶层选项在遇到消息后不再解析。
|
|
17
|
+
|
|
18
|
+
## 看它怎么干活
|
|
19
|
+
|
|
20
|
+
transcript 从下往上长,底部固定是「编辑器 + 上下边框 + footer」。footer 最多三行:
|
|
21
|
+
|
|
22
|
+
```text
|
|
23
|
+
~/Desktop/qi (master) • 会话名 ← cwd(+git 分支)(+会话名)
|
|
24
|
+
↑12k ↓678 1.2%/1.0M (auto) deepseek/v4.1 • medium ← token 统计 + 模型与思考级别
|
|
25
|
+
排队 2 / 扩展状态 / 瞬时提示 ← 只在这些内容时出现
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
- 工具调用是一块带底色的卡片(标题 = `read <path>`,输出按工具截断);`ctrl+o` 展开 / 折叠。
|
|
29
|
+
- 思考内容与回答**分开流式**(灰色斜体),`ctrl+t` 随时隐藏 / 显示。
|
|
30
|
+
- 压缩与分支摘要在回放里显示成底色块(`[compaction]` / `[branch]`),`ctrl+o` 展开看摘要。
|
|
31
|
+
- 正在跑时上边框内嵌 `Working` spinner;`escape` 中断(协作式:未执行的工具补"已中断"结果、半截回答照常落盘)。
|
|
32
|
+
|
|
33
|
+
## 改变方向
|
|
34
|
+
|
|
35
|
+
| 操作 | 行为 |
|
|
36
|
+
| --- | --- |
|
|
37
|
+
| 回合中 `enter` | **steer**:排队,当前回合结束后立刻作为下一回合发出 |
|
|
38
|
+
| 回合中 `alt+enter` | **follow-up**:排队,排在所有 steer 之后 |
|
|
39
|
+
| `escape` | 中止当前轮;有排队消息就退回编辑器 |
|
|
40
|
+
| `alt+up` | 取回排队消息(多条按 steer → follow-up 拼回编辑器) |
|
|
41
|
+
|
|
42
|
+
排队消息会以 dim 行写进 transcript(带"当前回合结束后发送"/"follow-up"标签),不会静默丢。
|
|
43
|
+
**命令(`/x`)与 bash(`!x`)例外:回合进行中也立即执行** —— 否则 `/quit`、`/tree` 这类按不下去。
|
|
44
|
+
`/new` 清空队列;队列只存在当前进程里,不落盘。
|
|
45
|
+
|
|
46
|
+
## 换模型与设置
|
|
47
|
+
|
|
48
|
+
| 想换 | 怎么做 |
|
|
49
|
+
| --- | --- |
|
|
50
|
+
| 模型 | `/model`(选择器)或 `ctrl+l`;`ctrl+p` / `ctrl+shift+p` 在 `/scoped-models` 圈定的清单里轮换 |
|
|
51
|
+
| 思考级别 | `/thinking` 或 `shift+tab` 循环(off → minimal → … → max);`--thinking <级别>` 只本次运行 |
|
|
52
|
+
| 偏好(主题 / 交互开关) | `/settings` 面板(`enter` 换值、`ctrl+s` 保存到**用户级** settings) |
|
|
53
|
+
| 本次运行的模型 | `qi --provider <名> --model <provider/模型>:<级别>` |
|
|
54
|
+
|
|
55
|
+
**切换会落盘**:换模型写一条 `model_change`、换级别写一条 `thinking_level_change`,所以**下次打开这条会话
|
|
56
|
+
仍用当时的模型与级别**(CLI 显式给的 `--model` / `--thinking` 优先)。还原前会检查凭证:provider 已经没密钥
|
|
57
|
+
时退回默认并在启动提示里说明,不会拿着注定 401 的模型继续跑。
|
|
58
|
+
|
|
59
|
+
思考级别经 litellm 的 `reasoning_effort` 下发(`minimal` / `low` / `medium` / `high`);`xhigh` / `max`
|
|
60
|
+
收敛为 `high`。只有模型在 `models.json` 里声明 `reasoning: true` 时才带参。provider 拒收时自动去掉参数
|
|
61
|
+
重试并提示一次。**默认 `medium`**。
|
|
62
|
+
|
|
63
|
+
## 接着还是重开
|
|
64
|
+
|
|
65
|
+
| 想做的事 | 怎么做 |
|
|
66
|
+
| --- | --- |
|
|
67
|
+
| 接着最近一条 | `qi -c` |
|
|
68
|
+
| 指定某条 | `qi --session <id\|前缀\|路径>` |
|
|
69
|
+
| 浏览 / 恢复 / 重命名 / 删除 | `/resume`(或 `qi -r` 直接进选择器;`tab` 切"当前目录 ↔ 全部") |
|
|
70
|
+
| 从某条分叉出新会话 | `/fork [序号\|id]`(把那条消息放回编辑器)、`/clone`、`qi --fork <id>` |
|
|
71
|
+
| 跳到本会话的别的节点继续 | `/tree`(同文件内开分支;被放弃的那段会压成 `branch_summary` 挂过去) |
|
|
72
|
+
| 不落盘地跑一把 | `qi --no-session`(内存会话) |
|
|
73
|
+
|
|
74
|
+
**裸 `qi` 与 `/new` 走懒建**:会话对象立刻就有,但**文件推迟到第一条助手回答**才出现 ——"进来看看就退出"
|
|
75
|
+
不会在会话目录里留下空文件。`/session` 看当前会话信息(ID / 文件 / cwd / 消息数 / 分支点 / 模型 / 用量)。
|
|
76
|
+
|
|
77
|
+
## 跑一条终端命令
|
|
78
|
+
|
|
79
|
+
| 写法 | 行为 |
|
|
80
|
+
| --- | --- |
|
|
81
|
+
| `!<命令>` | 执行 shell;输出以「上下 `─` + `$ cmd` + 输出」块展示,并把「命令 + 输出」落成一条 user 消息供后续回合参考 |
|
|
82
|
+
| `!!<命令>` | 同样执行,但**不进上下文**(块边框变 dim,标题标出) |
|
|
83
|
+
|
|
84
|
+
这是**你亲手敲的**操作:`!` 命令不经任何过滤(与模型走 `bash` 工具是两回事,见 [security.md](security.md))。
|
|
85
|
+
|
|
86
|
+
## 复制、导出、分享
|
|
87
|
+
|
|
88
|
+
| 操作 | 命令 |
|
|
89
|
+
| --- | --- |
|
|
90
|
+
| 复制最后一条回答 | `/copy` 或 `ctrl+x`(剪贴板走 OSC 52) |
|
|
91
|
+
| 导出会话 | `/export [file]` 或 `qi --export out.jsonl`(按扩展名:`.html` → 自包含 HTML(只含当前分支),其余 → 原始 JSONL) |
|
|
92
|
+
| 导入会话 | `/import <file>` |
|
|
93
|
+
| 分享 | `/share` —— 传成**私有** GitHub gist 并复制链接(token 见 [providers.md](providers.md)) |
|
|
94
|
+
|
|
95
|
+
## 调整终端
|
|
96
|
+
|
|
97
|
+
- **渲染模式**:`fullscreen`(qi 默认)= 进备用屏、qi 拥有视口,滚轮只在界面内滚;**`regular`** = 不占全屏,
|
|
98
|
+
滚动交给终端。改 `settings.tuiMode` 或 `--tui-mode <regular|fullscreen>`;改完要**重启**(`--tui-mode` 只覆盖当次)。
|
|
99
|
+
- **配色**:`dark` / `light` / `auto`(探测终端背景色);`QI_THEME=light qi`。见 [themes.md](themes.md)。
|
|
100
|
+
- **版式**:`editorPaddingX` / `outputPad` / `autocompleteMaxVisible` 在 settings 里调。
|
|
101
|
+
|
|
102
|
+
## 出问题时
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
qi doctor # 配置 / 凭证 / 扩展 / 包声明,一次全报(并给出可复制的修复命令)
|
|
106
|
+
qi config --json # 合并后的设置(含来自哪几个文件)
|
|
107
|
+
qi --list-models # provider / 模型 / 凭据状态
|
|
108
|
+
qi --verbose -p "问题" # 无头模式下也看进度(stderr,不污染 stdout)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`qi doctor` 的凭证一节会给出**来源**(`auth(...)` / `env:XXX` / `config:...`)——
|
|
112
|
+
"我明明设了环境变量"这类问题能直接看出是"没读到"还是"读到了但为空"。
|
qi_agent/export_html.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""会话导出成**单文件 HTML**(pi 的 `/export [file]`:按扩展名选 HTML 或 JSONL)。
|
|
2
|
+
|
|
3
|
+
三件事刻意这么做:
|
|
4
|
+
|
|
5
|
+
* **只导当前分支** —— 用户看到的、能接着跑的就是那一条(与会话回放同一口径);整个文件
|
|
6
|
+
(含其它分支)仍然可以用 `.jsonl` 导出,那条路没变;
|
|
7
|
+
* **自包含**:CSS 内联、不引任何外部资源 —— 这样一个文件就能分享(将来 `/share` 上传的
|
|
8
|
+
正是它),离线打开也不掉样式;
|
|
9
|
+
* **一切内容都转义**:这是**可分享的产物**,而会话里装的是用户与模型的自由文本 ——
|
|
10
|
+
模型输出里的 `<script>` 在别人的浏览器里执行,是这类导出最典型的坑。
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import html
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
_CSS = """body { margin: 0 auto; max-width: 52rem; padding: 2rem 1rem;
|
|
20
|
+
font: 15px/1.6 -apple-system, "Segoe UI", "Noto Sans CJK SC", sans-serif;
|
|
21
|
+
background: #faf9f7; color: #23201c; }
|
|
22
|
+
h1 { font-size: 1.1rem; margin: 0 0 .25rem; }
|
|
23
|
+
.meta { color: #6b635a; font-size: .8rem; margin-bottom: 1.5rem; }
|
|
24
|
+
.turn { border-left: 3px solid #d8d2c8; padding: .1rem 0 .1rem .8rem; margin: 1rem 0;
|
|
25
|
+
white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
26
|
+
.user { border-color: #8a7f6d; }
|
|
27
|
+
.assistant { border-color: #6b8ca8; }
|
|
28
|
+
.tool { border-color: #b3a894; color: #554e46; font-family: ui-monospace, monospace;
|
|
29
|
+
font-size: .85rem; }
|
|
30
|
+
.note { color: #6b635a; font-style: italic; }
|
|
31
|
+
.role { display: block; font-size: .7rem; letter-spacing: .06em; text-transform: uppercase;
|
|
32
|
+
color: #8a7f6d; margin-bottom: .2rem; }"""
|
|
33
|
+
|
|
34
|
+
_ROLE_LABEL = {"user": "你", "assistant": "助手", "tool": "工具"}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _text_of(content: Any) -> str:
|
|
38
|
+
"""`content` → 纯文本。字符串直接用;分段列表取其文本;其它形状退 `str()`。
|
|
39
|
+
|
|
40
|
+
(会话 entry 的 `content` 多数是字符串,但工具结果与多段消息可能是列表 ——
|
|
41
|
+
宁可退化成可读的字符串,也不要在这里抛。)
|
|
42
|
+
"""
|
|
43
|
+
if isinstance(content, str):
|
|
44
|
+
return content
|
|
45
|
+
if isinstance(content, list):
|
|
46
|
+
parts: list[str] = []
|
|
47
|
+
for item in content:
|
|
48
|
+
if isinstance(item, str):
|
|
49
|
+
parts.append(item)
|
|
50
|
+
elif isinstance(item, dict) and isinstance(item.get("text"), str):
|
|
51
|
+
parts.append(item["text"])
|
|
52
|
+
return "\n".join(parts)
|
|
53
|
+
return "" if content is None else str(content)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def render_session_html(session: Any, *, title: str | None = None) -> str:
|
|
57
|
+
"""把会话的**当前分支**渲染成一个自包含 HTML 字符串。"""
|
|
58
|
+
heading = title or getattr(session, "title", "") or "会话"
|
|
59
|
+
created = str(getattr(session, "created_at", "") or "")
|
|
60
|
+
sid = str(getattr(session, "id", "") or "")
|
|
61
|
+
where = str(getattr(session, "cwd", "") or "")
|
|
62
|
+
rows: list[str] = []
|
|
63
|
+
for entry in session.branch():
|
|
64
|
+
kind = str(entry.get("type") or "")
|
|
65
|
+
if kind == "message":
|
|
66
|
+
role = str(entry.get("role") or "assistant")
|
|
67
|
+
body = _text_of(entry.get("content"))
|
|
68
|
+
if not body.strip():
|
|
69
|
+
continue
|
|
70
|
+
label = _ROLE_LABEL.get(role, role)
|
|
71
|
+
rows.append(f'<div class="turn {html.escape(role)}">'
|
|
72
|
+
f'<span class="role">{html.escape(label)}</span>'
|
|
73
|
+
f'{html.escape(body)}</div>')
|
|
74
|
+
elif kind in ("compaction", "branch_summary"):
|
|
75
|
+
summary = _text_of(entry.get("summary"))
|
|
76
|
+
who = "上下文压缩" if kind == "compaction" else "分支摘要"
|
|
77
|
+
rows.append(f'<div class="turn note">{html.escape(who)}:{html.escape(summary)}</div>')
|
|
78
|
+
meta = " · ".join(part for part in (sid, created, where) if part)
|
|
79
|
+
return ("<!doctype html>\n<html lang=\"zh\">\n<head>\n<meta charset=\"utf-8\">\n"
|
|
80
|
+
f"<title>{html.escape(heading)}</title>\n<style>\n{_CSS}\n</style>\n</head>\n<body>\n"
|
|
81
|
+
f"<h1>{html.escape(heading)}</h1>\n"
|
|
82
|
+
f'<p class="meta">{html.escape(meta)}</p>\n'
|
|
83
|
+
+ "\n".join(rows) + "\n</body>\n</html>\n")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def write_session_html(session: Any, out: Path, *, title: str | None = None) -> Path:
|
|
87
|
+
"""写文件(目录不存在就建)。返回写到的路径。"""
|
|
88
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
out.write_text(render_session_html(session, title=title), encoding="utf-8")
|
|
90
|
+
return out
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def html_wanted(path: Path) -> bool:
|
|
94
|
+
"""`.html` / `.htm` → 导 HTML;其余(含 `.jsonl`)→ 导原始 JSONL(pi 同口径)。"""
|
|
95
|
+
return path.suffix.lower() in (".html", ".htm")
|