mirror of
https://github.com/Wx-2025/ST-Amily2-Chat-Optimisation.git
synced 2026-06-06 08:05:49 +00:00
Compare commits
5 Commits
2.1.0
...
dabc8992f1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dabc8992f1 | ||
|
|
d9fa3072a2 | ||
|
|
4bc6e0a047 | ||
|
|
31d00f4330 | ||
|
|
13d05651f3 |
96
51TODO.md
Normal file
96
51TODO.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# 51TODO — 劳动节后开工清单
|
||||
|
||||
> 创建于 2026-04-28。计划在 5月1日劳动节假后启动。
|
||||
> 本文件聚焦跨方向工作(Bus 升级 + 整体节奏)。
|
||||
> 表格模块的解耦与三模式落地详见 [TableTODO.md](TableTODO.md)。
|
||||
|
||||
---
|
||||
|
||||
## 一、全景
|
||||
|
||||
两条并行主线:
|
||||
|
||||
1. **Bus tool-call 能力升级**(本文 Phase A) —— 让任何 Amily2Bus 注册的插件都能定义自己的 tool_calls 工具集,LLM 调用时自动 dispatch 回 handler,跑 agent loop。
|
||||
2. **表格模块重构 + 三模式填表** —— 解耦 manager.js 上帝模块;新增 JSON / toolcall 填表模式;保留 legacy 默认,老用户零感知。详见 [TableTODO.md](TableTODO.md)。
|
||||
|
||||
两条线**可并行**,仅在表格的 toolcall 模式(TableTODO Phase C)落地时需要 Bus Phase A 完成。
|
||||
|
||||
---
|
||||
|
||||
## 二、Phase A:Bus tool-call 升级
|
||||
|
||||
### A.1 ToolRegistry
|
||||
|
||||
- 新文件 `SL/bus/tool/ToolRegistry.js`
|
||||
- 内部 `Map<pluginName, Map<toolName, { def, handler }>>`
|
||||
- 完全私有,不跨插件查询(每个模块自己用自己的工具集,不共享)
|
||||
|
||||
### A.2 plugin context 加 tool 能力
|
||||
|
||||
- `register(pluginName)` 返回的 context 上挂 `tool`:
|
||||
- `define(name, { description, parameters }, handler)`
|
||||
- `undefine(name)`
|
||||
- `list()`
|
||||
|
||||
### A.3 Options + RequestBody 透传 tools
|
||||
|
||||
- [Options.js](SL/bus/api/Options.js) 加 `tools` / `toolChoice` 字段
|
||||
- [RequestBody.toPayload](SL/bus/api/RequestBody.js) 在有 tools 时包进 payload
|
||||
- `ModelCaller._normalize` 在响应含 `tool_calls` 时返回完整 message 对象(而非只返字符串)—— 注意做后向兼容标记
|
||||
|
||||
### A.4 callWithTools agent loop
|
||||
|
||||
- `context.model.callWithTools(messages, options, { maxSteps = 8, onToolError = 'feedback' })`
|
||||
- 自动拼本插件 define 的工具进 request
|
||||
- 收 tool_calls → 串行 dispatch 到对应 handler → tool result 回喂 messages
|
||||
- handler 抛错时 catch,把 error string 作为 tool_result 喂回 LLM 让其自纠
|
||||
- maxSteps 兜底,防死循环
|
||||
|
||||
**Phase A 验收**:
|
||||
|
||||
- [ ] 写一个最简 ping tool 跑通 round-trip
|
||||
- [ ] handler 抛错回喂 LLM,LLM 能自纠
|
||||
- [ ] maxSteps 截断行为正确
|
||||
|
||||
**预估**:1.5 天人时,风险中(agent loop 边界条件多)。
|
||||
|
||||
---
|
||||
|
||||
## 三、跨方向决策点
|
||||
|
||||
> 假后开工前先拍:
|
||||
|
||||
1. **Phase A 与 TableTODO Phase 0 谁先**:
|
||||
- 选项 A:先 Phase A(Bus 升级),再 Table Phase 0
|
||||
- 选项 B:先 Table Phase 0(解耦),再 Phase A
|
||||
- 选项 C:并行两条分支
|
||||
- 倾向:B(Table Phase 0 不依赖 Bus,先把表格上帝模块拆了,后续 Phase A 也好用 ToolRegistry)
|
||||
|
||||
2. **Phase A 是否必须 ship 才能开 Table Phase B**:
|
||||
- 不必须。Phase B(JSON formatter)独立。Phase C(toolcall)才依赖 Phase A。
|
||||
|
||||
3. **是否合并发版**:
|
||||
- 选项 A:Phase 0 → 单独 ship → Phase A → ship → Phase B/C → ship(增量发布,回归风险低)
|
||||
- 选项 B:全部攒一起一次性发(节奏简单但风险高)
|
||||
- 倾向:A,每完成一段先发,老用户始终能用 legacy。
|
||||
|
||||
---
|
||||
|
||||
## 四、不在范围内
|
||||
|
||||
- 不重写 ui/table-bindings.js
|
||||
- 不改持久化 schema
|
||||
- 不改 SuperMemory 集成
|
||||
- 不引入 TypeScript
|
||||
|
||||
---
|
||||
|
||||
## 五、工时汇总
|
||||
|
||||
| 主线 | 子项 | 估时 |
|
||||
| ---- | ---- | ---- |
|
||||
| Bus | Phase A (tool-call 升级) | 1.5 天 |
|
||||
| 表格 | TableTODO Phase 0-C | ~5 天(详见 TableTODO §十) |
|
||||
| 验收 | 整体回归 + UI 验证 | 1 天 |
|
||||
|
||||
**合计 ~7.5 天人时。** 假期 5 天 + 假后两周缓冲,5 月底前可全量上线。
|
||||
154
DPS.drawio
Normal file
154
DPS.drawio
Normal file
@@ -0,0 +1,154 @@
|
||||
<mxfile host="65bd71144e" modified="2026-04-29T00:00:00.000Z" agent="Claude" version="22.0.0" type="device">
|
||||
<diagram id="dps" name="Domain-Pipeline-Service">
|
||||
<mxGraphModel dx="1422" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1200" pageHeight="900" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0" />
|
||||
<mxCell id="1" parent="0" />
|
||||
<mxCell id="title" value="表格模块 — Domain → Operation → Pipeline → Service 五层架构" style="text;html=1;align=center;fontSize=20;fontStyle=1" vertex="1" parent="1">
|
||||
<mxGeometry x="180" y="20" width="840" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="subtitle" value="自下而上:Domain(纯逻辑) → Operation(动作) → Pipeline(三模式) → Service(门面) → UI(订阅)" style="text;html=1;align=center;fontSize=12;fontStyle=2;fontColor=#666666" vertex="1" parent="1">
|
||||
<mxGeometry x="160" y="50" width="880" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="uiLayer" value="UI Layer — 现有,不动;通过订阅事件刷新" style="swimlane;fontStyle=1;fillColor=#e1d5e7;strokeColor=#9673a6;startSize=30" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="80" width="1120" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ui1" value="ui/table-bindings.js" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6" vertex="1" parent="uiLayer">
|
||||
<mxGeometry x="320" y="32" width="220" height="36" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="ui2" value="ui/message-table-renderer.js" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6" vertex="1" parent="uiLayer">
|
||||
<mxGeometry x="580" y="32" width="220" height="36" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="serviceLayer" value="Service Layer — 顶层门面,组合下层" style="swimlane;fontStyle=1;fillColor=#dae8fc;strokeColor=#6c8ebf;startSize=30" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="190" width="1120" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="svc" value="TableSystemService
processMessageUpdate / fillSecondary / fillBatch / reorganize / rollback" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=11" vertex="1" parent="serviceLayer">
|
||||
<mxGeometry x="280" y="20" width="560" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pipelineLayer" value="Pipeline Layer — 三模式落地;formatter 可插拔" style="swimlane;fontStyle=1;fillColor=#d5e8d4;strokeColor=#82b366;startSize=30" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="300" width="1120" height="200" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="formattersGroup" value="formatters/" style="rounded=0;whiteSpace=wrap;html=1;fillColor=none;strokeColor=#82b366;dashed=1;verticalAlign=top;fontStyle=1" vertex="1" parent="pipelineLayer">
|
||||
<mxGeometry x="20" y="40" width="500" height="140" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="fmtIdx" value="index.js
按 settings 分发" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=10" vertex="1" parent="formattersGroup">
|
||||
<mxGeometry x="160" y="30" width="180" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="fmtLeg" value="legacy.js
<Amily2Edit>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=10" vertex="1" parent="formattersGroup">
|
||||
<mxGeometry x="20" y="85" width="140" height="44" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="fmtJson" value="json.js
{operations}" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=10" vertex="1" parent="formattersGroup">
|
||||
<mxGeometry x="180" y="85" width="140" height="44" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="fmtTC" value="toolcall.js
Bus tools" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=10" vertex="1" parent="formattersGroup">
|
||||
<mxGeometry x="340" y="85" width="140" height="44" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="fillerGroup" value="filler/" style="rounded=0;whiteSpace=wrap;html=1;fillColor=none;strokeColor=#82b366;dashed=1;verticalAlign=top;fontStyle=1" vertex="1" parent="pipelineLayer">
|
||||
<mxGeometry x="560" y="40" width="540" height="140" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="fillShared" value="shared.js
worldbook + history + buildMessages + callModel" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=10" vertex="1" parent="fillerGroup">
|
||||
<mxGeometry x="60" y="30" width="420" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="fillSec" value="secondary.js
触发条件 + 楼层扫描" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=10" vertex="1" parent="fillerGroup">
|
||||
<mxGeometry x="20" y="85" width="160" height="44" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="fillBatch" value="batch.js
批次循环" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=10" vertex="1" parent="fillerGroup">
|
||||
<mxGeometry x="200" y="85" width="160" height="44" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="fillReorg" value="reorganize.js" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=10" vertex="1" parent="fillerGroup">
|
||||
<mxGeometry x="380" y="85" width="140" height="44" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="opLayer" value="Operation Layer — 统一动作;从 executor.js 抽出" style="swimlane;fontStyle=1;fillColor=#fff2cc;strokeColor=#d6b656;startSize=30" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="530" width="1120" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="op1" value="operations.js
applyOperations(state, ops) → { state, changes }
含 insertRow / updateRow / deleteRow 三内部函数" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=11" vertex="1" parent="opLayer">
|
||||
<mxGeometry x="320" y="10" width="480" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="domainLayer" value="Domain Layer — 拆自 manager.js;禁止 import UI" style="swimlane;fontStyle=1;fillColor=#f8cecc;strokeColor=#b85450;startSize=30" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="640" width="1120" height="120" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="dm1" value="store.js
currentTablesState
独占所有权" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=10" vertex="1" parent="domainLayer">
|
||||
<mxGeometry x="20" y="40" width="160" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="dm2" value="persist.js
commitToLastMessage
封装 16 处样板" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=10" vertex="1" parent="domainLayer">
|
||||
<mxGeometry x="200" y="40" width="160" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="dm3" value="mutations.js
addRow / addCol / ...
(16 个 UI 突变)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=10" vertex="1" parent="domainLayer">
|
||||
<mxGeometry x="380" y="40" width="180" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="dm4" value="rendering.js
toCsv * 3
(纯函数)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=10" vertex="1" parent="domainLayer">
|
||||
<mxGeometry x="580" y="40" width="160" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="dm5" value="templates.js
getter/setter" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=10" vertex="1" parent="domainLayer">
|
||||
<mxGeometry x="760" y="40" width="160" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="dm6" value="preset.js
import/export" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=10" vertex="1" parent="domainLayer">
|
||||
<mxGeometry x="940" y="40" width="160" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e_svc_fillSec" style="endArrow=classic;html=1" edge="1" parent="1" source="svc" target="fillSec">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e_svc_fillBatch" style="endArrow=classic;html=1" edge="1" parent="1" source="svc" target="fillBatch">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e_svc_fillReorg" style="endArrow=classic;html=1" edge="1" parent="1" source="svc" target="fillReorg">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e_svc_op" value="apply Op[]" style="endArrow=classic;html=1;fontSize=9" edge="1" parent="1" source="svc" target="op1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e_fillSec_shared" style="endArrow=classic;html=1" edge="1" parent="1" source="fillSec" target="fillShared">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e_fillBatch_shared" style="endArrow=classic;html=1" edge="1" parent="1" source="fillBatch" target="fillShared">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e_fillReorg_shared" style="endArrow=classic;html=1" edge="1" parent="1" source="fillReorg" target="fillShared">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e_shared_fmtIdx" value="dispatch" style="endArrow=classic;html=1;fontSize=9" edge="1" parent="1" source="fillShared" target="fmtIdx">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e_fmtIdx_leg" style="endArrow=open;html=1;dashed=1;endFill=0" edge="1" parent="1" source="fmtIdx" target="fmtLeg">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e_fmtIdx_json" style="endArrow=open;html=1;dashed=1;endFill=0" edge="1" parent="1" source="fmtIdx" target="fmtJson">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e_fmtIdx_tc" style="endArrow=open;html=1;dashed=1;endFill=0" edge="1" parent="1" source="fmtIdx" target="fmtTC">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e_leg_op" value="Op[]" style="endArrow=classic;html=1;dashed=1;fontSize=9" edge="1" parent="1" source="fmtLeg" target="op1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e_json_op" value="Op[]" style="endArrow=classic;html=1;dashed=1;fontSize=9" edge="1" parent="1" source="fmtJson" target="op1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e_tc_op" value="Op[]" style="endArrow=classic;html=1;dashed=1;fontSize=9" edge="1" parent="1" source="fmtTC" target="op1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e_op_dm1" value="state" style="endArrow=classic;html=1;fontSize=9" edge="1" parent="1" source="op1" target="dm1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e_svc_dm2" value="commit" style="endArrow=classic;html=1;dashed=1;fontSize=9" edge="1" parent="1" source="svc" target="dm2">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e_dm3_dm1" value="mutates" style="endArrow=classic;html=1;dashed=1;fontSize=9" edge="1" parent="1" source="dm3" target="dm1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e_dm3_dm2" value="commit" style="endArrow=classic;html=1;dashed=1;fontSize=9" edge="1" parent="1" source="dm3" target="dm2">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e_dm1_ui" value="subscribe / events" style="endArrow=classic;html=1;dashed=1;strokeColor=#9673a6;fontSize=9" edge="1" parent="1" source="dm1" target="ui1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="legend" value="实线箭头 → 直接调用(自顶而下) 虚线箭头 → 数据流向 / 跨层订阅 红色 Domain 层最严格:禁止 import UI" style="text;html=1;align=left;fontSize=11;fillColor=#ffffff;strokeColor=#cccccc;rounded=0" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="780" width="380" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="note1" value="特点: • 五层洋葱模型,依赖单向自顶而下 • 文件少(~14),manager.js 拆出后立即清晰 • Domain 是纯逻辑岛,可独立测试 • 无显式 DTO 层,shape 散在 JSDoc 注释里" style="text;html=1;align=left;fontSize=11;fillColor=#fff8e1;strokeColor=#ffb300;rounded=0" vertex="1" parent="1">
|
||||
<mxGeometry x="450" y="770" width="380" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
</mxfile>
|
||||
178
IAD.drawio
Normal file
178
IAD.drawio
Normal file
@@ -0,0 +1,178 @@
|
||||
<mxfile host="65bd71144e" modified="2026-04-29T00:00:00.000Z" agent="Claude" version="22.0.0" type="device">
|
||||
<diagram id="iad" name="Interface-Action-DTO">
|
||||
<mxGraphModel dx="1422" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1200" pageHeight="900" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0" />
|
||||
<mxCell id="1" parent="0" />
|
||||
<mxCell id="title" value="表格模块 — Interface → Action → DTO 架构" style="text;html=1;align=center;fontSize=20;fontStyle=1" vertex="1" parent="1">
|
||||
<mxGeometry x="280" y="20" width="640" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="subtitle" value="数据形状(DTO) ← 契约+实现(Interface) ← 业务动词(Action) ← 门面(Service)" style="text;html=1;align=center;fontSize=12;fontStyle=2;fontColor=#666666" vertex="1" parent="1">
|
||||
<mxGeometry x="200" y="50" width="800" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="serviceLayer" value="Service Layer — 顶层门面" style="swimlane;fontStyle=1;fillColor=#dae8fc;strokeColor=#6c8ebf;startSize=30;horizontal=1" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="80" width="1120" height="100" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="svc" value="TableSystemService
(Bus 注册 + 事件分发 + Action 编排)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12" vertex="1" parent="serviceLayer">
|
||||
<mxGeometry x="380" y="35" width="360" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="actionLayer" value="Action Layer — 业务动词,纯函数,注入 Interface" style="swimlane;fontStyle=1;fillColor=#d5e8d4;strokeColor=#82b366;startSize=30" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="200" width="1120" height="120" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="a1" value="applyOperations" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366" vertex="1" parent="actionLayer">
|
||||
<mxGeometry x="20" y="50" width="130" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="a2" value="fillSecondary" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366" vertex="1" parent="actionLayer">
|
||||
<mxGeometry x="170" y="50" width="130" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="a3" value="fillBatch" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366" vertex="1" parent="actionLayer">
|
||||
<mxGeometry x="320" y="50" width="130" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="a4" value="reorganize" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366" vertex="1" parent="actionLayer">
|
||||
<mxGeometry x="470" y="50" width="130" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="a5" value="loadTables" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366" vertex="1" parent="actionLayer">
|
||||
<mxGeometry x="620" y="50" width="130" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="a6" value="rollback" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366" vertex="1" parent="actionLayer">
|
||||
<mxGeometry x="770" y="50" width="130" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="a7" value="ui-mutations
(addRow / addCol / ...)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=10" vertex="1" parent="actionLayer">
|
||||
<mxGeometry x="920" y="50" width="160" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="interfaceLayer" value="Interface Layer — 契约(斜体) + 实现(橙色)" style="swimlane;fontStyle=1;fillColor=#fff2cc;strokeColor=#d6b656;startSize=30" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="340" width="1120" height="220" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="i1" value="ITableStore" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontStyle=2" vertex="1" parent="interfaceLayer">
|
||||
<mxGeometry x="20" y="50" width="180" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="i1impl" value="infra/store.js
getState/setState/subscribe" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;fontSize=10" vertex="1" parent="interfaceLayer">
|
||||
<mxGeometry x="20" y="130" width="180" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="i2" value="ITablePersistence" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontStyle=2" vertex="1" parent="interfaceLayer">
|
||||
<mxGeometry x="220" y="50" width="180" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="i2impl" value="infra/persistence.js
saveStateToMessage
loadFromMessage" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;fontSize=10" vertex="1" parent="interfaceLayer">
|
||||
<mxGeometry x="220" y="130" width="180" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="i3" value="IModelCaller" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontStyle=2" vertex="1" parent="interfaceLayer">
|
||||
<mxGeometry x="420" y="50" width="180" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="i3impl" value="infra/modelCaller.js
封装 callAI / callNccsAI" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;fontSize=10" vertex="1" parent="interfaceLayer">
|
||||
<mxGeometry x="420" y="130" width="180" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="i4" value="IFormatter
buildPrompt(state) / parseResponse(raw) → Op[]" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontStyle=2;fontSize=10" vertex="1" parent="interfaceLayer">
|
||||
<mxGeometry x="620" y="50" width="280" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="i4a" value="legacy.js
<Amily2Edit>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;fontSize=10" vertex="1" parent="interfaceLayer">
|
||||
<mxGeometry x="620" y="130" width="85" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="i4b" value="json.js
{operations}" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;fontSize=10" vertex="1" parent="interfaceLayer">
|
||||
<mxGeometry x="715" y="130" width="85" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="i4c" value="toolcall.js
Bus tools" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;fontSize=10" vertex="1" parent="interfaceLayer">
|
||||
<mxGeometry x="810" y="130" width="90" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="i5" value="IEventBus" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontStyle=2" vertex="1" parent="interfaceLayer">
|
||||
<mxGeometry x="920" y="50" width="180" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="i5impl" value="infra/eventBus.js
UI 通过订阅刷新" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;fontSize=10" vertex="1" parent="interfaceLayer">
|
||||
<mxGeometry x="920" y="130" width="180" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="dtoLayer" value="DTO Layer — 纯数据形状(@typedef + 工厂函数)" style="swimlane;fontStyle=1;fillColor=#f5f5f5;strokeColor=#666666;startSize=30" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="580" width="1120" height="100" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="d1" value="TableState" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666" vertex="1" parent="dtoLayer">
|
||||
<mxGeometry x="20" y="40" width="130" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="d2" value="Table" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666" vertex="1" parent="dtoLayer">
|
||||
<mxGeometry x="170" y="40" width="130" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="d3" value="Operation" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontStyle=1" vertex="1" parent="dtoLayer">
|
||||
<mxGeometry x="320" y="40" width="130" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="d4" value="Change" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666" vertex="1" parent="dtoLayer">
|
||||
<mxGeometry x="470" y="40" width="130" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="d5" value="FillRequest" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666" vertex="1" parent="dtoLayer">
|
||||
<mxGeometry x="620" y="40" width="130" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="d6" value="FillResult" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666" vertex="1" parent="dtoLayer">
|
||||
<mxGeometry x="770" y="40" width="130" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="d7" value="PromptContext" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666" vertex="1" parent="dtoLayer">
|
||||
<mxGeometry x="920" y="40" width="130" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e_svc_a1" style="endArrow=classic;html=1" edge="1" parent="1" source="svc" target="a1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e_svc_a2" style="endArrow=classic;html=1" edge="1" parent="1" source="svc" target="a2">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e_svc_a4" style="endArrow=classic;html=1" edge="1" parent="1" source="svc" target="a4">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e_svc_a6" style="endArrow=classic;html=1" edge="1" parent="1" source="svc" target="a6">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e_a2_i2" value="uses" style="endArrow=classic;html=1;dashed=1;strokeColor=#82b366;fontSize=9" edge="1" parent="1" source="a2" target="i2">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e_a2_i3" value="uses" style="endArrow=classic;html=1;dashed=1;strokeColor=#82b366;fontSize=9" edge="1" parent="1" source="a2" target="i3">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e_a2_i4" value="uses" style="endArrow=classic;html=1;dashed=1;strokeColor=#82b366;fontSize=9" edge="1" parent="1" source="a2" target="i4">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e_a1_i1" value="uses" style="endArrow=classic;html=1;dashed=1;strokeColor=#82b366;fontSize=9" edge="1" parent="1" source="a1" target="i1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e_a7_i5" value="emits" style="endArrow=classic;html=1;dashed=1;strokeColor=#82b366;fontSize=9" edge="1" parent="1" source="a7" target="i5">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e_i1_impl" value="impl" style="endArrow=open;html=1;dashed=1;endFill=0;fontSize=9" edge="1" parent="1" source="i1" target="i1impl">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e_i2_impl" value="impl" style="endArrow=open;html=1;dashed=1;endFill=0;fontSize=9" edge="1" parent="1" source="i2" target="i2impl">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e_i3_impl" value="impl" style="endArrow=open;html=1;dashed=1;endFill=0;fontSize=9" edge="1" parent="1" source="i3" target="i3impl">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e_i4_a" style="endArrow=open;html=1;dashed=1;endFill=0" edge="1" parent="1" source="i4" target="i4a">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e_i4_b" style="endArrow=open;html=1;dashed=1;endFill=0" edge="1" parent="1" source="i4" target="i4b">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e_i4_c" style="endArrow=open;html=1;dashed=1;endFill=0" edge="1" parent="1" source="i4" target="i4c">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e_i5_impl" value="impl" style="endArrow=open;html=1;dashed=1;endFill=0;fontSize=9" edge="1" parent="1" source="i5" target="i5impl">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e_a1_d3" value="ops" style="endArrow=classic;html=1;dashed=1;strokeColor=#666666;fontSize=9" edge="1" parent="1" source="a1" target="d3">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e_a1_d4" value="changes" style="endArrow=classic;html=1;dashed=1;strokeColor=#666666;fontSize=9" edge="1" parent="1" source="a1" target="d4">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e_a2_d5" value="req" style="endArrow=classic;html=1;dashed=1;strokeColor=#666666;fontSize=9" edge="1" parent="1" source="a2" target="d5">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e_a2_d6" value="result" style="endArrow=classic;html=1;dashed=1;strokeColor=#666666;fontSize=9" edge="1" parent="1" source="a2" target="d6">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="e_i4_d3" value="produces" style="endArrow=classic;html=1;dashed=1;strokeColor=#666666;fontSize=9" edge="1" parent="1" source="i4" target="d3">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="legend" value="实线箭头 → 直接调用 虚线箭头 → 依赖 / 数据流向 / 接口实现关系 斜体 = 抽象契约(@typedef),橙色 = 具体实现" style="text;html=1;align=left;fontSize=11;fillColor=#ffffff;strokeColor=#cccccc;rounded=0" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="710" width="380" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="note1" value="特点: • DTO 层独立,三模式 formatter 输出统一吐 Op[] • Action 是纯函数,注入 Interface 后可单元测试 • 文件多(~25),目录树是主导航 • 适合未来 TS 化" style="text;html=1;align=left;fontSize=11;fillColor=#fff8e1;strokeColor=#ffb300;rounded=0" vertex="1" parent="1">
|
||||
<mxGeometry x="450" y="700" width="350" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
</mxfile>
|
||||
@@ -1,4 +1,7 @@
|
||||
import Options from './Options.js';
|
||||
import { detectVendorSync, getRegistry } from '../../../utils/api-vendor.js';
|
||||
|
||||
getRegistry().catch(() => {});
|
||||
|
||||
/**
|
||||
* RequestBody (DTO)
|
||||
@@ -24,7 +27,10 @@ export class RequestBody {
|
||||
*/
|
||||
toPayload() {
|
||||
const { apiUrl, apiKey, model, maxTokens, temperature, params, fakeStream } = this.options;
|
||||
const isGoogle = apiUrl && apiUrl.includes('googleapis.com');
|
||||
const detectedVendor = detectVendorSync(apiUrl);
|
||||
const isGoogle = detectedVendor
|
||||
? detectedVendor === 'google'
|
||||
: Boolean(apiUrl && apiUrl.includes('googleapis.com'));
|
||||
|
||||
// 基础字段 (Base Fields)
|
||||
const payload = {
|
||||
@@ -71,4 +77,4 @@ export class RequestBody {
|
||||
}
|
||||
}
|
||||
|
||||
export default RequestBody;
|
||||
export default RequestBody;
|
||||
|
||||
@@ -2,8 +2,7 @@ import { Module, ModuleBuilder } from './Module.js';
|
||||
import { extension_settings, getContext } from '../../../../../extensions.js';
|
||||
import { saveSettingsDebounced, saveChat, reloadCurrentChat, eventSource, event_types } from '../../../../../../script.js';
|
||||
import { registerSlashCommand } from '../../../../../slash-commands.js';
|
||||
|
||||
const extensionName = 'ST-Amily2-Chat-Optimisation-Dev'; // Use main extension name for settings
|
||||
import { extensionName } from '../../utils/settings.js';
|
||||
const sfigenSettingsKey = 'sfigen_settings';
|
||||
|
||||
const defaultSettings = {
|
||||
|
||||
356
TODOList.md
Normal file
356
TODOList.md
Normal file
@@ -0,0 +1,356 @@
|
||||
# TODOList — 待办任务总览
|
||||
|
||||
> 用于派工与进度跟踪。任务卡格式统一,可拆分给不同执行者(人 / Claude / GPT / 其他模型)。
|
||||
>
|
||||
> 关联文档:
|
||||
> - [51TODO.md](51TODO.md) — 跨方向重构计划(Bus tool-call 升级 / 跨议题决策点)
|
||||
> - [TableTODO.md](TableTODO.md) — 表格模块 IAD 深度重构计划(Phase 0/B/C)
|
||||
> - [TODO.md](TODO.md) — 旧版本变更日志(保留作为发布记录)
|
||||
>
|
||||
> 最后更新:2026-05-08,对应 v2.2.0 已发布。
|
||||
|
||||
---
|
||||
|
||||
## 一、最近落地(v2.1.1 → v2.2.0)
|
||||
|
||||
> 上下文摘要,让接手者了解当前状态。代码细节看对应 commit。
|
||||
|
||||
| commit | 内容 | 涉及范围 |
|
||||
|--------|------|--------|
|
||||
| `d283ff4` | 表格模块 IAD 解耦 + API 自定义参数 + 厂商预设连接 | `core/table-system/*` 新增 dto/infra/actions;`assets/api-vendor-params.json`;UI |
|
||||
| `f022002` | DeepSeek registry 补 thinking 模式参数 | `assets/api-vendor-params.json` |
|
||||
| `671c1b2` | profile 优先级修正:profile 分配后即权威,旧字段不再覆盖 | `core/api.js` 6 处 `getApiSettings` |
|
||||
| `68217ff` | legacy 自动迁移 + 清除按钮 + tableFilling slot + silent fallback 移除 | `ApiProfileManager.js` / `historiographer.js` / 表格 3 filler |
|
||||
| `b40f575` | bump 2.2.0 + tableFilling 默认 link main | `manifest.json` / `ApiProfileManager.js` |
|
||||
|
||||
**核心架构现状**(接手必读):
|
||||
|
||||
- **状态权威**:`utils/config/ApiProfileManager.js` 是 API 配置单一指挥所;profile 分配后即权威,旧字段(`s.ngmsTemperature` 等)不再覆盖 profile
|
||||
- **表格模块**:核心在 [core/table-system/](core/table-system/) ,已按 IAD 拆分(dto/infra/actions/rendering.js/templates.js/preset.js),manager.js 退化为兼容层(仍保留 16 个 UI mutation + loadTables + updateTableFromText)
|
||||
- **API 厂商识别**:[utils/api-vendor.js](utils/api-vendor.js) 提供 detectVendor / listVendorParams;registry 在 [assets/api-vendor-params.json](assets/api-vendor-params.json)
|
||||
- **VS Code 类型校验**:[jsconfig.json](jsconfig.json) 已开启 checkJs,[types/sillytavern.d.ts](types/sillytavern.d.ts) 提供 SillyTavern 全局模块声明
|
||||
|
||||
---
|
||||
|
||||
## 二、待办任务
|
||||
|
||||
### 任务卡格式说明
|
||||
|
||||
每个任务包含:
|
||||
- **类型**:bug / feature / refactor / cleanup / docs
|
||||
- **难度**:🟢 简单(< 1h)/ 🟡 中等(1-3h)/ 🔴 高耦合(> 3h 或需架构判断)
|
||||
- **建议执行者**:`GPT` / `Claude` / `Human` / `任意`
|
||||
- **文件**:明确路径 + 行号锚点(若适用)
|
||||
- **修改要点**:bullet 列表
|
||||
- **验收**:可验证的预期行为
|
||||
- **依赖**:前置任务的 ID(若有)
|
||||
|
||||
---
|
||||
|
||||
### 🟢 GPT-friendly 简单任务
|
||||
|
||||
#### T-001: 清理已确认的死代码
|
||||
|
||||
- **类型**:cleanup
|
||||
- **难度**:🟢 简单
|
||||
- **建议执行者**:GPT
|
||||
- **依赖**:无
|
||||
|
||||
**待清理项**:
|
||||
|
||||
1. **[core/fractal-memory.js](core/fractal-memory.js)** —— 整个文件死代码,`initializeFractalMemory` 在文件外完全没人调用。建议:直接删除整个文件。
|
||||
2. **[ui/historiography-bindings.js:494-513](ui/historiography-bindings.js#L494)** —— 绑定 `#amily2_ngms_temperature` 和 `#amily2_ngms_max_tokens` 这两个 HTML 中已不存在的元素。`getElementById` 永远返回 null,整段代码空跑。建议:直接删掉这段。
|
||||
3. **[ui/plot-opt-bindings.js:664-665](ui/plot-opt-bindings.js#L664)** —— 同样引用不存在的 `#amily2_opt_max_tokens` / `#amily2_opt_temperature`。建议:删掉。
|
||||
4. **[ui/plot-opt-bindings.js:698-699](ui/plot-opt-bindings.js#L698)** —— `opt_bindSlider` 调用同样的不存在 ID,删除。
|
||||
|
||||
**修改要点**:
|
||||
- 删除前用 grep 确认每个 ID 在所有 .html 文件里都不存在
|
||||
- 删完后用 grep 检查没有其他文件 import 被删的函数
|
||||
- 提交前肉眼跑一次表格填表 / 剧情优化 / NGMS 总结,确认 UI 无回归
|
||||
|
||||
**验收**:
|
||||
- [ ] 4 处死代码块全部删除
|
||||
- [ ] 启动控制台无 JS 错误
|
||||
- [ ] 表格 / 剧情优化 / 总结功能无回归
|
||||
|
||||
---
|
||||
|
||||
#### T-002: cwb / autoCharCard 加入 legacy 自动迁移
|
||||
|
||||
- **类型**:feature
|
||||
- **难度**:🟢 简单
|
||||
- **建议执行者**:GPT
|
||||
- **依赖**:无
|
||||
|
||||
**背景**:[utils/config/ApiProfileManager.js](utils/config/ApiProfileManager.js) 的 `LEGACY_PROFILE_MIGRATION_MAP` 目前覆盖 main / plotOpt / plotOptConc / ngms / nccs / sybd 6 个 slot。cwb 和 autoCharCard 的 legacy 字段结构略不同(cwb 用 `cwb_apiUrl` / `cwb_apiKey` / `cwb_model` ;autoCharCard 用 `acc_*` 前缀),所以暂时没纳入。
|
||||
|
||||
**修改要点**:
|
||||
|
||||
1. 找出 cwb / autoCharCard 的 legacy 字段名(grep `cwb_apiUrl` / `acc_apiUrl` 之类)
|
||||
2. 在 `LEGACY_PROFILE_MIGRATION_MAP` 加两条:
|
||||
```js
|
||||
{
|
||||
slot: 'cwb',
|
||||
urlKey: 'cwb_apiUrl',
|
||||
modelKey: 'cwb_model',
|
||||
keyName: 'cwb_apiKey',
|
||||
maxTokensKey: 'cwb_max_tokens',
|
||||
temperatureKey: 'cwb_temperature',
|
||||
name: 'CWB 旧配置',
|
||||
},
|
||||
{
|
||||
slot: 'autoCharCard',
|
||||
urlKey: '???', // 需 grep 确认实际 key
|
||||
...
|
||||
}
|
||||
```
|
||||
3. 同时在 `clearLegacyConfig` 的 `ALL_LEGACY_FIELDS` 和 `LEGACY_KEY_NAMES` 加对应条目
|
||||
|
||||
**验收**:
|
||||
- [ ] 两个 slot 在迁移自调用 IIFE 跑过后能正确创建 profile + setKey + setAssignment
|
||||
- [ ] 清理按钮能识别并清除这俩模块的旧字段
|
||||
|
||||
---
|
||||
|
||||
#### T-003: 表格 NCCS 支路透传 customParams
|
||||
|
||||
- **类型**:feature
|
||||
- **难度**:🟢 简单
|
||||
- **建议执行者**:GPT
|
||||
- **依赖**:无
|
||||
|
||||
**背景**:v2.2.0 给 `core/api.js` 的 callOpenAITest / callOpenAICompatible / callSillyTavernBackend 都接入了 `options.customParams` spread。但 [core/api/NccsApi.js](core/api/NccsApi.js) 的 `callNccsOpenAITest` 等独立路径**没有**接入,导致用户在 NCCS profile 配置的 customParams 不生效。
|
||||
|
||||
**修改要点**:
|
||||
|
||||
1. 找 [NccsApi.js](core/api/NccsApi.js) 里发请求的函数(`callNccsOpenAITest` / `callNccsSillyTavernPreset`),定位到 `JSON.stringify({ ... })` 处
|
||||
2. 在 body 构建时按"customParams 在前,核心字段在后覆盖"的顺序 spread:
|
||||
```js
|
||||
body: JSON.stringify({
|
||||
...(options.customParams || {}),
|
||||
// 核心字段
|
||||
chat_completion_source: 'openai',
|
||||
model: options.model,
|
||||
messages,
|
||||
// ...
|
||||
})
|
||||
```
|
||||
3. 同时确保 `getNccsApiSettings` 把 `profile.customParams` 透出(参考 [core/api.js:447-462](core/api.js#L447) 模式)
|
||||
4. 同步给 NgmsApi / JqyhApi / SybdApi 做相同处理
|
||||
|
||||
**验收**:
|
||||
- [ ] 在 NCCS profile 加 `{"top_p": 0.5}` 后,DevTools Network 看请求 body 包含 top_p:0.5
|
||||
- [ ] NGMS / JQYH / SYBD 同样验证
|
||||
|
||||
---
|
||||
|
||||
#### T-004: hint panel 点击参数名插入到 textarea
|
||||
|
||||
- **类型**:feature
|
||||
- **难度**:🟢 简单
|
||||
- **建议执行者**:GPT
|
||||
- **依赖**:无
|
||||
|
||||
**背景**:[ui/api-config-bindings.js](ui/api-config-bindings.js) 的 `_updateCustomParamsHint` 现在只显示纯文本"已知参数:top_p、frequency_penalty、..."。没有交互。
|
||||
|
||||
**修改要点**:
|
||||
|
||||
1. 把 hint 区改成参数名按钮列表,每个按钮 click 触发"如果当前 textarea JSON 已有这个 key 则不动,没有就 append 进去"
|
||||
2. 实现 `_insertParamToCustomParams(paramName, defaultValue)`:解析 textarea JSON → 添加 key(用合理的占位值,例如 number 类型用 0、string 类型用 ""、object 类型用 {})→ JSON.stringify 回写
|
||||
3. 处理 textarea 当前为空 / 当前是非法 JSON 的情况(非法 JSON 时按钮 disabled + 提示用户先修复)
|
||||
|
||||
**验收**:
|
||||
- [ ] 切换 vendor 后参数名按钮列表更新
|
||||
- [ ] 点击按钮把对应 key 添加到 textarea
|
||||
- [ ] 已存在的 key 不重复添加
|
||||
|
||||
---
|
||||
|
||||
### 🟡 中等任务
|
||||
|
||||
#### T-005: 15 处散乱 vendor URL 检查迁到 detectVendor
|
||||
|
||||
- **类型**:refactor
|
||||
- **难度**:🟡 中等
|
||||
- **建议执行者**:GPT 或 Claude
|
||||
- **依赖**:无
|
||||
|
||||
**背景**:之前的 51TODO Phase B 收尾任务。代码里 15+ 处 `apiUrl.includes('googleapis.com')` 散乱判断厂商,应该统一调 [utils/api-vendor.js#detectVendor](utils/api-vendor.js)。
|
||||
|
||||
**待迁移文件**(grep `googleapis.com|anthropic.com|openai.com` 找):
|
||||
|
||||
- `ui/api-config-bindings.js`
|
||||
- `ui/plot-opt-bindings.js`
|
||||
- `core/rag-api.js`
|
||||
- `ui/profile-sync.js`
|
||||
- `core/api.js`
|
||||
- `CharacterWorldBook/src/cwb_apiService.js`
|
||||
- `ui/bindings.js`
|
||||
- `ui/table/nccs-bindings.js`
|
||||
- `core/api/SybdApi.js`
|
||||
- `core/api/Ngms_api.js`
|
||||
- `core/api/JqyhApi.js`
|
||||
- `core/api/NccsApi.js`
|
||||
- `core/api/ConcurrentApi.js`
|
||||
|
||||
**修改要点**:
|
||||
|
||||
1. 每处 `if (apiUrl.includes('googleapis.com'))` 改为 `if ((await detectVendor(apiUrl)) === 'google')`
|
||||
2. 注意有的位置在同步上下文(事件回调),用 `detectVendorSync` 但要先 `await getRegistry()` 预加载
|
||||
3. 不要为了重构改变行为:原来只判断 google 就只判断 google,原来判断多个 vendor 就保留多个
|
||||
|
||||
**验收**:
|
||||
- [ ] 所有散乱 URL 检查替换完
|
||||
- [ ] 行为完全等价(用 grep 自检 includes 已全替换)
|
||||
- [ ] 跑一遍主功能(主聊天 / 剧情优化 / NGMS 总结 / 表格填表)确认无回归
|
||||
|
||||
---
|
||||
|
||||
#### T-006: jqyh/sybd/cwb 在 profile 已分配时把 slider 改成 informational
|
||||
|
||||
- **类型**:feature / UX
|
||||
- **难度**:🟡 中等
|
||||
- **建议执行者**:GPT 或 Claude
|
||||
- **依赖**:无
|
||||
|
||||
**背景**:v2.2.0 之后,profile 一旦分配就权威,jqyh/sybd/cwb 这些有 slider 的模块在 profile 分配后 slider 是无效的(用户改 slider 不影响请求)。这是用户陷阱。
|
||||
|
||||
**修改要点**:
|
||||
|
||||
每个有 slider 的模块面板([plot-opt-bindings.js](ui/plot-opt-bindings.js) / [historiography-bindings.js](ui/historiography-bindings.js) / [glossary 相关 bindings](ui/) / [cwb_settingsManager.js](CharacterWorldBook/src/cwb_settingsManager.js)):
|
||||
|
||||
1. 启动时 / profile 分配变化时检查对应 slot 是否分配了 profile
|
||||
2. 若已分配:
|
||||
- slider disable
|
||||
- slider 旁加小字提示:"当前由 profile 「{profile.name}」 控制,请在 API 连接配置面板修改 profile"
|
||||
3. 若未分配:保持原样(slider 可用,写入 legacy 字段)
|
||||
4. 监听 profile 分配变化事件(可通过 ApiProfileManager 加 subscribe,或者轮询)
|
||||
|
||||
**验收**:
|
||||
- [ ] 给 plotOpt 分配 profile 后,剧情优化面板的温度/maxTokens slider 变灰 + 提示
|
||||
- [ ] 取消分配后 slider 重新可用
|
||||
- [ ] 其他模块同样行为
|
||||
|
||||
---
|
||||
|
||||
#### T-007: 表格 Phase 0.4 — 抽出 mutations.js
|
||||
|
||||
- **类型**:refactor
|
||||
- **难度**:🟡 中等
|
||||
- **建议执行者**:Claude(涉及 IAD 一致性判断)
|
||||
- **依赖**:无
|
||||
|
||||
**背景**:[TableTODO.md#四-phase-0](TableTODO.md) 计划的 Phase 0.4。manager.js 还有 16 个 UI 突变函数(addRow / deleteColumn / renameTable 等),应抽到 `core/table-system/actions/ui-mutations.js`。
|
||||
|
||||
**修改要点**:
|
||||
|
||||
1. 在 `core/table-system/actions/` 创建 `ui-mutations.js`
|
||||
2. 把 manager.js 里这 16 个函数搬过去:deleteColumn / moveRow / insertRow / addRow / addColumn / updateHeader / deleteRow / restoreRow / commitPendingDeletions / insertColumn / moveColumn / deleteTable / addTable / renameTable / moveTable / updateTableRules / updateRow / clearAllTables / updateColumnWidth
|
||||
3. manager.js 改为 re-export 这些函数(保持外部调用路径不变)
|
||||
4. 各函数签名/行为保持完全一致
|
||||
|
||||
**验收**:
|
||||
- [ ] manager.js 行数显著减少
|
||||
- [ ] 所有 UI 突变操作在表格面板里行为一致(手动测每个操作)
|
||||
- [ ] 没有任何 import 失败
|
||||
|
||||
---
|
||||
|
||||
### 🔴 高耦合 / 架构任务
|
||||
|
||||
#### T-008: Bus tool-call 能力升级
|
||||
|
||||
- **类型**:feature / 架构
|
||||
- **难度**:🔴 高
|
||||
- **建议执行者**:Claude(涉及 Bus 架构判断)
|
||||
- **依赖**:无(独立于表格重构)
|
||||
|
||||
**详见**:[51TODO.md#二-phase-a-bus-tool-call-升级](51TODO.md)
|
||||
|
||||
**核心交付**:
|
||||
- `SL/bus/tool/ToolRegistry.js` 私有工具注册表
|
||||
- `register(pluginName)` 返回的 context 加 `tool` 能力
|
||||
- `Options.js` / `RequestBody.js` 支持 `tools` / `toolChoice` 字段
|
||||
- `context.model.callWithTools(messages, options, { maxSteps, onToolError })` agent loop
|
||||
|
||||
**预估**:1.5 天
|
||||
|
||||
---
|
||||
|
||||
#### T-009: 表格 Phase B — JSON formatter
|
||||
|
||||
- **类型**:feature
|
||||
- **难度**:🟡 中等
|
||||
- **建议执行者**:GPT 或 Claude
|
||||
- **依赖**:无(不依赖 Bus 升级)
|
||||
|
||||
**详见**:[TableTODO.md#五-phase-b-json-formatter](TableTODO.md)
|
||||
|
||||
**核心交付**:
|
||||
- `core/table-system/formatters/json.js`:教 LLM 输出 `{"operations":[...]}`,解析为 Op[]
|
||||
- 设置项 `table_filling_format: 'legacy'|'json'|'toolcall'`,默认 `legacy`
|
||||
- UI 加 dropdown 切换
|
||||
- fillerShared 调用统一 formatter dispatcher
|
||||
|
||||
**预估**:0.5 天
|
||||
|
||||
---
|
||||
|
||||
#### T-010: 表格 Phase C — ToolCall formatter
|
||||
|
||||
- **类型**:feature
|
||||
- **难度**:🟡 中等
|
||||
- **建议执行者**:Claude
|
||||
- **依赖**:T-008 完成 + T-009 完成
|
||||
|
||||
**详见**:[TableTODO.md#六-phase-c-toolcall-formatter](TableTODO.md)
|
||||
|
||||
---
|
||||
|
||||
#### T-011: 表格 Phase 0.7-0.9 收尾
|
||||
|
||||
- **类型**:refactor
|
||||
- **难度**:🔴 高(filler 三方差异需小心对齐 / 解循环依赖 / Service 重写)
|
||||
- **建议执行者**:Claude
|
||||
- **依赖**:T-007(Phase 0.4 mutations 完成后做)
|
||||
|
||||
**详见**:[TableTODO.md#四-phase-0](TableTODO.md) 0.7-0.9
|
||||
|
||||
- 0.7: `core/table-system/filler/shared.js` —— 三个 filler 重复代码消除
|
||||
- 0.8: 解 manager.js ↔ secondary-filler.js 循环依赖
|
||||
- 0.9: TableSystemService 真正变成门面
|
||||
|
||||
**预估**:1 天
|
||||
|
||||
---
|
||||
|
||||
## 三、派工建议
|
||||
|
||||
### 适合现在直接派给 GPT(独立、无架构判断)
|
||||
|
||||
- ✅ T-001 死代码清理
|
||||
- ✅ T-002 cwb/autoCharCard 加入迁移
|
||||
- ✅ T-003 NCCS 透传 customParams
|
||||
- ✅ T-004 hint panel 点击插入
|
||||
|
||||
### GPT 或 Claude 都可以
|
||||
|
||||
- T-005 vendor 检查迁移(量大但机械)
|
||||
- T-006 slider informational 状态
|
||||
- T-009 JSON formatter
|
||||
|
||||
### 建议留给 Claude 或人
|
||||
|
||||
- T-007 mutations.js 抽出(涉及 IAD 一致性)
|
||||
- T-008 Bus tool-call 升级(架构核心)
|
||||
- T-010 ToolCall formatter(依赖前置)
|
||||
- T-011 表格 Phase 0 收尾(filler 重复代码 dedup 风险高)
|
||||
|
||||
---
|
||||
|
||||
## 四、未列入但可能的小项
|
||||
|
||||
- 自动迁移完成后给所有 chat 类型 slot 加默认 link 选项(不只 tableFilling)
|
||||
- profile 分配 UI 加"复用现有 profile"快捷按钮(避免用户为每个 slot 重复创建相同配置)
|
||||
- 51TODO.md 第三节决策点中"是否合并发版"等问题做最终决定记录
|
||||
- TODO.md(旧版本变更日志)的 v2.2.0 版本条目补全
|
||||
309
TableTODO.md
Normal file
309
TableTODO.md
Normal file
@@ -0,0 +1,309 @@
|
||||
# TableTODO — 表格模块重构清单
|
||||
|
||||
> 创建于 2026-04-28。劳动节假后启动。
|
||||
> 主线:解耦 → 三模式填表(legacy / json / toolcall)。
|
||||
> 跨方向依赖(Bus tool-call 升级)见 [51TODO.md](51TODO.md) Phase A。
|
||||
|
||||
---
|
||||
|
||||
## 一、动机
|
||||
|
||||
现行表格填表让 LLM 输出 `<Amily2Edit>insertRow(0, {0:"x",1:"y"})</Amily2Edit>` 这种"四不像"自定义文本格式,由 [executor.js#parseFunctionCall](core/table-system/executor.js#L98) 自实现的 brace-depth + quote-state 状态机解析。高温下:
|
||||
- 引号转义错乱、嵌套对象内逗号未转义 → 参数切错位
|
||||
- `data` 对象键写成无引号字段名 → 多层 JSON.parse fallback 仍可能失败
|
||||
- 一处 LLM 偷懒不输出 `<Amily2Edit>` → 整批回滚重试
|
||||
|
||||
**目标**:把"格式契约"从 prompt 字符串约定改成 schema 约定,让 LLM 直接吐结构化数据,砍掉自实现解析器。同时保留 legacy 文本模式确保老用户行为不变。
|
||||
|
||||
| 模式 | 输出形态 | 解析复杂度 | 兼容性 |
|
||||
|------|---------|-----------|--------|
|
||||
| `legacy`(默认) | `<Amily2Edit>insertRow(...)</Amily2Edit>` 文本块 | 高(现行解析器) | 100% 老行为 |
|
||||
| `json` | `{ "operations": [{op, tableIndex, ...}] }` 单 JSON 块 | 中(JSON.parse + schema 校验) | 新模式 |
|
||||
| `toolcall` | OpenAI tool_calls 多步迭代 | 低(结构化原生) | 依赖 Bus 升级(51TODO Phase A) |
|
||||
|
||||
---
|
||||
|
||||
## 二、当前耦合分析(2026-04-28 摸底)
|
||||
|
||||
### 2.1 manager.js 是上帝模块
|
||||
- 1745 行,51 个 export
|
||||
- 七层职责混杂:状态容器 / 持久化 / UI 突变操作 / LLM 指令执行 / Markdown 提示词渲染 / 模板 getter setter / 预设导入导出 / 回滚 / 跨模块事件分发
|
||||
|
||||
### 2.2 状态所有权
|
||||
- module-level mutable:`currentTablesState`、`highlightedCells`、`updatedTables`([manager.js:16-20](core/table-system/manager.js#L16-L20))
|
||||
- 20+ export 函数直接 mutate,没有封装边界
|
||||
|
||||
### 2.3 持久化模式被复制 16 次
|
||||
每个 UI 突变 export 末尾都有同款样板:
|
||||
```js
|
||||
const context = getContext();
|
||||
if (context.chat && context.chat.length > 0) {
|
||||
const lastMessage = context.chat[context.chat.length - 1];
|
||||
if (saveStateToMessage(currentTablesState, lastMessage)) {
|
||||
saveChat();
|
||||
return;
|
||||
}
|
||||
}
|
||||
saveChatDebounced();
|
||||
```
|
||||
受影响:addRow / addColumn / updateHeader / deleteRow / restoreRow / insertColumn / moveColumn / deleteTable / addTable / renameTable / moveTable / updateTableRules / updateRow / clearAllTables / updateColumnWidth / insertRow
|
||||
|
||||
### 2.4 三个 filler 大量重复
|
||||
- [secondary-filler.js#getWorldBookContext](core/table-system/secondary-filler.js#L16) ≈ [batch-filler.js#getWorldBookContext](core/table-system/batch-filler.js#L25)(含微妙差异:character book 来源处理不同)
|
||||
- mixed-order 拼装循环 + `callNccsAI vs callAI` 分支三处 copy
|
||||
- 三者都调 `updateTableFromText(rawContent)` 收尾
|
||||
|
||||
### 2.5 业务层硬依赖 UI 层
|
||||
[manager.js:9-10](core/table-system/manager.js#L9-L10):
|
||||
```js
|
||||
import { renderTables } from '../../ui/table-bindings.js';
|
||||
import { updateOrInsertTableInChat } from '../../ui/message-table-renderer.js';
|
||||
```
|
||||
在 loadMemoryState / deleteRow / restoreRow / rollbackState / updateTableFromText 里直调。逻辑和渲染焊死。
|
||||
|
||||
### 2.6 提示词构建散在 4 个文件
|
||||
- 模板常量:[settings.js](core/table-system/settings.js)
|
||||
- getter:[manager.js:1244-1259](core/table-system/manager.js#L1244)
|
||||
- 占位符替换 `flowTemplate.replace('{{{Amily2TableData}}}', ...)`:secondary-filler / batch-filler / reorganizer / injector 各自一份
|
||||
|
||||
### 2.7 格式锁死(重构核心痛点)
|
||||
`<Amily2Edit>` 文本格式硬编码在 4 处:
|
||||
- [executor.js#L98-202](core/table-system/executor.js#L98) 解析器
|
||||
- [settings.js#L11-16](core/table-system/settings.js#L11) 模板示例
|
||||
- [manager.js#updateTableFromText](core/table-system/manager.js#L1266) 入口
|
||||
- [secondary-filler.js#L292](core/table-system/secondary-filler.js#L292) 失败检测 `if (!rawContent.includes('<Amily2Edit>'))`
|
||||
|
||||
### 2.8 循环依赖
|
||||
- [manager.js:5](core/table-system/manager.js#L5) → `secondary-filler.js`
|
||||
- [secondary-filler.js:7](core/table-system/secondary-filler.js#L7) → `manager.js`
|
||||
- 引发点:`manager.rollbackAndRefill` 需要调 `fillWithSecondaryApi`
|
||||
|
||||
### 2.9 TableSystemService 是半成品门面
|
||||
[TableSystemService.js](core/table-system/TableSystemService.js) 把 manager / executor / secondary-filler / ui 全 import 后再 expose,没解耦任何东西,只是 Bus 注册帖。
|
||||
|
||||
---
|
||||
|
||||
## 三、目标分层
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ UI Layer (existing, untouched) │
|
||||
│ ui/table-bindings.js · ui/message-table-renderer.js │
|
||||
└────────────────────▲────────────────────────────────────────┘
|
||||
│ 仅订阅事件,不被业务层 import
|
||||
┌────────────────────┴────────────────────────────────────────┐
|
||||
│ Service Layer (TableSystemService 真正承担门面) │
|
||||
│ ├─ 编排:fill/reorganize/rollback │
|
||||
│ ├─ Bus 注册 │
|
||||
│ └─ 通过事件通知 UI(而非 import) │
|
||||
└────────────────────▲────────────────────────────────────────┘
|
||||
│
|
||||
┌────────────────────┴────────────────────────────────────────┐
|
||||
│ Pipeline Layer (新增,三模式落地点) │
|
||||
│ ├─ formatters/legacy.js : <Amily2Edit> prompt + parse │
|
||||
│ ├─ formatters/json.js : JSON prompt + parse │
|
||||
│ ├─ formatters/toolcall.js : Bus tool_calls (依赖 Bus 升级) │
|
||||
│ ├─ formatters/index.js : 按 settings 分发 │
|
||||
│ └─ filler/ │
|
||||
│ ├─ shared.js : worldbook + history + 拼装 │
|
||||
│ ├─ secondary.js : 触发条件 + 用 shared │
|
||||
│ └─ batch.js : 批次循环 + 用 shared │
|
||||
└────────────────────▲────────────────────────────────────────┘
|
||||
│ 输出统一 Operation[]
|
||||
┌────────────────────┴────────────────────────────────────────┐
|
||||
│ Operation Layer (从 executor.js 抽出) │
|
||||
│ operations.js │
|
||||
│ ├─ applyOperations(state, ops) → { state, changes } │
|
||||
│ └─ schema: Op = { op, tableIndex, ...args } │
|
||||
└────────────────────▲────────────────────────────────────────┘
|
||||
│
|
||||
┌────────────────────┴────────────────────────────────────────┐
|
||||
│ Domain Layer (从 manager.js 拆出) │
|
||||
│ ├─ store.js : currentTablesState 单一所有权 + 订阅 │
|
||||
│ ├─ persist.js : saveStateToMessage / load / 持久化封装 │
|
||||
│ ├─ mutations.js : addRow/addColumn/.../updateRow 突变 API │
|
||||
│ ├─ rendering.js : convertTablesToCsvString * 3 (纯函数) │
|
||||
│ ├─ templates.js : prompt 模板 getter setter │
|
||||
│ └─ preset.js : 导入导出 / 全局预设 │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**关键原则**
|
||||
- Domain Layer 是纯逻辑,**禁止 import UI**
|
||||
- Service Layer 与 UI 通过事件解耦(已有 events-schema.js 基础设施)
|
||||
- Pipeline Layer 的 formatter 是可插拔的,新增格式 = 加文件,不动旧文件
|
||||
- `currentTablesState` 由 store.js 独占,对外只有 `getState() / setState() / subscribe()`
|
||||
|
||||
---
|
||||
|
||||
## 四、Phase 0:解耦准备(必须先做)
|
||||
|
||||
下列任务**不引入新功能**,只重排现有代码。每条独立可 ship。
|
||||
|
||||
### 0.1 抽出 store.js(单一所有权)
|
||||
- 文件:`core/table-system/domain/store.js`
|
||||
- 把 `currentTablesState` / `highlightedCells` / `updatedTables` 搬过来
|
||||
- 提供:`getState() / setState() / addHighlight / clearHighlights / getUpdatedTables / subscribe(listener)`
|
||||
- manager.js 改为代理调用
|
||||
|
||||
### 0.2 抽出 persist.js(消除 16 处持久化样板)
|
||||
- 文件:`core/table-system/domain/persist.js`
|
||||
- 提供 `commitToLastMessage(state)`:封装 `getContext + saveStateToMessage + saveChat + fallback`
|
||||
- 替换 manager.js 16 处样板
|
||||
|
||||
### 0.3 抽出 operations.js(解锁三模式的关键)
|
||||
- 文件:`core/table-system/operations.js`
|
||||
- 把 [executor.js insertRow/updateRow/deleteRow](core/table-system/executor.js#L3-L89) 抽成纯函数
|
||||
- schema:`Op = { op: 'insertRow'|'updateRow'|'deleteRow', tableIndex, rowIndex?, data? }`
|
||||
- API:`applyOperations(state, ops): { state, changes }`
|
||||
- executor.js 改名 → `formatters/legacy.js`,只保留文本解析 → 输出 Op[] → 调 applyOperations
|
||||
|
||||
### 0.4 拆 mutations.js
|
||||
- 文件:`core/table-system/domain/mutations.js`
|
||||
- 把 manager.js 里 16 个突变 export(addRow / addColumn 等)搬过来
|
||||
- 全部改为:调 store.setState + persist.commitToLastMessage + 发事件
|
||||
- **删除**对 ui/* 的所有 import;改为 `store.subscribe` 让 UI 自己订阅刷新
|
||||
|
||||
### 0.5 拆 rendering.js
|
||||
- 文件:`core/table-system/domain/rendering.js`
|
||||
- 把 [convertTablesToCsvString](core/table-system/manager.js#L1005) / [convertSelectedTablesToCsvString](core/table-system/manager.js#L1096) / [convertTablesToCsvStringForContentOnly](core/table-system/manager.js#L1201) 搬过来
|
||||
- 都做成纯函数 `(state, options?) => string`,不依赖 store
|
||||
|
||||
### 0.6 拆 templates.js + preset.js
|
||||
- `domain/templates.js`:getBatchFillerRuleTemplate / saveBatchFillerRuleTemplate / Flow 同款
|
||||
- `domain/preset.js`:exportPreset / importPreset / clearGlobalPreset / importGlobalPreset
|
||||
|
||||
### 0.7 抽出 fillerShared.js(消除三 filler 重复)
|
||||
- 文件:`core/table-system/filler/shared.js`
|
||||
- 提供:
|
||||
- `getWorldBookContext(settings)` — 合并 secondary 和 batch 两份的差异,参数化处理
|
||||
- `buildHistoryContext(opts)` — 统一对话历史拼装
|
||||
- `buildMessages(scope, { worldbook, history, coreContent, flowPrompt, ruleTemplate })` — mixed-order 循环 + presetPrompts 拼装
|
||||
- `callModel(messages, settings)` — 统一 nccsEnabled 分支
|
||||
- secondary-filler.js / batch-filler.js / reorganizer.js 改用 shared
|
||||
|
||||
### 0.8 解循环依赖
|
||||
- manager.js 的 `rollbackAndRefill` 不直接 import `fillWithSecondaryApi`
|
||||
- 改为:在 service 层 (TableSystemService) 编排"先 rollback 再 fill"
|
||||
- manager(或新的 mutations.js)只暴露 rollbackState
|
||||
|
||||
### 0.9 TableSystemService 真正变成门面
|
||||
- 不再 `import * as TableManager` + 一一 expose
|
||||
- 改为:内部组合 store / persist / mutations / formatters / filler,对外只暴露稳定接口
|
||||
- 现有 `processMessageUpdate` 保留
|
||||
|
||||
**Phase 0 完成验收**:
|
||||
- [ ] manager.js 缩到 < 200 行(仅作为 deprecation 兼容层重导出 + 标 @deprecated)
|
||||
- [ ] 任何 domain/* 文件都不 import ui/*
|
||||
- [ ] 三个 filler 共用 fillerShared.js,各自只有 ~100 行
|
||||
- [ ] 现行 legacy 模式行为完全不变(手动验证)
|
||||
|
||||
---
|
||||
|
||||
## 五、Phase B:JSON formatter
|
||||
|
||||
> 依赖 Phase 0。不依赖 Bus 升级(Phase A)。
|
||||
|
||||
### B.1 formatters/json.js
|
||||
- prompt 模板:教 LLM 输出 `{ "operations": [{ "op": "insertRow", "tableIndex": 0, "data": { "0": "...", "1": "..." } }] }`
|
||||
- 解析:`JSON.parse` + schema 校验 → Op[]
|
||||
- 输出 Op[] 给 applyOperations
|
||||
|
||||
### B.2 设置项与 UI
|
||||
- 新设置:`settings.table_filling_format: 'legacy' | 'json' | 'toolcall'`,默认 `legacy`
|
||||
- 表格设置面板加 dropdown
|
||||
- 默认值保证老用户零感知
|
||||
|
||||
### B.3 集成到 fillerShared
|
||||
- shared.callModel 调完后传 raw response 给当前 formatter
|
||||
- formatter 返回 Op[]
|
||||
- shared 负责 applyOperations + persist + 发事件
|
||||
|
||||
**Phase B 验收**:
|
||||
- [ ] 切换到 json 模式后,手动跑分步填表 + 批量填表 + 重新整理 三种场景都能成功
|
||||
- [ ] 回切 legacy 行为不变
|
||||
|
||||
---
|
||||
|
||||
## 六、Phase C:ToolCall formatter
|
||||
|
||||
> 依赖 Phase 0 + 51TODO Phase A(Bus tool-call 升级)+ Phase B(B 已经把 formatter 切换走通了)。
|
||||
|
||||
### C.1 formatters/toolcall.js
|
||||
- 注册 Bus 工具:`table.insertRow / table.updateRow / table.deleteRow`
|
||||
- 工具 parameters 用标准 JSONSchema 描述
|
||||
- handler 内部调 `applyOperations`(其实是收集 Op[] 累加)
|
||||
- 让 fillerShared 在该模式下走 `model.callWithTools`,loop 跑完后取累计的 Op[]
|
||||
|
||||
### C.2 终止条件
|
||||
- LLM 在某轮没有吐 tool_calls 即停(对应"我已填完"的语义信号)
|
||||
- maxSteps 兜底
|
||||
|
||||
### C.3 Prompt 调整
|
||||
- toolcall 模式下不需要 `<Amily2Edit>` 教学,prompt 简化
|
||||
- 但要保留 `{{{Amily2TableData}}}` 注入当前状态作为参考
|
||||
|
||||
**Phase C 验收**:
|
||||
- [ ] toolcall 模式跑通分步填表
|
||||
- [ ] 串表问题肉眼对比 legacy 显著减少
|
||||
- [ ] handler 内 tableIndex 不存在时回喂 LLM 能自纠
|
||||
|
||||
---
|
||||
|
||||
## 七、表格部分决策点
|
||||
|
||||
> 重构前需要确认:
|
||||
|
||||
1. **填表格式开关粒度**:全局一个?还是分步 / 批量 / 重整 三个独立?
|
||||
- 倾向:全局一个 `table_filling_format`,简化 UI
|
||||
|
||||
2. **JSON 模式形态**:
|
||||
- A:单 JSON 块 `{"operations":[...]}` 直球到底
|
||||
- B:允许 LLM 在 ops 前后写自由文本(像 toolcall 那样夹带推理)
|
||||
- 倾向:A,简单可靠
|
||||
|
||||
3. **toolcall 终止条件**:
|
||||
- A:模型某轮无 tool_calls 即停 + maxSteps 兜底
|
||||
- B:必须显式调 `commit_table_changes` 工具才算完
|
||||
- 倾向:A
|
||||
|
||||
4. **manager.js 兜底兼容期**:
|
||||
- 拆解后保留 manager.js 作 re-export 兼容层多久?
|
||||
- 倾向:保留至 2.0.2,2.0.3 删除
|
||||
|
||||
---
|
||||
|
||||
## 八、不在范围内(明确不做)
|
||||
|
||||
- 不重写 ui/table-bindings.js(UI 层独立演进)
|
||||
- 不改持久化 schema(`message.extra.amily2_tables_data` 保持)
|
||||
- 不改 SuperMemory 集成(继续走 Bus query + CustomEvent fallback)
|
||||
- 不引入 TypeScript(DTS 注释为主)
|
||||
- Phase 0 阶段不动 prompt 模板内容(只挪文件位置)
|
||||
|
||||
---
|
||||
|
||||
## 九、入手顺序
|
||||
|
||||
1. Phase 0.3(operations.js)—— 影响面小,立刻能验证 executor 抽离不破坏 legacy
|
||||
2. Phase 0.1 + 0.2(store + persist)—— 给后续 mutations 拆解铺路
|
||||
3. Phase 0.4-0.6 —— manager.js 收缩主战
|
||||
4. Phase 0.7-0.9 —— filler 重复消除 + 循环依赖
|
||||
5. Phase 0 整体回归
|
||||
6. Phase B(独立可走,不等 Bus 升级)
|
||||
7. Phase C(等 51TODO Phase A 完成后再做)
|
||||
|
||||
---
|
||||
|
||||
## 十、工时(粗)
|
||||
|
||||
| Phase | 预估 | 风险 |
|
||||
|-------|------|------|
|
||||
| 0.1-0.3 (store/persist/operations) | 1 天 | 低 |
|
||||
| 0.4-0.6 (mutations/rendering/templates) | 1 天 | 中(manager.js 删减易漏) |
|
||||
| 0.7-0.9 (filler / 循环依赖 / Service) | 1 天 | 中(filler 三方差异需仔细对齐) |
|
||||
| Phase B | 0.5 天 | 低 |
|
||||
| Phase C | 0.5 天 | 低(前置都搞完了,纯组装) |
|
||||
| 回归测试 | 1 天 | — |
|
||||
|
||||
合计 ~5 天人时(不含 Bus 升级,那部分见 51TODO)。
|
||||
@@ -83,6 +83,20 @@
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- 旧配置清理 -->
|
||||
<fieldset class="settings-group">
|
||||
<legend><i class="fas fa-broom"></i> 旧配置清理</legend>
|
||||
<small class="notes" style="display:block; margin-bottom:10px;">
|
||||
旧版各模块独立的 API 配置(URL / Key / Model / 温度等)已自动迁移到上方"连接配置"。
|
||||
若上方分配无误且使用一切正常,可点击下方按钮清除 <code>extension_settings</code> 中的旧字段
|
||||
与 <code>localStorage</code> 中的旧 API Key,避免残留卡 bug。<br/>
|
||||
<strong>清理前会校验所有旧字段所属槽位都已分配 profile</strong>,未分配的槽位会阻止清理并提示。
|
||||
</small>
|
||||
<button id="amily2_clear_legacy_config" class="menu_button caution interactable small_button amily2-vbtn">
|
||||
<span class="vbtn-icon"><i class="fas fa-trash-alt"></i></span><span class="vbtn-label">清除旧配置残留</span>
|
||||
</button>
|
||||
</fieldset>
|
||||
|
||||
<!-- 新建/编辑 Profile 表单(details 折叠) -->
|
||||
<details id="amily2_profile_form_details" class="settings-group amily2-profile-form">
|
||||
<summary>
|
||||
@@ -109,22 +123,33 @@
|
||||
<div class="amily2_settings_block">
|
||||
<label for="amily2_pf_provider">接口类型</label>
|
||||
<select id="amily2_pf_provider" class="text_pole">
|
||||
<option value="openai">OpenAI / 兼容接口(推荐)</option>
|
||||
<option value="google">Google Gemini 直连</option>
|
||||
<option value="sillytavern_backend">SillyTavern 后端代理</option>
|
||||
<option value="sillytavern_preset">SillyTavern 预设转发</option>
|
||||
<optgroup label="官方预设(自动填默认 URL)">
|
||||
<option value="openai">OpenAI (GPT)</option>
|
||||
<option value="anthropic">Anthropic Claude</option>
|
||||
<option value="google">Google Gemini</option>
|
||||
<option value="openrouter">OpenRouter (聚合)</option>
|
||||
<option value="deepseek">DeepSeek</option>
|
||||
<option value="xai">xAI Grok</option>
|
||||
</optgroup>
|
||||
<optgroup label="自定义 / 高级">
|
||||
<option value="custom_oai">Custom OpenAI 兼容(自填 URL)</option>
|
||||
<option value="sillytavern_backend">SillyTavern 后端代理</option>
|
||||
<option value="sillytavern_preset">SillyTavern 预设转发</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
<div class="amily2_settings_block" id="amily2_pf_url_row">
|
||||
<label for="amily2_pf_url">API 地址</label>
|
||||
<input id="amily2_pf_url" type="text" class="text_pole" placeholder="https://api.example.com/v1" />
|
||||
</div>
|
||||
<!-- Google 专属提示 -->
|
||||
<div id="amily2_pf_google_note" style="display:none; margin-bottom:8px;">
|
||||
<small class="notes" style="display:block; padding:6px 10px; background:var(--black10a); border-radius:4px; border-left:3px solid #4285f4;">
|
||||
<i class="fas fa-info-circle" style="color:#4285f4;"></i>
|
||||
Google AI Studio — 接口地址已自动配置,只需填写 API Key 即可。
|
||||
在 <a href="https://aistudio.google.com/apikey" target="_blank" rel="noopener" style="color:#4285f4;">aistudio.google.com</a> 生成密钥。
|
||||
<!-- Vendor 提示(动态内容由 JS 填充) -->
|
||||
<div id="amily2_pf_vendor_note" style="display:none; margin-bottom:8px;">
|
||||
<small class="notes" style="display:block; padding:6px 10px; background:var(--black10a); border-radius:4px; border-left:3px solid var(--SmartThemeQuoteColor);">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<span id="amily2_pf_vendor_note_text"></span>
|
||||
<span id="amily2_pf_vendor_note_link_wrap" style="display:none;">
|
||||
<a id="amily2_pf_vendor_note_link" href="#" target="_blank" rel="noopener" style="color:var(--SmartThemeQuoteColor);"></a>
|
||||
</span>
|
||||
</small>
|
||||
</div>
|
||||
<div class="amily2_settings_block">
|
||||
@@ -175,6 +200,30 @@
|
||||
<small class="notes" style="display:block; font-weight:normal;">以 stream:true 接收 SSE 后拼接,适用于经 CloudFlare 免费代理的接口</small>
|
||||
</label>
|
||||
</div>
|
||||
<div class="amily2_settings_block">
|
||||
<label for="amily2_pf_custom_params">
|
||||
自定义参数 (JSON)
|
||||
<small class="notes" style="display:block; font-weight:normal;">
|
||||
透传到 LLM 请求 body 的额外参数。留空或 <code>{}</code> 表示不附加。
|
||||
</small>
|
||||
</label>
|
||||
<textarea id="amily2_pf_custom_params"
|
||||
class="text_pole"
|
||||
rows="6"
|
||||
spellcheck="false"
|
||||
style="font-family:monospace; font-size:0.85em;"
|
||||
placeholder='{
|
||||
"top_p": 0.9,
|
||||
"frequency_penalty": 0.3,
|
||||
"stop": ["\n###"]
|
||||
}'></textarea>
|
||||
<small id="amily2_pf_custom_params_hint"
|
||||
class="notes"
|
||||
style="display:block; margin-top:4px; color:var(--SmartThemeQuoteColor); font-size:0.82em;"></small>
|
||||
<small id="amily2_pf_custom_params_error"
|
||||
class="notes"
|
||||
style="display:none; margin-top:4px; color:var(--warning, #d9534f); font-size:0.82em;"></small>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
208
assets/api-vendor-params.json
Normal file
208
assets/api-vendor-params.json
Normal file
@@ -0,0 +1,208 @@
|
||||
{
|
||||
"version": 1,
|
||||
"_doc": "API 厂商参数 registry。用作自定义参数编辑器的提示导航,不做强制约束 —— 用户写厂商不认识的参数会被原样发送,至多被服务端忽略。新增厂商:在 vendors 数组追加一项;新增参数:在对应 vendor.params 加一条。",
|
||||
"vendors": [
|
||||
{
|
||||
"id": "anthropic",
|
||||
"displayName": "Anthropic Claude",
|
||||
"match": ["api.anthropic.com", "anthropic.com"],
|
||||
"defaultUrl": "https://api.anthropic.com/v1",
|
||||
"doc": "https://docs.anthropic.com/en/api/openai-sdk",
|
||||
"_note": "通过 Anthropic 官方的 OpenAI 兼容层接入。需要 anthropic-version header 走 ST backend 自动加。",
|
||||
"params": {
|
||||
"top_p": {
|
||||
"type": "number",
|
||||
"range": [0, 1],
|
||||
"desc": "核采样阈值。与 temperature 二选一,不要同时调。"
|
||||
},
|
||||
"top_k": {
|
||||
"type": "integer",
|
||||
"desc": "采样候选词数量上限。"
|
||||
},
|
||||
"stop_sequences": {
|
||||
"type": "array<string>",
|
||||
"desc": "停止序列(注意 Anthropic 用复数形式)。"
|
||||
},
|
||||
"thinking": {
|
||||
"type": "object",
|
||||
"desc": "Claude 3.7+ 思考模式:{ \"type\": \"enabled\", \"budget_tokens\": 1024 }。"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"desc": "{ \"user_id\": \"...\" } 用于厂商侧滥用追踪。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "openai",
|
||||
"displayName": "OpenAI (GPT)",
|
||||
"match": ["api.openai.com", "openai.com"],
|
||||
"defaultUrl": "https://api.openai.com/v1",
|
||||
"doc": "https://platform.openai.com/docs/api-reference/chat/create",
|
||||
"params": {
|
||||
"top_p": {
|
||||
"type": "number",
|
||||
"range": [0, 1],
|
||||
"desc": "核采样阈值。与 temperature 二选一。"
|
||||
},
|
||||
"frequency_penalty": {
|
||||
"type": "number",
|
||||
"range": [-2, 2],
|
||||
"desc": "已出现 token 的惩罚(频次基础)。"
|
||||
},
|
||||
"presence_penalty": {
|
||||
"type": "number",
|
||||
"range": [-2, 2],
|
||||
"desc": "已出现 token 的惩罚(存在与否)。"
|
||||
},
|
||||
"seed": {
|
||||
"type": "integer",
|
||||
"desc": "随机数种子,相同 seed + 相同输入 ≈ 相同输出(不保证)。"
|
||||
},
|
||||
"stop": {
|
||||
"type": "string | array<string>",
|
||||
"desc": "停止序列,最多 4 个。"
|
||||
},
|
||||
"response_format": {
|
||||
"type": "object",
|
||||
"desc": "{ \"type\": \"json_object\" } 或 { \"type\": \"json_schema\", \"json_schema\": {...} }。"
|
||||
},
|
||||
"reasoning_effort": {
|
||||
"type": "string",
|
||||
"values": ["low", "medium", "high"],
|
||||
"desc": "o 系列推理强度。"
|
||||
},
|
||||
"logit_bias": {
|
||||
"type": "object",
|
||||
"desc": "{ tokenId: bias } 调整特定 token 概率。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "openrouter",
|
||||
"displayName": "OpenRouter (聚合)",
|
||||
"match": ["openrouter.ai"],
|
||||
"defaultUrl": "https://openrouter.ai/api/v1",
|
||||
"doc": "https://openrouter.ai/docs",
|
||||
"params": {
|
||||
"top_p": { "type": "number", "range": [0, 1], "desc": "核采样阈值。" },
|
||||
"top_k": { "type": "integer", "desc": "部分模型支持。" },
|
||||
"frequency_penalty": { "type": "number", "range": [-2, 2], "desc": "频次惩罚。" },
|
||||
"presence_penalty": { "type": "number", "range": [-2, 2], "desc": "存在惩罚。" },
|
||||
"seed": { "type": "integer", "desc": "随机数种子。" },
|
||||
"stop": { "type": "string | array<string>", "desc": "停止序列。" },
|
||||
"provider": {
|
||||
"type": "object",
|
||||
"desc": "OR 路由配置:{ \"order\": [\"Anthropic\"], \"allow_fallbacks\": true, \"require_parameters\": false, \"data_collection\": \"deny\" }。"
|
||||
},
|
||||
"transforms": {
|
||||
"type": "array<string>",
|
||||
"desc": "[\"middle-out\"] 启用中间挤压防 context 超限。"
|
||||
},
|
||||
"models": {
|
||||
"type": "array<string>",
|
||||
"desc": "fallback 模型列表,主模型失败时按顺序尝试。"
|
||||
},
|
||||
"route": {
|
||||
"type": "string",
|
||||
"values": ["fallback"],
|
||||
"desc": "\"fallback\" 启用 models 列表。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "google",
|
||||
"displayName": "Google Gemini",
|
||||
"match": ["googleapis.com", "generativelanguage.googleapis.com"],
|
||||
"defaultUrl": "https://generativelanguage.googleapis.com/v1beta/openai",
|
||||
"doc": "https://ai.google.dev/gemini-api/docs/openai",
|
||||
"_note": "走 Gemini 的 OpenAI 兼容端点 /v1beta/openai。原生 generate-content 端点不在此模式覆盖范围,需用 Custom 模式手填。",
|
||||
"params": {
|
||||
"top_p": { "type": "number", "range": [0, 1], "desc": "核采样阈值。" },
|
||||
"top_k": { "type": "integer", "desc": "Gemini 支持 top_k 采样。" },
|
||||
"stop_sequences": {
|
||||
"type": "array<string>",
|
||||
"desc": "停止序列(数组形式)。"
|
||||
},
|
||||
"safety_settings": {
|
||||
"type": "array<object>",
|
||||
"desc": "[{\"category\": \"HARM_CATEGORY_HARASSMENT\", \"threshold\": \"BLOCK_NONE\"}, ...] 安全过滤。"
|
||||
},
|
||||
"response_mime_type": {
|
||||
"type": "string",
|
||||
"values": ["text/plain", "application/json"],
|
||||
"desc": "强制响应格式。"
|
||||
},
|
||||
"thinking_config": {
|
||||
"type": "object",
|
||||
"desc": "Gemini 2.5 思考配置:{ \"thinking_budget\": 1024 }。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "deepseek",
|
||||
"displayName": "DeepSeek",
|
||||
"match": ["api.deepseek.com", "deepseek.com"],
|
||||
"defaultUrl": "https://api.deepseek.com/v1",
|
||||
"doc": "https://api-docs.deepseek.com",
|
||||
"params": {
|
||||
"top_p": { "type": "number", "range": [0, 1], "desc": "核采样阈值。" },
|
||||
"frequency_penalty": { "type": "number", "range": [-2, 2], "desc": "频次惩罚。" },
|
||||
"presence_penalty": { "type": "number", "range": [-2, 2], "desc": "存在惩罚。" },
|
||||
"stop": { "type": "string | array<string>", "desc": "停止序列。" },
|
||||
"response_format": {
|
||||
"type": "object",
|
||||
"desc": "{ \"type\": \"json_object\" } 强制 JSON 输出。"
|
||||
},
|
||||
"thinking": {
|
||||
"type": "object",
|
||||
"desc": "V3.2+ 思考模式开关:{ \"type\": \"enabled\" } 或 { \"type\": \"disabled\" },默认 enabled。"
|
||||
},
|
||||
"reasoning_effort": {
|
||||
"type": "string",
|
||||
"values": ["high", "max"],
|
||||
"desc": "思考强度,默认 high;复杂 Agent 请求会自动升至 max。"
|
||||
},
|
||||
"_warning_reasoner": "deepseek-reasoner 模型会忽略 temperature/top_p/frequency_penalty/presence_penalty。"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "xai",
|
||||
"displayName": "xAI Grok",
|
||||
"match": ["api.x.ai", "x.ai", "xai.com"],
|
||||
"defaultUrl": "https://api.x.ai/v1",
|
||||
"doc": "https://docs.x.ai/api",
|
||||
"params": {
|
||||
"top_p": { "type": "number", "range": [0, 1], "desc": "核采样阈值。" },
|
||||
"frequency_penalty": { "type": "number", "range": [-2, 2], "desc": "频次惩罚。" },
|
||||
"presence_penalty": { "type": "number", "range": [-2, 2], "desc": "存在惩罚。" },
|
||||
"seed": { "type": "integer", "desc": "随机数种子。" },
|
||||
"stop": { "type": "string | array<string>", "desc": "停止序列。" },
|
||||
"response_format": {
|
||||
"type": "object",
|
||||
"desc": "{ \"type\": \"json_object\" }。"
|
||||
},
|
||||
"search_parameters": {
|
||||
"type": "object",
|
||||
"desc": "Live Search 配置:{ \"mode\": \"auto\" | \"on\" | \"off\", \"sources\": [...] }。"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"fallback": {
|
||||
"id": "openai-compat",
|
||||
"displayName": "OpenAI-compatible (通用)",
|
||||
"doc": "Mistral / Together / Fireworks / 本地 KoboldCpp / Ollama 等。匹配不到具体 vendor 时归到此条,提示 OpenAI 标准参数。",
|
||||
"params": {
|
||||
"top_p": { "type": "number", "range": [0, 1], "desc": "核采样阈值。" },
|
||||
"top_k": { "type": "integer", "desc": "部分实现支持。" },
|
||||
"frequency_penalty": { "type": "number", "range": [-2, 2], "desc": "频次惩罚。" },
|
||||
"presence_penalty": { "type": "number", "range": [-2, 2], "desc": "存在惩罚。" },
|
||||
"min_p": { "type": "number", "range": [0, 1], "desc": "本地模型常用,OpenAI 没有。" },
|
||||
"seed": { "type": "integer", "desc": "随机数种子。" },
|
||||
"stop": { "type": "string | array<string>", "desc": "停止序列。" },
|
||||
"response_format": { "type": "object", "desc": "{ \"type\": \"json_object\" }。" },
|
||||
"repetition_penalty": { "type": "number", "desc": "本地模型常用,OpenAI 没有。" }
|
||||
}
|
||||
}
|
||||
}
|
||||
47
core/api.js
47
core/api.js
@@ -441,7 +441,7 @@ async function fetchSillyTavernPresetModels() {
|
||||
export async function getApiSettings(slot = 'main') {
|
||||
const s = extension_settings[extensionName] || {};
|
||||
|
||||
// 优先读取槽位分配的 Profile(仅接管连接参数)
|
||||
// 优先读取槽位分配的 Profile(profile 一旦分配即为权威,不再被主面板/模块独立设置压制)
|
||||
const profile = await getSlotProfile(slot);
|
||||
if (profile) {
|
||||
const resolvedProvider = profile.provider === 'sillytavern_backend'
|
||||
@@ -453,10 +453,10 @@ export async function getApiSettings(slot = 'main') {
|
||||
apiUrl: profile.apiUrl,
|
||||
apiKey: profile.apiKey ?? '',
|
||||
model: profile.model,
|
||||
// 温度 / MaxTokens 读面板值(profile-sync 保留了这些输入框)
|
||||
maxTokens: s.maxTokens ?? profile.maxTokens ?? 65500,
|
||||
temperature: s.temperature ?? profile.temperature ?? 1.0,
|
||||
maxTokens: profile.maxTokens ?? 65500,
|
||||
temperature: profile.temperature ?? 1.0,
|
||||
fakeStream: profile.fakeStream ?? false,
|
||||
customParams: profile.customParams ?? {},
|
||||
tavernProfile: '',
|
||||
};
|
||||
}
|
||||
@@ -588,7 +588,10 @@ export async function callAI(messages, options = {}) {
|
||||
apiUrl: apiSettings.apiUrl,
|
||||
apiKey: apiSettings.apiKey,
|
||||
apiProvider: apiSettings.apiProvider,
|
||||
...options
|
||||
customParams: apiSettings.customParams ?? {},
|
||||
...options,
|
||||
// options 可显式覆盖 customParams,体现"代码内显式 > profile 配置"
|
||||
customParams: { ...(apiSettings.customParams ?? {}), ...(options.customParams ?? {}) },
|
||||
};
|
||||
|
||||
if (finalOptions.apiProvider !== 'sillytavern_preset') {
|
||||
@@ -680,11 +683,14 @@ async function callOpenAICompatible(messages, options) {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
// 用户自定义参数(profile.customParams + 显式 options.customParams 已在 callAI 合并)
|
||||
...(options.customParams || {}),
|
||||
// 表单托管的核心字段总是覆盖 customParams
|
||||
model: options.model,
|
||||
messages: messages,
|
||||
max_tokens: options.maxTokens,
|
||||
temperature: options.temperature,
|
||||
stream: false
|
||||
stream: false,
|
||||
})
|
||||
});
|
||||
|
||||
@@ -699,6 +705,21 @@ async function callOpenAICompatible(messages, options) {
|
||||
|
||||
async function callOpenAITest(messages, options) {
|
||||
const body = {
|
||||
// 1. 可调默认值(用户 customParams 可覆盖)
|
||||
top_p: options.top_p || 1,
|
||||
frequency_penalty: 0,
|
||||
presence_penalty: 0.12,
|
||||
include_reasoning: false,
|
||||
reasoning_effort: 'medium',
|
||||
enable_web_search: false,
|
||||
request_images: false,
|
||||
custom_prompt_post_processing: 'strict',
|
||||
group_names: [],
|
||||
|
||||
// 2. 用户 customParams 覆盖上层默认值
|
||||
...(options.customParams || {}),
|
||||
|
||||
// 3. 表单托管的核心字段总是 win
|
||||
chat_completion_source: 'openai',
|
||||
messages: messages,
|
||||
model: options.model,
|
||||
@@ -707,15 +728,6 @@ async function callOpenAITest(messages, options) {
|
||||
stream: false,
|
||||
max_tokens: options.maxTokens || 30000,
|
||||
temperature: options.temperature || 1,
|
||||
top_p: options.top_p || 1,
|
||||
custom_prompt_post_processing: 'strict',
|
||||
enable_web_search: false,
|
||||
frequency_penalty: 0,
|
||||
group_names: [],
|
||||
include_reasoning: false,
|
||||
presence_penalty: 0.12,
|
||||
reasoning_effort: 'medium',
|
||||
request_images: false,
|
||||
};
|
||||
|
||||
const response = await fetch('/api/backends/chat-completions/generate', {
|
||||
@@ -816,6 +828,9 @@ async function callSillyTavernBackend(messages, options) {
|
||||
type: 'POST',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify({
|
||||
// 用户 customParams(可被核心字段覆盖)
|
||||
...(options.customParams || {}),
|
||||
// 表单托管字段总是 win
|
||||
chat_completion_source: 'custom',
|
||||
custom_url: options.apiUrl,
|
||||
api_key: options.apiKey,
|
||||
@@ -823,7 +838,7 @@ async function callSillyTavernBackend(messages, options) {
|
||||
messages: messages,
|
||||
max_tokens: options.maxTokens,
|
||||
temperature: options.temperature,
|
||||
stream: false
|
||||
stream: false,
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
@@ -3,11 +3,12 @@ import { getRequestHeaders } from "/script.js";
|
||||
import { extensionName } from "../../utils/settings.js";
|
||||
import { getSlotProfile, providerToApiMode } from './api-resolver.js';
|
||||
import { configManager } from '../../utils/config/ConfigManager.js';
|
||||
import { detectVendor } from '../../utils/api-vendor.js';
|
||||
|
||||
async function getConcurrentApiSettings() {
|
||||
const s = extension_settings[extensionName] || {};
|
||||
|
||||
// 优先读取槽位分配的 Profile(仅接管连接参数)
|
||||
// 优先读取槽位分配的 Profile(profile 一旦分配即权威,slider 残值不再覆盖)
|
||||
const profile = await getSlotProfile('plotOptConc');
|
||||
if (profile) {
|
||||
return {
|
||||
@@ -15,8 +16,7 @@ async function getConcurrentApiSettings() {
|
||||
apiUrl: profile.apiUrl,
|
||||
apiKey: profile.apiKey ?? '',
|
||||
model: profile.model,
|
||||
// MaxTokens 读面板值
|
||||
maxTokens: s.plotOpt_concurrentMaxTokens ?? profile.maxTokens ?? 8100,
|
||||
maxTokens: profile.maxTokens ?? 8100,
|
||||
temperature: profile.temperature ?? 1,
|
||||
};
|
||||
}
|
||||
@@ -47,7 +47,7 @@ export async function callConcurrentAI(messages, options = {}) {
|
||||
|
||||
if (!finalOptions.apiUrl || !finalOptions.model || !finalOptions.apiKey) {
|
||||
console.warn("[Amily2-Concurrent外交部] API配置不完整,无法调用AI");
|
||||
toastr.error("并发API配置不完整,请检查URL、Key和模型配置。", "Concurrent-外交部");
|
||||
toastr.error("并发剧情优化(plotOptConc)未配置 API 连接配置,请前往 API 连接配置面板分配 profile 或填写并发优化独立设置。", "Amily2-并发优化未配置");
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ export async function callConcurrentAI(messages, options = {}) {
|
||||
}
|
||||
|
||||
async function callConcurrentOpenAITest(messages, options) {
|
||||
const isGoogleApi = options.apiUrl.includes('googleapis.com');
|
||||
const isGoogleApi = (await detectVendor(options.apiUrl)) === 'google';
|
||||
|
||||
const body = {
|
||||
chat_completion_source: 'openai',
|
||||
|
||||
@@ -4,6 +4,7 @@ import { extensionName } from "../../utils/settings.js";
|
||||
import { amilyHelper } from '../../core/tavern-helper/main.js';
|
||||
import { getSlotProfile, providerToApiMode } from './api-resolver.js';
|
||||
import { configManager } from '../../utils/config/ConfigManager.js';
|
||||
import { detectVendor } from '../../utils/api-vendor.js';
|
||||
|
||||
let ChatCompletionService = undefined;
|
||||
try {
|
||||
@@ -47,7 +48,7 @@ function normalizeApiResponse(responseData) {
|
||||
export async function getJqyhApiSettings() {
|
||||
const s = extension_settings[extensionName] || {};
|
||||
|
||||
// JQYH 与剧情优化互斥,共用 'plotOpt' 槽位
|
||||
// JQYH 与剧情优化互斥,共用 'plotOpt' 槽位(profile 一旦分配即权威,slider 残值不再覆盖)
|
||||
const profile = await getSlotProfile('plotOpt');
|
||||
if (profile) {
|
||||
return {
|
||||
@@ -55,9 +56,9 @@ export async function getJqyhApiSettings() {
|
||||
apiUrl: profile.apiUrl,
|
||||
apiKey: profile.apiKey ?? '',
|
||||
model: profile.model,
|
||||
// 温度 / MaxTokens 读面板值
|
||||
maxTokens: s.jqyhMaxTokens ?? profile.maxTokens ?? 65500,
|
||||
temperature: s.jqyhTemperature ?? profile.temperature ?? 1.0,
|
||||
maxTokens: profile.maxTokens ?? 65500,
|
||||
temperature: profile.temperature ?? 1.0,
|
||||
customParams: profile.customParams ?? {},
|
||||
tavernProfile: '',
|
||||
};
|
||||
}
|
||||
@@ -70,6 +71,7 @@ export async function getJqyhApiSettings() {
|
||||
model: s.jqyhModel || '',
|
||||
maxTokens: s.jqyhMaxTokens || 4000,
|
||||
temperature: s.jqyhTemperature || 0.7,
|
||||
customParams: {},
|
||||
tavernProfile: s.jqyhTavernProfile || '',
|
||||
};
|
||||
}
|
||||
@@ -96,7 +98,7 @@ export async function callJqyhAI(messages, options = {}) {
|
||||
if (finalOptions.apiMode !== 'sillytavern_preset') {
|
||||
if (!finalOptions.apiUrl || !finalOptions.model || !finalOptions.apiKey) {
|
||||
console.warn("[Amily2-Jqyh外交部] API配置不完整,无法调用AI");
|
||||
toastr.error("API配置不完整,请检查URL、Key和模型配置。", "Jqyh-外交部");
|
||||
toastr.error("剧情优化前置(JQYH)未配置 API 连接配置,请前往 API 连接配置面板分配 profile 或填写 JQYH 独立设置。", "Amily2-JQYH 未配置");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -160,9 +162,11 @@ export async function callJqyhAI(messages, options = {}) {
|
||||
}
|
||||
|
||||
async function callJqyhOpenAITest(messages, options) {
|
||||
const isGoogleApi = options.apiUrl.includes('googleapis.com');
|
||||
const isGoogleApi = (await detectVendor(options.apiUrl)) === 'google';
|
||||
|
||||
const body = {
|
||||
top_p: options.top_p || 1,
|
||||
...(options.customParams || {}),
|
||||
chat_completion_source: 'openai',
|
||||
messages: messages,
|
||||
model: options.model,
|
||||
@@ -171,7 +175,6 @@ async function callJqyhOpenAITest(messages, options) {
|
||||
stream: false,
|
||||
max_tokens: options.maxTokens || 30000,
|
||||
temperature: options.temperature || 1,
|
||||
top_p: options.top_p || 1,
|
||||
};
|
||||
|
||||
if (!isGoogleApi) {
|
||||
@@ -245,7 +248,8 @@ async function callJqyhSillyTavernPreset(messages, options) {
|
||||
responsePromise = context.ConnectionManagerRequestService.sendRequest(
|
||||
targetProfile.id,
|
||||
messages,
|
||||
options.maxTokens || 4000
|
||||
options.maxTokens || 4000,
|
||||
options.customParams || {}
|
||||
);
|
||||
|
||||
} finally {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { extensionName } from "../../utils/settings.js";
|
||||
import { amilyHelper } from '../../core/tavern-helper/main.js';
|
||||
import { getSlotProfile, providerToApiMode } from './api-resolver.js';
|
||||
import { configManager } from '../../utils/config/ConfigManager.js';
|
||||
import { detectVendor } from '../../utils/api-vendor.js';
|
||||
|
||||
let ChatCompletionService = undefined;
|
||||
try {
|
||||
@@ -41,7 +42,7 @@ if (window.Amily2Bus) {
|
||||
export async function getNccsApiSettings() {
|
||||
const s = extension_settings[extensionName] || {};
|
||||
|
||||
// 优先读取 'nccs' 槽位分配的 Profile(仅接管连接参数)
|
||||
// 优先读取 'nccs' 槽位分配的 Profile(profile 一旦分配即权威,旧 slider 残值不再覆盖)
|
||||
const profile = await getSlotProfile('nccs');
|
||||
if (profile) {
|
||||
return {
|
||||
@@ -50,9 +51,9 @@ export async function getNccsApiSettings() {
|
||||
apiUrl: profile.apiUrl,
|
||||
apiKey: profile.apiKey ?? '',
|
||||
model: profile.model,
|
||||
// 温度 / MaxTokens / FakeStream 读面板值(profile-sync 保留了这些输入框)
|
||||
maxTokens: s.nccsMaxTokens ?? profile.maxTokens ?? 65500,
|
||||
temperature: s.nccsTemperature ?? profile.temperature ?? 1.0,
|
||||
maxTokens: profile.maxTokens ?? 65500,
|
||||
temperature: profile.temperature ?? 1.0,
|
||||
customParams: profile.customParams ?? {},
|
||||
tavernProfile: '',
|
||||
useFakeStream: s.nccsFakeStreamEnabled ?? false,
|
||||
};
|
||||
@@ -67,6 +68,7 @@ export async function getNccsApiSettings() {
|
||||
model: s.nccsModel || '',
|
||||
maxTokens: s.nccsMaxTokens ?? 8192,
|
||||
temperature: s.nccsTemperature ?? 1,
|
||||
customParams: {},
|
||||
tavernProfile: s.nccsTavernProfile || '',
|
||||
useFakeStream: s.nccsFakeStreamEnabled || false,
|
||||
};
|
||||
@@ -94,7 +96,7 @@ export async function callNccsAI(messages, options = {}) {
|
||||
if (finalOptions.apiMode !== 'sillytavern_preset') {
|
||||
if (!finalOptions.apiUrl || !finalOptions.model || !finalOptions.apiKey) {
|
||||
console.warn("[Amily2-Nccs外交部] API配置不完整,无法调用AI");
|
||||
toastr.error("API配置不完整,请检查URL、Key和模型配置。", "Nccs-外交部");
|
||||
toastr.error("并发模块(NCCS)未配置 API 连接配置,请前往 API 连接配置面板分配 profile 或填写 NCCS 独立设置。", "Amily2-NCCS 未配置");
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
@@ -187,8 +189,10 @@ function normalizeApiResponse(responseData) {
|
||||
}
|
||||
|
||||
async function callNccsOpenAITest(messages, options) {
|
||||
const isGoogleApi = options.apiUrl.includes('googleapis.com');
|
||||
const isGoogleApi = (await detectVendor(options.apiUrl)) === 'google';
|
||||
const body = {
|
||||
top_p: options.top_p || 1,
|
||||
...(options.customParams || {}),
|
||||
chat_completion_source: 'openai',
|
||||
messages: messages,
|
||||
model: options.model,
|
||||
@@ -197,7 +201,6 @@ async function callNccsOpenAITest(messages, options) {
|
||||
stream: !!options.stream,
|
||||
max_tokens: 8192,
|
||||
temperature: 1,
|
||||
top_p: options.top_p || 1,
|
||||
};
|
||||
|
||||
if (!isGoogleApi) {
|
||||
@@ -244,7 +247,8 @@ async function callNccsSillyTavernPreset(messages, options) {
|
||||
const result = await context.ConnectionManagerRequestService.sendRequest(
|
||||
targetProfile.id,
|
||||
messages,
|
||||
8192
|
||||
8192,
|
||||
options.customParams || {}
|
||||
);
|
||||
|
||||
return normalizeApiResponse(result);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { extensionName } from "../../utils/settings.js";
|
||||
import { amilyHelper } from '../../core/tavern-helper/main.js';
|
||||
import { getSlotProfile, providerToApiMode } from './api-resolver.js';
|
||||
import { configManager } from '../../utils/config/ConfigManager.js';
|
||||
import { detectVendor } from '../../utils/api-vendor.js';
|
||||
|
||||
let ChatCompletionService = undefined;
|
||||
try {
|
||||
@@ -47,7 +48,7 @@ function normalizeApiResponse(responseData) {
|
||||
export async function getNgmsApiSettings() {
|
||||
const s = extension_settings[extensionName] || {};
|
||||
|
||||
// 优先读取 'ngms' 槽位分配的 Profile(仅接管连接参数)
|
||||
// 优先读取 'ngms' 槽位分配的 Profile(profile 一旦分配即权威,旧 slider 残值不再覆盖)
|
||||
const profile = await getSlotProfile('ngms');
|
||||
if (profile) {
|
||||
return {
|
||||
@@ -55,9 +56,9 @@ export async function getNgmsApiSettings() {
|
||||
apiUrl: profile.apiUrl,
|
||||
apiKey: profile.apiKey ?? '',
|
||||
model: profile.model,
|
||||
// 温度 / MaxTokens / FakeStream 读面板值
|
||||
maxTokens: s.ngmsMaxTokens ?? profile.maxTokens ?? 65500,
|
||||
temperature: s.ngmsTemperature ?? profile.temperature ?? 1.0,
|
||||
maxTokens: profile.maxTokens ?? 65500,
|
||||
temperature: profile.temperature ?? 1.0,
|
||||
customParams: profile.customParams ?? {},
|
||||
tavernProfile: '',
|
||||
useFakeStream: s.ngmsFakeStreamEnabled ?? false,
|
||||
};
|
||||
@@ -71,6 +72,7 @@ export async function getNgmsApiSettings() {
|
||||
model: s.ngmsModel || '',
|
||||
maxTokens: s.ngmsMaxTokens ?? 30000,
|
||||
temperature: s.ngmsTemperature ?? 1.0,
|
||||
customParams: {},
|
||||
tavernProfile: s.ngmsTavernProfile || '',
|
||||
useFakeStream: s.ngmsFakeStreamEnabled || false,
|
||||
};
|
||||
@@ -101,7 +103,7 @@ export async function callNgmsAI(messages, options = {}) {
|
||||
if (finalOptions.apiMode !== 'sillytavern_preset') {
|
||||
if (!finalOptions.apiUrl || !finalOptions.model || !finalOptions.apiKey) {
|
||||
console.warn("[Amily2-Ngms外交部] API配置不完整,无法调用AI");
|
||||
toastr.error("API配置不完整,请检查URL、Key和模型配置。", "Ngms-外交部");
|
||||
toastr.error("总结模块(NGMS)未配置 API 连接配置,请前往 API 连接配置面板分配 profile 或填写 NGMS 独立设置。", "Amily2-NGMS 未配置");
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
@@ -221,9 +223,11 @@ async function fetchFakeStream(url, opts) {
|
||||
}
|
||||
|
||||
async function callNgmsOpenAITest(messages, options) {
|
||||
const isGoogleApi = options.apiUrl.includes('googleapis.com');
|
||||
const isGoogleApi = (await detectVendor(options.apiUrl)) === 'google';
|
||||
|
||||
const body = {
|
||||
top_p: options.top_p || 1,
|
||||
...(options.customParams || {}),
|
||||
chat_completion_source: 'openai',
|
||||
messages: messages,
|
||||
model: options.model,
|
||||
@@ -232,7 +236,6 @@ async function callNgmsOpenAITest(messages, options) {
|
||||
stream: !!options.stream,
|
||||
max_tokens: options.maxTokens || 30000,
|
||||
temperature: options.temperature || 1,
|
||||
top_p: options.top_p || 1,
|
||||
};
|
||||
|
||||
if (!isGoogleApi) {
|
||||
@@ -312,7 +315,8 @@ async function callNgmsSillyTavernPreset(messages, options) {
|
||||
responsePromise = context.ConnectionManagerRequestService.sendRequest(
|
||||
targetProfile.id,
|
||||
messages,
|
||||
options.maxTokens || 4000
|
||||
options.maxTokens || 4000,
|
||||
options.customParams || {}
|
||||
);
|
||||
|
||||
} finally {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { extensionName } from "../../utils/settings.js";
|
||||
import { amilyHelper } from '../../core/tavern-helper/main.js';
|
||||
import { getSlotProfile, providerToApiMode } from './api-resolver.js';
|
||||
import { configManager } from '../../utils/config/ConfigManager.js';
|
||||
import { detectVendor } from '../../utils/api-vendor.js';
|
||||
|
||||
let ChatCompletionService = undefined;
|
||||
try {
|
||||
@@ -47,7 +48,7 @@ function normalizeApiResponse(responseData) {
|
||||
export async function getSybdApiSettings() {
|
||||
const s = extension_settings[extensionName] || {};
|
||||
|
||||
// 优先读取 'sybd' 槽位分配的 Profile
|
||||
// 优先读取 'sybd' 槽位分配的 Profile(profile 一旦分配即权威,slider 残值不再覆盖)
|
||||
const profile = await getSlotProfile('sybd');
|
||||
if (profile) {
|
||||
return {
|
||||
@@ -55,8 +56,9 @@ export async function getSybdApiSettings() {
|
||||
apiUrl: profile.apiUrl,
|
||||
apiKey: profile.apiKey ?? '',
|
||||
model: profile.model,
|
||||
maxTokens: s.sybdMaxTokens ?? profile.maxTokens ?? 4000,
|
||||
temperature: s.sybdTemperature ?? profile.temperature ?? 0.7,
|
||||
maxTokens: profile.maxTokens ?? 4000,
|
||||
temperature: profile.temperature ?? 0.7,
|
||||
customParams: profile.customParams ?? {},
|
||||
tavernProfile: '',
|
||||
};
|
||||
}
|
||||
@@ -69,6 +71,7 @@ export async function getSybdApiSettings() {
|
||||
model: s.sybdModel || '',
|
||||
maxTokens: s.sybdMaxTokens || 4000,
|
||||
temperature: s.sybdTemperature || 0.7,
|
||||
customParams: {},
|
||||
tavernProfile: s.sybdTavernProfile || '',
|
||||
};
|
||||
}
|
||||
@@ -95,7 +98,7 @@ export async function callSybdAI(messages, options = {}) {
|
||||
if (finalOptions.apiMode !== 'sillytavern_preset') {
|
||||
if (!finalOptions.apiUrl || !finalOptions.model || !finalOptions.apiKey) {
|
||||
console.warn("[Amily2-Sybd外交部] API配置不完整,无法调用AI");
|
||||
toastr.error("API配置不完整,请检查URL、Key和模型配置。", "Sybd-外交部");
|
||||
toastr.error("术语表填写(SYBD)未配置 API 连接配置,请前往 API 连接配置面板分配 profile 或填写 SYBD 独立设置。", "Amily2-SYBD 未配置");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -159,9 +162,11 @@ export async function callSybdAI(messages, options = {}) {
|
||||
}
|
||||
|
||||
async function callSybdOpenAITest(messages, options) {
|
||||
const isGoogleApi = options.apiUrl.includes('googleapis.com');
|
||||
const isGoogleApi = (await detectVendor(options.apiUrl)) === 'google';
|
||||
|
||||
const body = {
|
||||
top_p: options.top_p || 1,
|
||||
...(options.customParams || {}),
|
||||
chat_completion_source: 'openai',
|
||||
messages: messages,
|
||||
model: options.model,
|
||||
@@ -170,7 +175,6 @@ async function callSybdOpenAITest(messages, options) {
|
||||
stream: false,
|
||||
max_tokens: options.maxTokens || 30000,
|
||||
temperature: options.temperature || 1,
|
||||
top_p: options.top_p || 1,
|
||||
};
|
||||
|
||||
if (!isGoogleApi) {
|
||||
@@ -244,7 +248,8 @@ async function callSybdSillyTavernPreset(messages, options) {
|
||||
responsePromise = context.ConnectionManagerRequestService.sendRequest(
|
||||
targetProfile.id,
|
||||
messages,
|
||||
options.maxTokens || 4000
|
||||
options.maxTokens || 4000,
|
||||
options.customParams || {}
|
||||
);
|
||||
|
||||
} finally {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { characters, this_chid, saveSettingsDebounced, getCharacters } from "/sc
|
||||
import { world_names } from "/scripts/world-info.js";
|
||||
import { getApiConfig, setApiConfig, testConnection, fetchModels } from "./api.js";
|
||||
import { tools } from "./tools.js";
|
||||
import { syncSlot } from "../../ui/profile-sync.js";
|
||||
|
||||
const extensionFolderPath = `scripts/extensions/third-party/${extensionName}`;
|
||||
|
||||
@@ -42,6 +43,7 @@ export async function openAutoCharCardWindow() {
|
||||
try {
|
||||
populateDropdowns();
|
||||
loadApiSettings();
|
||||
await syncSlot('autoCharCard');
|
||||
renderRulesList();
|
||||
renderSessionsList();
|
||||
restoreChatHistory();
|
||||
|
||||
@@ -15,7 +15,7 @@ import { compatibleWriteToLorebook } from "./tavernhelper-compatibility.js";
|
||||
import { ingestTextToHanlinyuan } from "./rag-processor.js";
|
||||
import { showSummaryModal, showHtmlModal } from "../ui/page-window.js";
|
||||
import { getPresetPrompts, getMixedOrder } from '../PresetSettings/index.js';
|
||||
import { callAI, generateRandomSeed } from "./api.js";
|
||||
import { generateRandomSeed } from "./api.js";
|
||||
import { callNgmsAI } from "./api/Ngms_api.js";
|
||||
import { executeAutoHide } from "./autoHideManager.js";
|
||||
import { resolveHistoriographyRuleConfig } from "../utils/config/RuleProfileManager.js";
|
||||
@@ -384,7 +384,9 @@ async function getSummary(formattedHistory, toastTitle, retryCount = 0) {
|
||||
}
|
||||
}
|
||||
|
||||
const summary = settings.ngmsEnabled ? await callNgmsAI(messages) : await callAI(messages);
|
||||
// 历史总结统一走 NGMS slot;ngms 未配置时 callNgmsAI 自带模块名错误提示。
|
||||
// 旧 ngmsEnabled 三元式 fallback 到 main 的设计已在主 API 移除后失效。
|
||||
const summary = await callNgmsAI(messages);
|
||||
console.log('[大史官-微言录] AI回复的全部内容:', summary);
|
||||
|
||||
if (!summary || !summary.trim()) {
|
||||
@@ -603,7 +605,8 @@ export async function executeRefinement(worldbook, loreKey) {
|
||||
|
||||
const getRefinedContent = async (retryCount = 0) => {
|
||||
toastr.info("正在召唤模型进行内容精炼...", "宏史卷重铸");
|
||||
const content = settings.ngmsEnabled ? await callNgmsAI(messages) : await callAI(messages);
|
||||
// 历史总结统一走 NGMS slot;ngms 未配置时 callNgmsAI 自带错误提示。
|
||||
const content = await callNgmsAI(messages);
|
||||
|
||||
if (!content || !content.trim()) {
|
||||
const maxRetries = settings.historiographyMaxRetries ?? 2;
|
||||
|
||||
@@ -9,20 +9,20 @@ import {
|
||||
buildGoogleEmbeddingApiUrl
|
||||
} from './utils/googleAdapter.js';
|
||||
import { getSlotProfile } from './api/api-resolver.js';
|
||||
import { extensionName } from '../utils/settings.js';
|
||||
|
||||
const MODULE_NAME = 'hanlinyuan-rag-core';
|
||||
const GOOGLE_API_BASE_URL = 'https://generativelanguage.googleapis.com';
|
||||
|
||||
function getSettings() {
|
||||
const context = SillyTavern.getContext();
|
||||
if (!context || !context.extensionSettings || !context.extensionSettings[MODULE_NAME]) {
|
||||
console.error('[翰林院-API] 无法获取设置,API调用可能失败。');
|
||||
return {
|
||||
retrieval: {},
|
||||
rerank: {}
|
||||
};
|
||||
}
|
||||
return context.extensionSettings[MODULE_NAME];
|
||||
const root = extension_settings[extensionName];
|
||||
const nested = root && root[MODULE_NAME];
|
||||
if (nested) return nested;
|
||||
// 读侧兼容:若迁移尚未触发(极早期调用),回退至旧顶层位置,避免空配置。
|
||||
const legacy = extension_settings[MODULE_NAME];
|
||||
if (legacy) return legacy;
|
||||
console.error('[翰林院-API] 无法获取设置,API调用可能失败。');
|
||||
return { retrieval: {}, rerank: {} };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,7 +33,7 @@ export async function getEmbedRetrievalSettings() {
|
||||
const profile = await getSlotProfile('ragEmbed');
|
||||
if (profile) {
|
||||
return {
|
||||
apiEndpoint: 'custom',
|
||||
apiEndpoint: profile.provider === 'google' ? 'google_direct' : 'custom',
|
||||
customApiUrl: profile.apiUrl,
|
||||
apiKey: profile.apiKey ?? '',
|
||||
embeddingModel: profile.model,
|
||||
|
||||
@@ -4,14 +4,17 @@ import {
|
||||
extension_prompt_roles,
|
||||
setExtensionPrompt,
|
||||
eventSource,
|
||||
event_types
|
||||
event_types,
|
||||
saveSettingsDebounced
|
||||
} from '/script.js';
|
||||
import { extension_settings } from '/scripts/extensions.js';
|
||||
|
||||
import * as ContextUtils from './utils/context-utils.js';
|
||||
import { getCollectionIdInfo, getCharacterId, getCharacterStableId } from './utils/context-utils.js';
|
||||
import { defaultSettings as ragDefaultSettings } from './rag-settings.js';
|
||||
import { extractBlocksByTags, applyExclusionRules } from './utils/rag-tag-extractor.js';
|
||||
import { resolveQueryPreprocessingRuleConfig } from '../utils/config/RuleProfileManager.js';
|
||||
import { extensionName } from '../utils/settings.js';
|
||||
import * as IngestionManager from './ingestion-manager.js';
|
||||
import {
|
||||
getEmbeddings,
|
||||
@@ -148,6 +151,7 @@ function initialize() {
|
||||
console.error('[翰林院] 未能获取SillyTavern上下文,初始化失败。');
|
||||
return;
|
||||
}
|
||||
migrateLegacyRagSettings();
|
||||
settings = getSettings();
|
||||
if (!window.hanlinyuanRagProcessor) {
|
||||
window.hanlinyuanRagProcessor = {};
|
||||
@@ -296,17 +300,16 @@ async function ingestTextToHanlinyuan(text, source = 'manual', metadata = {}, pr
|
||||
}
|
||||
|
||||
function getSettings() {
|
||||
if (!context || !context.extensionSettings) {
|
||||
|
||||
return structuredClone(ragDefaultSettings);
|
||||
if (!extension_settings[extensionName]) {
|
||||
extension_settings[extensionName] = {};
|
||||
}
|
||||
|
||||
const root = extension_settings[extensionName];
|
||||
|
||||
let s = context.extensionSettings[MODULE_NAME];
|
||||
let s = root[MODULE_NAME];
|
||||
|
||||
if (!s) {
|
||||
s = {};
|
||||
context.extensionSettings[MODULE_NAME] = s;
|
||||
root[MODULE_NAME] = s;
|
||||
}
|
||||
|
||||
if (s.condensationHistory === undefined) {
|
||||
@@ -343,16 +346,49 @@ function getSettings() {
|
||||
}
|
||||
|
||||
function saveSettings() {
|
||||
|
||||
if (context) context.saveSettingsDebounced();
|
||||
saveSettingsDebounced();
|
||||
}
|
||||
|
||||
function resetSettings() {
|
||||
|
||||
if (context) {
|
||||
context.extensionSettings[MODULE_NAME] = structuredClone(ragDefaultSettings);
|
||||
saveSettings();
|
||||
if (!extension_settings[extensionName]) {
|
||||
extension_settings[extensionName] = {};
|
||||
}
|
||||
extension_settings[extensionName][MODULE_NAME] = structuredClone(ragDefaultSettings);
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function migrateLegacyRagSettings() {
|
||||
const legacy = extension_settings[MODULE_NAME];
|
||||
if (!legacy || typeof legacy !== 'object') return;
|
||||
|
||||
if (!extension_settings[extensionName]) {
|
||||
extension_settings[extensionName] = {};
|
||||
}
|
||||
const root = extension_settings[extensionName];
|
||||
|
||||
// legacy 是用户此前实际交互过的真数据来源;nested 可能已被 super-memory 等模块用默认值填过,
|
||||
// 因此采用 legacy-优先的深合并:legacy 中的叶子值覆盖 nested,nested 中 legacy 没有的键保留。
|
||||
if (!root[MODULE_NAME] || typeof root[MODULE_NAME] !== 'object') {
|
||||
root[MODULE_NAME] = legacy;
|
||||
console.log(`[翰林院] 已迁移旧版 '${MODULE_NAME}' 设置到 extension_settings['${extensionName}']。`);
|
||||
} else {
|
||||
const merged = root[MODULE_NAME];
|
||||
const overlayLegacy = (src, dst) => {
|
||||
for (const key of Object.keys(src)) {
|
||||
const sv = src[key];
|
||||
if (sv && typeof sv === 'object' && !Array.isArray(sv) && dst[key] && typeof dst[key] === 'object' && !Array.isArray(dst[key])) {
|
||||
overlayLegacy(sv, dst[key]);
|
||||
} else {
|
||||
dst[key] = sv;
|
||||
}
|
||||
}
|
||||
};
|
||||
overlayLegacy(legacy, merged);
|
||||
console.log(`[翰林院] 发现新旧两处配置;已将顶层 '${MODULE_NAME}' 深合并覆盖到 extension_settings['${extensionName}']。`);
|
||||
}
|
||||
|
||||
delete extension_settings[MODULE_NAME];
|
||||
saveSettingsDebounced();
|
||||
}
|
||||
|
||||
function showNotification(message, type = 'info') {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getContext, extension_settings } from "/scripts/extensions.js";
|
||||
import { saveSettingsDebounced } from "/script.js";
|
||||
import { getCharacterStableId } from "../utils/context-utils.js";
|
||||
import { getMemoryState } from "../table-system/manager.js";
|
||||
import { extensionName } from "../../utils/settings.js";
|
||||
@@ -160,26 +161,62 @@ export function getRelatedNodes(nodeId, maxDepth = 1) {
|
||||
return related;
|
||||
}
|
||||
|
||||
function getGraphStore(create = false) {
|
||||
if (!extension_settings[extensionName]) {
|
||||
if (!create) return null;
|
||||
extension_settings[extensionName] = {};
|
||||
}
|
||||
const root = extension_settings[extensionName];
|
||||
if (!root.relationship_graphs) {
|
||||
if (!create) return null;
|
||||
root.relationship_graphs = {};
|
||||
}
|
||||
return root.relationship_graphs;
|
||||
}
|
||||
|
||||
function migrateLegacyRelationshipGraphs() {
|
||||
const legacy = extension_settings.relationship_graphs;
|
||||
if (!legacy || typeof legacy !== 'object') return;
|
||||
|
||||
if (!extension_settings[extensionName]) {
|
||||
extension_settings[extensionName] = {};
|
||||
}
|
||||
const root = extension_settings[extensionName];
|
||||
|
||||
if (!root.relationship_graphs) {
|
||||
root.relationship_graphs = legacy;
|
||||
console.log(`[关系图谱] 已迁移旧版 'relationship_graphs' 到 extension_settings['${extensionName}']。`);
|
||||
} else {
|
||||
console.log(`[关系图谱] 发现遗留顶层 'relationship_graphs',但新位置已存在;合并遗留数据并清理顶层。`);
|
||||
for (const [cid, data] of Object.entries(legacy)) {
|
||||
if (!root.relationship_graphs[cid]) {
|
||||
root.relationship_graphs[cid] = data;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
delete extension_settings.relationship_graphs;
|
||||
saveSettingsDebounced();
|
||||
}
|
||||
|
||||
export async function saveGraph() {
|
||||
const context = getContext();
|
||||
const charId = getCharacterStableId();
|
||||
if (!charId) return;
|
||||
|
||||
if (!context.extensionSettings.relationship_graphs) {
|
||||
context.extensionSettings.relationship_graphs = {};
|
||||
}
|
||||
|
||||
context.extensionSettings.relationship_graphs[charId] = graphData;
|
||||
context.saveSettingsDebounced();
|
||||
const store = getGraphStore(true);
|
||||
if (!store) return;
|
||||
|
||||
store[charId] = graphData;
|
||||
saveSettingsDebounced();
|
||||
}
|
||||
|
||||
export async function loadGraph() {
|
||||
const context = getContext();
|
||||
const charId = getCharacterStableId();
|
||||
if (!charId) return;
|
||||
|
||||
if (context.extensionSettings.relationship_graphs && context.extensionSettings.relationship_graphs[charId]) {
|
||||
graphData = context.extensionSettings.relationship_graphs[charId];
|
||||
const store = getGraphStore(false);
|
||||
if (store && store[charId]) {
|
||||
graphData = store[charId];
|
||||
console.log(`[关系图谱] 已加载角色 ${charId} 的图谱: ${graphData.nodes.length} 个节点, ${graphData.edges.length} 条边。`);
|
||||
} else {
|
||||
graphData = { nodes: [], edges: [] };
|
||||
@@ -188,6 +225,7 @@ export async function loadGraph() {
|
||||
|
||||
const context = getContext();
|
||||
if (context) {
|
||||
migrateLegacyRelationshipGraphs();
|
||||
loadGraph();
|
||||
document.addEventListener('AMILY2_TABLE_UPDATED', (e) => {
|
||||
const { tableName } = e.detail;
|
||||
|
||||
190
core/table-system/actions/applyOperations.js
Normal file
190
core/table-system/actions/applyOperations.js
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* @file Action: applyOperations —— 表格操作推演核心。
|
||||
*
|
||||
* 输入:基准 state + Operation[]
|
||||
* 输出:新 state(深拷贝)+ Change[] 变更记录
|
||||
*
|
||||
* 不依赖任何 formatter / store / persistence —— 纯函数。
|
||||
* 所有 formatter (legacy / json / toolcall) 解析完都吐 Operation[] 给本函数。
|
||||
*
|
||||
* 历史来源:从 executor.js 中 insertRow / updateRow / deleteRow 三个内部函数
|
||||
* 抽出,行为完全等价。executeCommands 改造为:parse 文本 → ops → 调本函数。
|
||||
*
|
||||
* 关键行为约定(不要随便改,否则破坏老存档):
|
||||
* - 入参 state 不被修改;返回的 state 是 JSON 深拷贝
|
||||
* - updateRow 的 rowIndex 越界 → 自动转换为 insertRow(历史智能修正)
|
||||
* - deleteRow 是延迟删除:rowStatuses[rowIndex] = 'pending-deletion',行不实际从 rows 中移除
|
||||
* - insertRow 的 changes 用 type='update'(每个被填的单元格一条),不要发明 'insert'
|
||||
*
|
||||
* @typedef {import('../dto/Table.js').TableState} TableState
|
||||
* @typedef {import('../dto/Operation.js').Operation} Operation
|
||||
* @typedef {import('../dto/Operation.js').InsertRowOperation} InsertRowOperation
|
||||
* @typedef {import('../dto/Operation.js').UpdateRowOperation} UpdateRowOperation
|
||||
* @typedef {import('../dto/Operation.js').DeleteRowOperation} DeleteRowOperation
|
||||
* @typedef {import('../dto/Change.js').Change} Change
|
||||
*/
|
||||
|
||||
import { log } from '../logger.js';
|
||||
|
||||
/**
|
||||
* 在表格末尾插入一行。in-place mutation(调用方已 clone)。
|
||||
* @param {TableState} state
|
||||
* @param {number} tableIndex
|
||||
* @param {Object<string, string>} data
|
||||
* @returns {{ state: TableState, changes: Change[] }}
|
||||
*/
|
||||
function _insertRow(state, tableIndex, data) {
|
||||
if (!state[tableIndex]) {
|
||||
log(`AI指令错误:尝试在不存在的表格索引 ${tableIndex} 中插入行。`, 'error');
|
||||
return { state, changes: [] };
|
||||
}
|
||||
|
||||
if (typeof data !== 'object' || data === null) {
|
||||
log(`AI指令错误:insertRow 的 data 参数必须是对象,实际收到: ${typeof data} (${data})`, 'error');
|
||||
return { state, changes: [] };
|
||||
}
|
||||
|
||||
const table = state[tableIndex];
|
||||
const colCount = table.headers.length;
|
||||
const newRow = Array(colCount).fill('');
|
||||
/** @type {Change[]} */
|
||||
const changes = [];
|
||||
const newRowIndex = table.rows.length;
|
||||
|
||||
for (const colIndex in data) {
|
||||
const cIndex = parseInt(colIndex, 10);
|
||||
if (cIndex < colCount) {
|
||||
newRow[cIndex] = data[colIndex];
|
||||
changes.push({ type: 'update', tableIndex, rowIndex: newRowIndex, colIndex: cIndex });
|
||||
}
|
||||
}
|
||||
table.rows.push(newRow);
|
||||
|
||||
// 同步更新 rowStatuses
|
||||
if (!table.rowStatuses) {
|
||||
table.rowStatuses = Array(table.rows.length - 1).fill('normal');
|
||||
}
|
||||
table.rowStatuses.push('normal');
|
||||
|
||||
return { state, changes };
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新指定行。in-place mutation。
|
||||
* 历史智能修正:rowIndex 越界自动降级为 insertRow。
|
||||
* @param {TableState} state
|
||||
* @param {number} tableIndex
|
||||
* @param {number} rowIndex
|
||||
* @param {Object<string, string>} data
|
||||
* @returns {{ state: TableState, changes: Change[] }}
|
||||
*/
|
||||
function _updateRow(state, tableIndex, rowIndex, data) {
|
||||
if (!state[tableIndex]) {
|
||||
log(`AI指令错误:尝试更新不存在的表格 ${tableIndex}。`, 'error');
|
||||
return { state, changes: [] };
|
||||
}
|
||||
|
||||
if (typeof data !== 'object' || data === null) {
|
||||
log(`AI指令错误:updateRow 的 data 参数必须是对象,实际收到: ${typeof data} (${data})`, 'error');
|
||||
return { state, changes: [] };
|
||||
}
|
||||
|
||||
const table = state[tableIndex];
|
||||
|
||||
if (rowIndex >= table.rows.length) {
|
||||
log(`AI指令修正:updateRow 的行索引 ${rowIndex} 超出范围,自动转换为 insertRow。`, 'warn');
|
||||
return _insertRow(state, tableIndex, data);
|
||||
}
|
||||
|
||||
const row = table.rows[rowIndex];
|
||||
/** @type {Change[]} */
|
||||
const changes = [];
|
||||
for (const colIndex in data) {
|
||||
const cIndex = parseInt(colIndex, 10);
|
||||
if (cIndex < row.length) {
|
||||
row[cIndex] = data[colIndex];
|
||||
changes.push({ type: 'update', tableIndex, rowIndex, colIndex: cIndex });
|
||||
}
|
||||
}
|
||||
return { state, changes };
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记指定行为待删除(延迟删除)。in-place mutation。
|
||||
* 不从 rows 实际移除;commitPendingDeletions 才会真正 splice。
|
||||
* @param {TableState} state
|
||||
* @param {number} tableIndex
|
||||
* @param {number} rowIndex
|
||||
* @returns {{ state: TableState, changes: Change[] }}
|
||||
*/
|
||||
function _deleteRow(state, tableIndex, rowIndex) {
|
||||
const table = state[tableIndex];
|
||||
if (!table || !table.rows[rowIndex]) {
|
||||
log(`AI指令错误:尝试删除不存在的表格 ${tableIndex} 或行 ${rowIndex}。`, 'error');
|
||||
return { state, changes: [] };
|
||||
}
|
||||
|
||||
if (!table.rowStatuses) {
|
||||
table.rowStatuses = Array(table.rows.length).fill('normal');
|
||||
}
|
||||
|
||||
if (table.rowStatuses[rowIndex] !== 'pending-deletion') {
|
||||
table.rowStatuses[rowIndex] = 'pending-deletion';
|
||||
/** @type {Change[]} */
|
||||
const changes = [{ type: 'delete', tableIndex, rowIndex }];
|
||||
return { state, changes };
|
||||
}
|
||||
|
||||
return { state, changes: [] };
|
||||
}
|
||||
|
||||
/** @type {Object<string, (state: TableState, op: Operation) => { state: TableState, changes: Change[] }>} */
|
||||
const HANDLERS = {
|
||||
insertRow: (state, op) => _insertRow(state, op.tableIndex, /** @type {InsertRowOperation} */(op).data),
|
||||
updateRow: (state, op) => _updateRow(state, op.tableIndex, /** @type {UpdateRowOperation} */(op).rowIndex, /** @type {UpdateRowOperation} */(op).data),
|
||||
deleteRow: (state, op) => _deleteRow(state, op.tableIndex, /** @type {DeleteRowOperation} */(op).rowIndex),
|
||||
};
|
||||
|
||||
/**
|
||||
* 把一组操作推演到 state 上。
|
||||
*
|
||||
* @param {TableState} initialState
|
||||
* @param {Operation[]} operations
|
||||
* @returns {{ state: TableState, changes: Change[] }}
|
||||
*/
|
||||
export function applyOperations(initialState, operations) {
|
||||
if (!Array.isArray(operations) || operations.length === 0) {
|
||||
return { state: initialState, changes: [] };
|
||||
}
|
||||
|
||||
let state = JSON.parse(JSON.stringify(initialState));
|
||||
/** @type {Change[]} */
|
||||
let allChanges = [];
|
||||
|
||||
for (const op of operations) {
|
||||
if (!op || typeof op !== 'object' || typeof op.op !== 'string') {
|
||||
log(`跳过非法操作: ${JSON.stringify(op)}`, 'warn');
|
||||
continue;
|
||||
}
|
||||
const handler = HANDLERS[op.op];
|
||||
if (!handler) {
|
||||
log(`未知操作类型: ${op.op}`, 'error');
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const result = handler(state, op);
|
||||
state = result.state;
|
||||
if (result.changes && result.changes.length > 0) {
|
||||
allChanges = allChanges.concat(result.changes);
|
||||
}
|
||||
const opLabel = op.op + '(' + op.tableIndex
|
||||
+ (typeof (/** @type {any} */(op)).rowIndex === 'number' ? `, ${(/** @type {any} */(op)).rowIndex}` : '')
|
||||
+ ')';
|
||||
log(`成功推演操作: ${opLabel}`, 'success');
|
||||
} catch (e) {
|
||||
log(`推演操作 ${op.op} 时发生运行时错误: ${e.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
return { state, changes: allChanges };
|
||||
}
|
||||
@@ -125,8 +125,8 @@ async function callTableModel(messages) {
|
||||
}
|
||||
return result;
|
||||
} else {
|
||||
log('使用默认 API 进行表格填充...', 'info');
|
||||
const result = await callAI(messages);
|
||||
log('使用 tableFilling slot 进行表格填充...', 'info');
|
||||
const result = await callAI(messages, { slot: 'tableFilling' });
|
||||
if (!result) {
|
||||
throw new Error('API返回内容为空。');
|
||||
}
|
||||
|
||||
21
core/table-system/dto/Change.js
Normal file
21
core/table-system/dto/Change.js
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* applyOperations 推演完成后吐出的变更记录。供高亮、SuperMemory 同步、UI 刷新使用。
|
||||
*
|
||||
* 注意 type 只有 'update' 和 'delete' 两种 —— insertRow 在 executor.js 历史实现里
|
||||
* 也吐 type='update'(每个被填的单元格一条),不要发明 'insert' type。
|
||||
*
|
||||
* @typedef {Object} UpdateChange
|
||||
* @property {'update'} type
|
||||
* @property {number} tableIndex
|
||||
* @property {number} rowIndex
|
||||
* @property {number} colIndex
|
||||
*
|
||||
* @typedef {Object} DeleteChange
|
||||
* @property {'delete'} type
|
||||
* @property {number} tableIndex
|
||||
* @property {number} rowIndex
|
||||
*
|
||||
* @typedef {UpdateChange | DeleteChange} Change
|
||||
*/
|
||||
|
||||
export {};
|
||||
26
core/table-system/dto/Operation.js
Normal file
26
core/table-system/dto/Operation.js
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* LLM 输出的统一动作格式。无论 formatter 是 legacy / json / toolcall,
|
||||
* 解析完都吐 Operation[],下游 applyOperations 不关心来源。
|
||||
*
|
||||
* data 字段的 key 是列索引的字符串形式('0', '1', ...),与 executor.js 历史行为对齐。
|
||||
*
|
||||
* @typedef {Object} InsertRowOperation
|
||||
* @property {'insertRow'} op
|
||||
* @property {number} tableIndex
|
||||
* @property {Object<string, string>} data { [colIndex]: cellValue }
|
||||
*
|
||||
* @typedef {Object} UpdateRowOperation
|
||||
* @property {'updateRow'} op
|
||||
* @property {number} tableIndex
|
||||
* @property {number} rowIndex
|
||||
* @property {Object<string, string>} data
|
||||
*
|
||||
* @typedef {Object} DeleteRowOperation
|
||||
* @property {'deleteRow'} op
|
||||
* @property {number} tableIndex
|
||||
* @property {number} rowIndex
|
||||
*
|
||||
* @typedef {InsertRowOperation | UpdateRowOperation | DeleteRowOperation} Operation
|
||||
*/
|
||||
|
||||
export {};
|
||||
38
core/table-system/dto/Table.js
Normal file
38
core/table-system/dto/Table.js
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* @file 表格相关数据形状(DTO)
|
||||
* 对应运行时存于 message.extra.amily2_tables_data 的结构。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 单元格内容;空值约定为空串而非 null/undefined。
|
||||
* @typedef {string} Cell
|
||||
*/
|
||||
|
||||
/**
|
||||
* 行状态。'pending-deletion' 表示已标记待删除(延迟删除机制)。
|
||||
* @typedef {'normal' | 'pending-deletion'} RowStatus
|
||||
*/
|
||||
|
||||
/**
|
||||
* 单张表格。
|
||||
* @typedef {Object} Table
|
||||
* @property {string} name 表格名(唯一标识 + UI 显示名)
|
||||
* @property {string[]} headers 列头数组,长度 = 列数
|
||||
* @property {Cell[][]} rows 行数据,二维数组,rows[i].length = headers.length
|
||||
* @property {RowStatus[]} [rowStatuses] 行状态数组,与 rows 等长
|
||||
* @property {(number|null)[]} [columnWidths] 列宽数组(UI 用),与 headers 等长,null 表示自适应
|
||||
* @property {string} [note] 表格说明
|
||||
* @property {string} [rule_add] 添加行规则(自然语言)
|
||||
* @property {string} [rule_delete] 删除行规则
|
||||
* @property {string} [rule_update] 更新行规则
|
||||
* @property {Object<string, number>} [charLimitRules] 多列字符限制:{ "colIndexStr": maxChars }
|
||||
* @property {number} [rowLimitRule] 行数上限,0 表示不限
|
||||
* @property {number} [simplifyRowThreshold] 历史行简化阈值,0 表示不简化
|
||||
*/
|
||||
|
||||
/**
|
||||
* 表格集合 = 全局状态。
|
||||
* @typedef {Table[]} TableState
|
||||
*/
|
||||
|
||||
export {};
|
||||
9
core/table-system/dto/TableState.js
Normal file
9
core/table-system/dto/TableState.js
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* @file TableState 的实际定义已合并至 ./Table.js(与 Table 共处一处便于阅读)。
|
||||
* 本文件保留为转发别名,供需要按 dto 名称单独导入的消费方使用:
|
||||
* /** @typedef {import('./TableState.js').TableState} TableState *\/
|
||||
*
|
||||
* @typedef {import('./Table.js').TableState} TableState
|
||||
*/
|
||||
|
||||
export {};
|
||||
@@ -1,100 +1,31 @@
|
||||
/**
|
||||
* @file 旧版 <Amily2Edit> 文本格式的解析器 + executeCommands 入口。
|
||||
*
|
||||
* Phase 0 重构后职责收窄:
|
||||
* - 仅负责把 LLM 返回的文本块解析成 Operation[](legacy formatter 角色)
|
||||
* - 推演下推到 actions/applyOperations.js,本文件不再持有 insertRow/updateRow/deleteRow 实现
|
||||
*
|
||||
* 对外 API:
|
||||
* - parseToOperations(text) : 纯解析,文本 → Op[](Phase B legacy formatter 直接复用)
|
||||
* - executeCommands(text, state) : 解析 + 推演,返回历史 shape { finalState, hasChanges, changes }
|
||||
*
|
||||
* 等 Phase B 引入 formatters/ 目录后,本文件改名为 formatters/legacy.js。
|
||||
*
|
||||
* @typedef {import('./dto/Operation.js').Operation} Operation
|
||||
* @typedef {import('./dto/Table.js').TableState} TableState
|
||||
*/
|
||||
|
||||
import { log } from './logger.js';
|
||||
import { applyOperations } from './actions/applyOperations.js';
|
||||
|
||||
function insertRow(state, tableIndex, data) {
|
||||
if (!state[tableIndex]) {
|
||||
log(`AI指令错误:尝试在不存在的表格索引 ${tableIndex} 中插入行。`, 'error');
|
||||
return { state, changes: [] };
|
||||
}
|
||||
|
||||
// 【安全检查】确保 data 是对象
|
||||
if (typeof data !== 'object' || data === null) {
|
||||
log(`AI指令错误:insertRow 的 data 参数必须是对象,实际收到: ${typeof data} (${data})`, 'error');
|
||||
return { state, changes: [] };
|
||||
}
|
||||
|
||||
const table = state[tableIndex];
|
||||
const colCount = table.headers.length;
|
||||
const newRow = Array(colCount).fill('');
|
||||
const changes = [];
|
||||
const newRowIndex = table.rows.length;
|
||||
|
||||
for (const colIndex in data) {
|
||||
const cIndex = parseInt(colIndex, 10);
|
||||
if (cIndex < colCount) {
|
||||
newRow[cIndex] = data[colIndex];
|
||||
changes.push({ type: 'update', tableIndex, rowIndex: newRowIndex, colIndex: cIndex });
|
||||
}
|
||||
}
|
||||
table.rows.push(newRow);
|
||||
|
||||
// 同步更新 rowStatuses
|
||||
if (!table.rowStatuses) {
|
||||
table.rowStatuses = Array(table.rows.length - 1).fill('normal');
|
||||
}
|
||||
table.rowStatuses.push('normal');
|
||||
|
||||
return { state, changes };
|
||||
}
|
||||
|
||||
function updateRow(state, tableIndex, rowIndex, data) {
|
||||
if (!state[tableIndex]) {
|
||||
log(`AI指令错误:尝试更新不存在的表格 ${tableIndex}。`, 'error');
|
||||
return { state, changes: [] };
|
||||
}
|
||||
|
||||
// 【安全检查】确保 data 是对象
|
||||
if (typeof data !== 'object' || data === null) {
|
||||
log(`AI指令错误:updateRow 的 data 参数必须是对象,实际收到: ${typeof data} (${data})`, 'error');
|
||||
return { state, changes: [] };
|
||||
}
|
||||
|
||||
const table = state[tableIndex];
|
||||
|
||||
if (rowIndex >= table.rows.length) {
|
||||
log(`AI指令修正:updateRow 的行索引 ${rowIndex} 超出范围,自动转换为 insertRow。`, 'warn');
|
||||
return insertRow(state, tableIndex, data);
|
||||
}
|
||||
|
||||
const row = table.rows[rowIndex];
|
||||
const changes = [];
|
||||
for (const colIndex in data) {
|
||||
const cIndex = parseInt(colIndex, 10);
|
||||
if (cIndex < row.length) {
|
||||
row[cIndex] = data[colIndex];
|
||||
changes.push({ type: 'update', tableIndex, rowIndex, colIndex: cIndex });
|
||||
}
|
||||
}
|
||||
return { state, changes };
|
||||
}
|
||||
|
||||
|
||||
function deleteRow(state, tableIndex, rowIndex) {
|
||||
const table = state[tableIndex];
|
||||
if (!table || !table.rows[rowIndex]) {
|
||||
log(`AI指令错误:尝试删除不存在的表格 ${tableIndex} 或行 ${rowIndex}。`, 'error');
|
||||
return { state, changes: [] };
|
||||
}
|
||||
|
||||
if (!table.rowStatuses) {
|
||||
table.rowStatuses = Array(table.rows.length).fill('normal');
|
||||
}
|
||||
|
||||
if (table.rowStatuses[rowIndex] !== 'pending-deletion') {
|
||||
table.rowStatuses[rowIndex] = 'pending-deletion';
|
||||
const changes = [{ type: 'delete', tableIndex, rowIndex }];
|
||||
return { state, changes };
|
||||
}
|
||||
|
||||
return { state, changes: [] };
|
||||
}
|
||||
|
||||
|
||||
const allowedFunctions = {
|
||||
insertRow,
|
||||
updateRow,
|
||||
deleteRow,
|
||||
};
|
||||
const ALLOWED_FN_NAMES = new Set(['insertRow', 'updateRow', 'deleteRow']);
|
||||
|
||||
/**
|
||||
* 把单行函数调用文本解析为 { name, args } 中间表示。
|
||||
* 内部用,不导出。args 是位置参数数组,待 _argsToOperation 转成 Operation 对象。
|
||||
* @param {string} callString
|
||||
* @returns {{ name: string, args: any[] } | null}
|
||||
*/
|
||||
function parseFunctionCall(callString) {
|
||||
const match = callString.trim().match(/(\w+)\((.*)\)/);
|
||||
if (!match) {
|
||||
@@ -105,7 +36,7 @@ function parseFunctionCall(callString) {
|
||||
const functionName = match[1];
|
||||
const argsString = match[2];
|
||||
|
||||
if (!allowedFunctions[functionName]) {
|
||||
if (!ALLOWED_FN_NAMES.has(functionName)) {
|
||||
log(`检测到非法函数调用: "${functionName}"。已阻止执行。`, 'error');
|
||||
return null;
|
||||
}
|
||||
@@ -116,11 +47,11 @@ function parseFunctionCall(callString) {
|
||||
let currentArg = '';
|
||||
let inQuote = false;
|
||||
let quoteChar = '';
|
||||
let braceDepth = 0;
|
||||
|
||||
let braceDepth = 0;
|
||||
|
||||
for (let i = 0; i < argsString.length; i++) {
|
||||
const char = argsString[i];
|
||||
|
||||
|
||||
if ((char === '"' || char === "'") && (i === 0 || argsString[i-1] !== '\\')) {
|
||||
if (!inQuote) {
|
||||
inQuote = true;
|
||||
@@ -164,7 +95,7 @@ function parseValue(val) {
|
||||
if (val === 'null') return null;
|
||||
if (val === 'undefined') return undefined;
|
||||
if (!isNaN(Number(val)) && val !== '') return Number(val);
|
||||
|
||||
|
||||
if (val.startsWith('"') && val.endsWith('"')) {
|
||||
try { return JSON.parse(val); } catch (e) { return val.slice(1, -1); }
|
||||
}
|
||||
@@ -203,14 +134,14 @@ function parseValue(val) {
|
||||
|
||||
function tryParseObject(str) {
|
||||
if (!str.startsWith('{') || !str.endsWith('}')) return null;
|
||||
|
||||
|
||||
let content = str.slice(1, -1);
|
||||
const result = {};
|
||||
let hasMatch = false;
|
||||
|
||||
|
||||
const strings = [];
|
||||
let placeholderIndex = 0;
|
||||
|
||||
|
||||
// 提取字符串并替换为占位符,避免正则在字符串内部匹配
|
||||
const stringRegex = /"([^"\\]|\\.)*"|'([^'\\]|\\.)*'/g;
|
||||
content = content.replace(stringRegex, (match) => {
|
||||
@@ -219,36 +150,36 @@ function tryParseObject(str) {
|
||||
placeholderIndex++;
|
||||
return placeholder;
|
||||
});
|
||||
|
||||
|
||||
// 匹配键:(开头或逗号/分号/冒号) + (数字 或 字母数字下划线 或 占位符) + 冒号
|
||||
const keyRegex = /(?:^|[,;:]+\s*)(?:(\d+)|([a-zA-Z0-9_]+)|(__STR_\d+__))\s*:/g;
|
||||
|
||||
|
||||
let match;
|
||||
let lastIndex = 0;
|
||||
let lastKey = null;
|
||||
|
||||
|
||||
while ((match = keyRegex.exec(content)) !== null) {
|
||||
hasMatch = true;
|
||||
if (lastKey !== null) {
|
||||
let valStr = content.slice(lastIndex, match.index).trim();
|
||||
valStr = valStr.replace(/[,;:]+$/, '').trim();
|
||||
|
||||
|
||||
let actualKey = restoreStrings(lastKey, strings);
|
||||
result[actualKey] = restoreStrings(valStr, strings);
|
||||
}
|
||||
|
||||
|
||||
lastKey = match[1] || match[2] || match[3];
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
|
||||
if (lastKey !== null) {
|
||||
let valStr = content.slice(lastIndex).trim();
|
||||
valStr = valStr.replace(/[,;:]+$/, '').trim();
|
||||
|
||||
|
||||
let actualKey = restoreStrings(lastKey, strings);
|
||||
result[actualKey] = restoreStrings(valStr, strings);
|
||||
}
|
||||
|
||||
|
||||
return hasMatch ? result : null;
|
||||
}
|
||||
|
||||
@@ -269,51 +200,77 @@ function cleanValueStr(str) {
|
||||
return str;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 parseFunctionCall 返回的位置参数数组转成 Operation 对象。
|
||||
* @param {string} name
|
||||
* @param {any[]} args
|
||||
* @returns {Operation | null}
|
||||
*/
|
||||
function _argsToOperation(name, args) {
|
||||
if (name === 'insertRow') {
|
||||
return /** @type {Operation} */ ({ op: 'insertRow', tableIndex: args[0], data: args[1] });
|
||||
}
|
||||
if (name === 'updateRow') {
|
||||
return /** @type {Operation} */ ({ op: 'updateRow', tableIndex: args[0], rowIndex: args[1], data: args[2] });
|
||||
}
|
||||
if (name === 'deleteRow') {
|
||||
return /** @type {Operation} */ ({ op: 'deleteRow', tableIndex: args[0], rowIndex: args[1] });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function executeCommands(aiResponseText, initialState) {
|
||||
/**
|
||||
* 把 LLM 返回的文本块解析为 Operation[]。
|
||||
* 不在文本中找到 <Amily2Edit> 块时返回空数组(不视为错误)。
|
||||
*
|
||||
* @param {string} aiResponseText
|
||||
* @returns {Operation[]}
|
||||
*/
|
||||
export function parseToOperations(aiResponseText) {
|
||||
const commandBlockRegex = /<Amily2Edit>([\s\S]*?)<\/Amily2Edit>/;
|
||||
const match = aiResponseText.match(commandBlockRegex);
|
||||
const match = (aiResponseText || '').match(commandBlockRegex);
|
||||
if (!match) return [];
|
||||
|
||||
if (!match) {
|
||||
return { finalState: initialState, hasChanges: false, changes: [] };
|
||||
}
|
||||
|
||||
log('检测到AI指令块,开始推演...', 'info');
|
||||
const commandBlock = match[1].replace(/<!--|-->/g, '').trim();
|
||||
if (!commandBlock) {
|
||||
return { finalState: initialState, hasChanges: false, changes: [] };
|
||||
}
|
||||
if (!commandBlock) return [];
|
||||
|
||||
const commands = commandBlock.split('\n').filter(line => line.trim() !== '');
|
||||
if (commands.length === 0) {
|
||||
if (commands.length === 0) return [];
|
||||
|
||||
/** @type {Operation[]} */
|
||||
const ops = [];
|
||||
for (const commandString of commands) {
|
||||
const trimmed = commandString.trim();
|
||||
if (!trimmed.startsWith('insertRow(') &&
|
||||
!trimmed.startsWith('updateRow(') &&
|
||||
!trimmed.startsWith('deleteRow(')) {
|
||||
continue;
|
||||
}
|
||||
const parsed = parseFunctionCall(trimmed);
|
||||
if (!parsed) continue;
|
||||
const op = _argsToOperation(parsed.name, parsed.args);
|
||||
if (op) ops.push(op);
|
||||
}
|
||||
return ops;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 LLM 文本指令并推演到 state 上。
|
||||
* 历史 API,调用方期望返回 { finalState, hasChanges, changes }。
|
||||
*
|
||||
* @param {string} aiResponseText
|
||||
* @param {TableState} initialState
|
||||
* @returns {{ finalState: TableState, hasChanges: boolean, changes: import('./dto/Change.js').Change[] }}
|
||||
*/
|
||||
export function executeCommands(aiResponseText, initialState) {
|
||||
const ops = parseToOperations(aiResponseText);
|
||||
|
||||
if (ops.length === 0) {
|
||||
return { finalState: initialState, hasChanges: false, changes: [] };
|
||||
}
|
||||
|
||||
let currentState = JSON.parse(JSON.stringify(initialState));
|
||||
let allChanges = [];
|
||||
log(`检测到 ${ops.length} 条 AI 指令,开始推演...`, 'info');
|
||||
|
||||
commands.forEach(commandString => {
|
||||
const trimmedCommand = commandString.trim();
|
||||
if (trimmedCommand.startsWith('insertRow(') ||
|
||||
trimmedCommand.startsWith('deleteRow(') ||
|
||||
trimmedCommand.startsWith('updateRow('))
|
||||
{
|
||||
const parsed = parseFunctionCall(trimmedCommand);
|
||||
if (parsed) {
|
||||
try {
|
||||
const result = allowedFunctions[parsed.name](currentState, ...parsed.args);
|
||||
currentState = result.state;
|
||||
if (result.changes && result.changes.length > 0) {
|
||||
allChanges = allChanges.concat(result.changes);
|
||||
}
|
||||
log(`成功推演指令: ${commandString}`, 'success');
|
||||
} catch (e) {
|
||||
log(`推演指令 "${commandString}" 时发生运行时错误: ${e.message}`, 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const hasChanges = allChanges.length > 0;
|
||||
return { finalState: currentState, hasChanges, changes: allChanges };
|
||||
const { state, changes } = applyOperations(initialState, ops);
|
||||
return { finalState: state, hasChanges: changes.length > 0, changes };
|
||||
}
|
||||
|
||||
98
core/table-system/infra/persistence.js
Normal file
98
core/table-system/infra/persistence.js
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* @file ITablePersistence 实现 —— 表格状态的持久化层。
|
||||
*
|
||||
* 替代 manager.js 中:
|
||||
* - saveStateToMessage(state, targetMessage) → 写入指定消息的 extra
|
||||
* - 16 处复制样板(getContext + saveStateToMessage + saveChat / saveChatDebounced)
|
||||
* 被合并为 commitToLastMessage / commitToLastMessageAsync 两个函数
|
||||
*
|
||||
* 不读取 store;调用方显式传入要持久化的 state。这样:
|
||||
* - 测试容易(不依赖全局单例)
|
||||
* - 万一未来需要在事务边界提交"快照"而非当前 state,接口已就位
|
||||
*
|
||||
* @typedef {import('../dto/Table.js').TableState} TableState
|
||||
*/
|
||||
|
||||
import { saveChat } from '/script.js';
|
||||
import { getContext } from '/scripts/extensions.js';
|
||||
import { saveChatDebounced } from '../../../utils/utils.js';
|
||||
import { log } from '../logger.js';
|
||||
|
||||
/**
|
||||
* message.extra 中存储表格状态的 key。
|
||||
* 此值不能轻易改 —— 所有历史聊天的存档都用这个 key。
|
||||
*/
|
||||
export const TABLE_DATA_KEY = 'amily2_tables_data';
|
||||
|
||||
/**
|
||||
* 把状态深拷贝写入指定消息的 metadata。
|
||||
* 不主动调用 saveChat —— 写盘时机由调用方决定。
|
||||
*
|
||||
* @param {TableState | null} stateToSave
|
||||
* @param {Object} targetMessage
|
||||
* @returns {boolean} 是否写入成功
|
||||
*/
|
||||
export function saveStateToMessage(stateToSave, targetMessage) {
|
||||
if (!stateToSave || !targetMessage) {
|
||||
log('缺少状态或目标消息,无法保存。', 'error');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!targetMessage.extra) {
|
||||
targetMessage.extra = {};
|
||||
}
|
||||
|
||||
targetMessage.extra[TABLE_DATA_KEY] = JSON.parse(JSON.stringify(stateToSave));
|
||||
log(`表格状态已准备写入消息 [${targetMessage.mes.substring(0, 20)}...]`, 'info');
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 state 提交到 chat 最新一条消息并立即 saveChat。
|
||||
*
|
||||
* 该函数封装了 manager.js 中复制了 16 次的样板:
|
||||
* const context = getContext();
|
||||
* if (context.chat && context.chat.length > 0) {
|
||||
* const lastMessage = context.chat[context.chat.length - 1];
|
||||
* if (saveStateToMessage(state, lastMessage)) {
|
||||
* saveChat();
|
||||
* return;
|
||||
* }
|
||||
* }
|
||||
* saveChatDebounced();
|
||||
*
|
||||
* @param {TableState | null} state
|
||||
* @returns {boolean} true = 走 last-message commit 路径;false = 降级到 debounced
|
||||
*/
|
||||
export function commitToLastMessage(state) {
|
||||
const context = getContext();
|
||||
if (context.chat && context.chat.length > 0) {
|
||||
const lastMessage = context.chat[context.chat.length - 1];
|
||||
if (saveStateToMessage(state, lastMessage)) {
|
||||
saveChat();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
saveChatDebounced();
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* commitToLastMessage 的 async 变体。
|
||||
* deleteRow / restoreRow / rollbackState 等需要等 saveChat 完成后才做后续渲染的场景使用。
|
||||
*
|
||||
* @param {TableState | null} state
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export async function commitToLastMessageAsync(state) {
|
||||
const context = getContext();
|
||||
if (context.chat && context.chat.length > 0) {
|
||||
const lastMessage = context.chat[context.chat.length - 1];
|
||||
if (saveStateToMessage(state, lastMessage)) {
|
||||
await saveChat();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
await saveChatDebounced();
|
||||
return false;
|
||||
}
|
||||
117
core/table-system/infra/store.js
Normal file
117
core/table-system/infra/store.js
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* @file ITableStore 实现 —— 表格运行时状态的唯一所有者。
|
||||
*
|
||||
* 替代 manager.js 中三个 module-level 可变量:
|
||||
* currentTablesState → 通过 getState/setState 访问
|
||||
* highlightedCells → addHighlight/getHighlights/clearHighlights
|
||||
* updatedTables → markTableUpdated/getUpdatedTables/clearUpdatedTables
|
||||
*
|
||||
* 本模块只承担"存",不触发任何副作用(不保存、不渲染、不发事件总线消息)。
|
||||
* 副作用编排留给 Service 层 / Action 层。
|
||||
*
|
||||
* setState 会触发 subscribe 注册的回调,给 UI / SuperMemory 一个钩子,
|
||||
* 但不直接 import UI(保持 domain 纯度)。
|
||||
*
|
||||
* @typedef {import('../dto/Table.js').TableState} TableState
|
||||
*/
|
||||
|
||||
import { log } from '../logger.js';
|
||||
|
||||
/** @type {TableState | null} */
|
||||
let _state = null;
|
||||
|
||||
/** @type {Set<string>} 形如 "tableIndex-rowIndex-colIndex" */
|
||||
const _highlights = new Set();
|
||||
|
||||
/** @type {Set<number>} 标记本周期内被改过的表格索引 */
|
||||
const _updatedTables = new Set();
|
||||
|
||||
/** @type {Set<(state: TableState | null) => void>} */
|
||||
const _listeners = new Set();
|
||||
|
||||
// ── 主状态 ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @returns {TableState | null}
|
||||
*/
|
||||
export function getState() {
|
||||
return _state;
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接替换全局状态。注意:不做深拷贝,调用方需自己负责传入的 state 不被外部 mutate。
|
||||
* @param {TableState | null} newState
|
||||
*/
|
||||
export function setState(newState) {
|
||||
_state = newState;
|
||||
_notify();
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅 setState 触发的变更通知。返回取消订阅函数。
|
||||
* 仅在 setState 被调用时触发;mutate 同一引用不会触发。
|
||||
* @param {(state: TableState | null) => void} listener
|
||||
* @returns {() => void}
|
||||
*/
|
||||
export function subscribe(listener) {
|
||||
_listeners.add(listener);
|
||||
return () => _listeners.delete(listener);
|
||||
}
|
||||
|
||||
function _notify() {
|
||||
for (const l of _listeners) {
|
||||
try {
|
||||
l(_state);
|
||||
} catch (e) {
|
||||
console.error('[TableStore] listener error:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 单元格高亮 ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @param {number} tableIndex
|
||||
* @param {number} rowIndex
|
||||
* @param {number} colIndex
|
||||
*/
|
||||
export function addHighlight(tableIndex, rowIndex, colIndex) {
|
||||
_highlights.add(`${tableIndex}-${rowIndex}-${colIndex}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Set<string>}
|
||||
*/
|
||||
export function getHighlights() {
|
||||
return _highlights;
|
||||
}
|
||||
|
||||
export function clearHighlights() {
|
||||
if (_highlights.size > 0) {
|
||||
_highlights.clear();
|
||||
log('已清除所有单元格高亮标记。', 'info');
|
||||
}
|
||||
}
|
||||
|
||||
// ── 更新过的表格标记 ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @param {number} tableIndex
|
||||
*/
|
||||
export function markTableUpdated(tableIndex) {
|
||||
_updatedTables.add(tableIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Set<number>}
|
||||
*/
|
||||
export function getUpdatedTables() {
|
||||
return _updatedTables;
|
||||
}
|
||||
|
||||
export function clearUpdatedTables() {
|
||||
if (_updatedTables.size > 0) {
|
||||
_updatedTables.clear();
|
||||
log('已清除所有表格的更新标记。', 'info');
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
329
core/table-system/preset.js
Normal file
329
core/table-system/preset.js
Normal file
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* @file 表格预设的导入 / 导出 / 全局预设管理。
|
||||
*
|
||||
* 历史来源:从 manager.js 抽出
|
||||
* - exportPreset / exportPresetFull → 调内部 exportPresetBase
|
||||
* - importPreset → 接受 hooks 注入 SuperMemory 同步等副作用
|
||||
* - clearGlobalPreset → 清除 extension_settings 中的全局预设
|
||||
* - importGlobalPreset → 写入全局预设
|
||||
*
|
||||
* 设计要点:
|
||||
* - 不内含 SuperMemory dispatch 逻辑(避免与 manager.js 循环依赖)
|
||||
* - importPreset 接受 hooks: { onAfterApply, onImported },调用方注入需要的副作用
|
||||
* - 所有持久化走 infra/persistence.js,不再复制 saveStateToMessage 样板
|
||||
*/
|
||||
|
||||
import { extension_settings, getContext } from '/scripts/extensions.js';
|
||||
import { saveSettingsDebounced } from '/script.js';
|
||||
import { extensionName } from '../../utils/settings.js';
|
||||
import { log } from './logger.js';
|
||||
import { getState, setState } from './infra/store.js';
|
||||
import { saveStateToMessage, commitToLastMessage } from './infra/persistence.js';
|
||||
import {
|
||||
getBatchFillerRuleTemplate,
|
||||
getBatchFillerFlowTemplate,
|
||||
saveBatchFillerRuleTemplate,
|
||||
saveBatchFillerFlowTemplate,
|
||||
saveAiTemplate,
|
||||
} from './templates.js';
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* onAfterApply?: () => void,
|
||||
* onImported?: () => void
|
||||
* }} ImportPresetHooks
|
||||
*/
|
||||
|
||||
// ── 导出 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @param {boolean} includeData 是否包含 rows 实际数据
|
||||
*/
|
||||
function exportPresetBase(includeData = false) {
|
||||
const state = getState();
|
||||
if (!state) {
|
||||
log('无法导出:当前表格状态为空。', 'error');
|
||||
toastr.error('没有可导出的表格数据。');
|
||||
return;
|
||||
}
|
||||
|
||||
let tablesToExport;
|
||||
let fileNameSuffix;
|
||||
|
||||
if (includeData) {
|
||||
// 完整备份
|
||||
tablesToExport = JSON.parse(JSON.stringify(state));
|
||||
fileNameSuffix = '完整备份';
|
||||
} else {
|
||||
// 纯净预设:仅结构 + 规则,不带数据
|
||||
tablesToExport = state.map(table => ({
|
||||
name: table.name,
|
||||
headers: table.headers,
|
||||
columnWidths: table.columnWidths || [],
|
||||
note: table.note,
|
||||
rule_add: table.rule_add,
|
||||
rule_delete: table.rule_delete,
|
||||
rule_update: table.rule_update,
|
||||
charLimitRules: table.charLimitRules || {},
|
||||
rowLimitRule: table.rowLimitRule || 0,
|
||||
// simplifyRowThreshold 不导出:与当前聊天进度强绑定的临时设置
|
||||
rows: [],
|
||||
rowStatuses: [],
|
||||
}));
|
||||
fileNameSuffix = '纯净预设';
|
||||
}
|
||||
|
||||
const preset = {
|
||||
version: 'Amily2-Table-Preset-v3.0-separated_templates',
|
||||
batchFillerRuleTemplate: getBatchFillerRuleTemplate(),
|
||||
batchFillerFlowTemplate: getBatchFillerFlowTemplate(),
|
||||
tables: tablesToExport,
|
||||
};
|
||||
|
||||
const blob = new Blob([JSON.stringify(preset, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `Amily2-${fileNameSuffix}-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
log(`【${fileNameSuffix}】已成功导出。`, 'success');
|
||||
toastr.success(`【${fileNameSuffix}】已开始下载。`, '导出成功');
|
||||
}
|
||||
|
||||
export function exportPreset() {
|
||||
exportPresetBase(false);
|
||||
}
|
||||
|
||||
export function exportPresetFull() {
|
||||
exportPresetBase(true);
|
||||
}
|
||||
|
||||
// ── 导入 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 把导入的 tables 数组归一化(补字段 + 兼容旧版结构)。in-place mutation。
|
||||
*/
|
||||
function _normalizeImportedTables(importedTables) {
|
||||
importedTables.forEach(table => {
|
||||
if (table.name === undefined || table.headers === undefined || table.rows === undefined) {
|
||||
throw new Error(`导入的表格数据格式不正确: ${JSON.stringify(table)}`);
|
||||
}
|
||||
if (table.note === undefined) table.note = '无';
|
||||
if (table.rule_add === undefined) table.rule_add = '允许';
|
||||
if (table.rule_delete === undefined) table.rule_delete = '允许';
|
||||
if (table.rule_update === undefined) table.rule_update = '允许';
|
||||
|
||||
// 多列规则兼容:旧 charLimitRule 单列对象 → 新 charLimitRules 对象映射
|
||||
if (table.charLimitRule && !table.charLimitRules) {
|
||||
table.charLimitRules = {};
|
||||
if (table.charLimitRule.columnIndex !== -1 && table.charLimitRule.limit > 0) {
|
||||
table.charLimitRules[table.charLimitRule.columnIndex] = table.charLimitRule.limit;
|
||||
}
|
||||
} else if (table.charLimitRules === undefined) {
|
||||
table.charLimitRules = {};
|
||||
}
|
||||
delete table.charLimitRule;
|
||||
|
||||
// 延迟删除:rowStatuses 必须存在
|
||||
if (!table.rowStatuses) {
|
||||
table.rowStatuses = Array(table.rows.length).fill('normal');
|
||||
}
|
||||
if (table.rowLimitRule === undefined) table.rowLimitRule = 0;
|
||||
if (table.columnWidths === undefined) table.columnWidths = [];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 把导入的预设里的模板字段写回 extension_settings。版本兼容三档:
|
||||
* v3.0(separated) / v2.1(aiRule+aiFlow) / v2.0(aiTemplate)
|
||||
*/
|
||||
function _applyImportedTemplates(preset) {
|
||||
if (preset.version === 'Amily2-Table-Preset-v3.0-separated_templates') {
|
||||
saveBatchFillerRuleTemplate(preset.batchFillerRuleTemplate || '');
|
||||
saveBatchFillerFlowTemplate(preset.batchFillerFlowTemplate || '');
|
||||
saveAiTemplate(preset.injectionFlowTemplate || '');
|
||||
} else if (preset.aiRuleTemplate !== undefined && preset.aiFlowTemplate !== undefined) {
|
||||
saveBatchFillerRuleTemplate(preset.aiRuleTemplate || '');
|
||||
saveBatchFillerFlowTemplate(preset.aiFlowTemplate || '');
|
||||
saveAiTemplate(preset.aiFlowTemplate || '');
|
||||
} else if (preset.aiTemplate) {
|
||||
saveBatchFillerRuleTemplate('');
|
||||
saveBatchFillerFlowTemplate(preset.aiTemplate || '');
|
||||
saveAiTemplate(preset.aiTemplate || '');
|
||||
} else {
|
||||
log('导入的预设中缺少指令模板字段,模板将不会被更新。', 'warn');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 弹出文件选择 → 解析 JSON → 归一化 → 写入 store + 持久化。
|
||||
*
|
||||
* hooks.onAfterApply 在 setState 之后、saveChat 之前触发(用于注入 SuperMemory 同步等副作用)。
|
||||
* hooks.onImported 在全部完成后触发(UI 刷新)。
|
||||
*
|
||||
* @param {ImportPresetHooks | (() => void)} [hooksOrCallback] 兼容旧签名 importPreset(callback)
|
||||
*/
|
||||
export function importPreset(hooksOrCallback) {
|
||||
/** @type {ImportPresetHooks} */
|
||||
const hooks = typeof hooksOrCallback === 'function'
|
||||
? { onImported: hooksOrCallback }
|
||||
: (hooksOrCallback || {});
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.json';
|
||||
|
||||
input.onchange = e => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = event => {
|
||||
try {
|
||||
const preset = JSON.parse(event.target.result);
|
||||
|
||||
if (!preset.version || !Array.isArray(preset.tables)) {
|
||||
throw new Error('文件格式无效或缺少版本号/表格数据。');
|
||||
}
|
||||
|
||||
const confirmation = window.confirm(
|
||||
'【警告】\n\n导入操作将完全覆盖您当前的AI指令模板和所有表格(包括结构和内容)。\n\n此操作不可逆,是否确定要继续?'
|
||||
);
|
||||
if (!confirmation) {
|
||||
log('用户取消了导入操作。', 'info');
|
||||
toastr.info('导入操作已取消。');
|
||||
return;
|
||||
}
|
||||
|
||||
_applyImportedTemplates(preset);
|
||||
|
||||
const importedTables = preset.tables;
|
||||
_normalizeImportedTables(importedTables);
|
||||
|
||||
setState(importedTables);
|
||||
|
||||
// 钩子:让调用方注入 SuperMemory 全量同步等副作用
|
||||
if (typeof hooks.onAfterApply === 'function') {
|
||||
try { hooks.onAfterApply(); } catch (e) {
|
||||
log(`importPreset onAfterApply 抛错: ${e.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
commitToLastMessage(getState());
|
||||
log('导入的预设已强制写入最新消息并立即保存。', 'success');
|
||||
log('预设已成功导入并应用。', 'success');
|
||||
toastr.success('预设已成功导入!', '导入成功');
|
||||
|
||||
if (typeof hooks.onImported === 'function') {
|
||||
try { hooks.onImported(); } catch (e) {
|
||||
log(`importPreset onImported 抛错: ${e.message}`, 'error');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log(`导入预设失败: ${error.message}`, 'error');
|
||||
toastr.error(`导入失败:${error.message}`, '错误');
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
input.click();
|
||||
}
|
||||
|
||||
// ── 全局预设 ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function clearGlobalPreset() {
|
||||
if (extension_settings[extensionName] && extension_settings[extensionName].global_table_preset) {
|
||||
const confirmation = window.confirm(
|
||||
'【清除全局预设】\n\n您确定要清除已设置的全局预设吗?\n\n清除后,新聊天将恢复使用扩展内置的默认表格模板。'
|
||||
);
|
||||
|
||||
if (confirmation) {
|
||||
delete extension_settings[extensionName].global_table_preset;
|
||||
saveSettingsDebounced();
|
||||
log('全局预设已被清除。', 'success');
|
||||
toastr.success('全局预设已清除,新聊天将使用默认模板。', '操作成功');
|
||||
} else {
|
||||
log('用户取消了清除全局预设的操作。', 'info');
|
||||
toastr.info('操作已取消。');
|
||||
}
|
||||
} else {
|
||||
log('无需清除,当前未设置任何全局预设。', 'info');
|
||||
toastr.info('当前没有设置全局预设。', '提示');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {(() => void) | undefined} onImported
|
||||
*/
|
||||
export function importGlobalPreset(onImported) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.json';
|
||||
|
||||
input.onchange = e => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = event => {
|
||||
try {
|
||||
const preset = JSON.parse(event.target.result);
|
||||
|
||||
if (!preset.version || !Array.isArray(preset.tables)) {
|
||||
throw new Error('文件格式无效或缺少版本号/表格数据。');
|
||||
}
|
||||
|
||||
const confirmation = window.confirm(
|
||||
'【全局预设导入】\n\n这将把选定的预设设置为所有新聊天的默认表格。\n\n此操作将覆盖任何已存在的全局预设,是否确定?'
|
||||
);
|
||||
if (!confirmation) {
|
||||
log('用户取消了全局预设导入操作。', 'info');
|
||||
toastr.info('操作已取消。');
|
||||
return;
|
||||
}
|
||||
|
||||
// 纯净副本:仅结构,不含 rows
|
||||
const cleanTables = preset.tables.map(table => ({
|
||||
name: table.name,
|
||||
headers: table.headers,
|
||||
note: table.note,
|
||||
rule_add: table.rule_add,
|
||||
rule_delete: table.rule_delete,
|
||||
rule_update: table.rule_update,
|
||||
rows: [],
|
||||
}));
|
||||
|
||||
if (!extension_settings[extensionName]) extension_settings[extensionName] = {};
|
||||
extension_settings[extensionName].global_table_preset = {
|
||||
version: preset.version,
|
||||
tables: cleanTables,
|
||||
batchFillerRuleTemplate: preset.batchFillerRuleTemplate,
|
||||
batchFillerFlowTemplate: preset.batchFillerFlowTemplate,
|
||||
};
|
||||
saveSettingsDebounced();
|
||||
|
||||
_applyImportedTemplates(preset);
|
||||
|
||||
log('全局预设已成功导入并保存到扩展设置中。', 'success');
|
||||
toastr.success('全局预设已设置!新聊天将默认使用此预设。', '设置成功');
|
||||
|
||||
if (typeof onImported === 'function') {
|
||||
try { onImported(); } catch (e) {
|
||||
log(`importGlobalPreset onImported 抛错: ${e.message}`, 'error');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log(`导入全局预设失败: ${error.message}`, 'error');
|
||||
toastr.error(`导入失败:${error.message}`, '错误');
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
input.click();
|
||||
}
|
||||
239
core/table-system/rendering.js
Normal file
239
core/table-system/rendering.js
Normal file
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* @file Markdown/CSV 渲染 —— 把 TableState 渲染为 prompt 可用的字符串。
|
||||
*
|
||||
* 纯函数:吃 state、吐字符串。不读 store、不写盘、不发事件。
|
||||
*
|
||||
* 历史来源:从 manager.js 抽出
|
||||
* - convertTablesToCsvString → tablesToCsv
|
||||
* - convertSelectedTablesToCsvString → tablesToCsvWithSelection
|
||||
* - convertTablesToCsvStringForContentOnly → tablesToCsvContentOnly
|
||||
* - checkTableRules (内部) → _checkTableRules (内部)
|
||||
*
|
||||
* manager.js 保留同名 export 作 wrapper(自动注入 getState()),所有外部调用点零改动。
|
||||
*
|
||||
* @typedef {import('./dto/Table.js').Table} Table
|
||||
* @typedef {import('./dto/Table.js').TableState} TableState
|
||||
*/
|
||||
|
||||
/**
|
||||
* 检查表格规则违规,返回聚合警告字符串(多行)。
|
||||
* 行数超限 + 多列字符限制超限。
|
||||
* @param {Table} table
|
||||
* @returns {string}
|
||||
*/
|
||||
function _checkTableRules(table) {
|
||||
const warnings = [];
|
||||
|
||||
// 行数限制
|
||||
if (table.rowLimitRule && table.rowLimitRule > 0 && table.rows.length > table.rowLimitRule) {
|
||||
warnings.push(`【当前(${table.name})超出规定(${table.rowLimitRule})行,请结合剧情缩减至(${table.rowLimitRule})行以下,但切莫完全删除。】`);
|
||||
}
|
||||
|
||||
// 多列字符限制
|
||||
const charLimitRules = table.charLimitRules || {};
|
||||
for (const colIndexStr in charLimitRules) {
|
||||
const colIndex = parseInt(colIndexStr, 10);
|
||||
const limit = charLimitRules[colIndex];
|
||||
if (limit > 0 && colIndex >= 0 && colIndex < table.headers.length) {
|
||||
const colName = table.headers[colIndex];
|
||||
const offendingRows = [];
|
||||
table.rows.forEach((row, rowIndex) => {
|
||||
if (table.rowStatuses && table.rowStatuses[rowIndex] === 'pending-deletion') return;
|
||||
const cellContent = row[colIndex] || '';
|
||||
if (cellContent.length > limit) offendingRows.push(rowIndex);
|
||||
});
|
||||
if (offendingRows.length > 0) {
|
||||
warnings.push(`【当前(${table.name})第(${offendingRows.join('、')})行(${colName})列,字符超出规定(${limit})字限制,请进行缩减。】`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return warnings.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 把单个 table 的"内容主体"(含 simplify 处理 + warnings)写入到 fullString 末尾。
|
||||
* 提取自三个渲染函数中重复的内层逻辑。
|
||||
*
|
||||
* @param {Table} table
|
||||
* @param {string} tagName
|
||||
* @returns {string}
|
||||
*/
|
||||
function _renderTableBody(table, tagName) {
|
||||
let out = '';
|
||||
const activeRows = table.rows.filter((row, i) => !table.rowStatuses || table.rowStatuses[i] !== 'pending-deletion');
|
||||
|
||||
if (activeRows.length === 0) {
|
||||
out += '(该表当前内容为空)\n';
|
||||
} else {
|
||||
const simplifyThreshold = table.simplifyRowThreshold || 0;
|
||||
let simplifiedCount = 0;
|
||||
|
||||
table.rows.forEach((row, rowIndex) => {
|
||||
if (table.rowStatuses && table.rowStatuses[rowIndex] === 'pending-deletion') return;
|
||||
|
||||
// 历史内容简化:前 N 行用 ---已锁定--- 占位
|
||||
if (simplifyThreshold > 0 && rowIndex < simplifyThreshold) {
|
||||
if (simplifiedCount === 0) {
|
||||
const placeholderCells = row.map(() => '---已锁定---');
|
||||
out += `| ${rowIndex} | ${placeholderCells.join(' | ')} |\n`;
|
||||
out += `| ... | ${row.map(() => '...').join(' | ')} |\n`;
|
||||
}
|
||||
if (rowIndex === simplifyThreshold - 1) {
|
||||
const placeholderCells = row.map(() => '---已锁定---');
|
||||
out += `| ${rowIndex} | ${placeholderCells.join(' | ')} |\n`;
|
||||
}
|
||||
simplifiedCount++;
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(row)) {
|
||||
const rowCells = row.map(cell => {
|
||||
const cellContent = (cell === null || cell === undefined || cell === '') ? '未知' : String(cell);
|
||||
return cellContent.replace(/\|/g, '|');
|
||||
});
|
||||
out += `| ${rowIndex} | ${rowCells.join(' | ')} |\n`;
|
||||
}
|
||||
});
|
||||
|
||||
if (simplifiedCount > 0) {
|
||||
out += `\n【系统提示】:表格前 ${simplifiedCount} 行(索引 0 到 ${simplifiedCount - 1})的历史内容已简化并锁定,无需读取或修改。请专注于后续行的内容。\n`;
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 完整渲染:所有表格内容 + 规则 + 警告,注入到主流程 prompt。
|
||||
* 对应 manager.js#convertTablesToCsvString。
|
||||
*
|
||||
* @param {TableState | null} state
|
||||
* @returns {string}
|
||||
*/
|
||||
export function tablesToCsv(state) {
|
||||
if (!state || state.length === 0) return '';
|
||||
|
||||
let fullString = '';
|
||||
state.forEach((table, tableIndex) => {
|
||||
// 标题
|
||||
fullString += `\n* ${tableIndex}:${table.name}\n`;
|
||||
|
||||
// 说明
|
||||
fullString += `【说明】:\n${table.note || '无'}\n`;
|
||||
|
||||
// 内容(Markdown 表)
|
||||
const tagName = table.name.replace(/\s/g, '') + '内容';
|
||||
fullString += `<${tagName}>\n`;
|
||||
const headerWithIndex = ['rowIndex', ...table.headers.map((h, i) => `${i}:${h}`)];
|
||||
fullString += `| ${headerWithIndex.join(' | ')} |\n`;
|
||||
fullString += `|${headerWithIndex.map(() => '---').join('|')}|\n`;
|
||||
fullString += _renderTableBody(table, tagName);
|
||||
|
||||
// 警告
|
||||
const warnings = _checkTableRules(table);
|
||||
if (warnings) fullString += `${warnings}\n`;
|
||||
fullString += `</${tagName}>\n`;
|
||||
|
||||
// 规则
|
||||
fullString += `【增加】: ${table.rule_add || '允许'}\n`;
|
||||
fullString += `【删除】: ${table.rule_delete || '允许'}\n`;
|
||||
fullString += `【修改】: ${table.rule_update || '允许'}\n`;
|
||||
|
||||
if (tableIndex < state.length - 1) fullString += '\n---\n';
|
||||
});
|
||||
|
||||
return fullString;
|
||||
}
|
||||
|
||||
/**
|
||||
* 选中态渲染:未选中的表格只展示表头作为索引参考;选中的展示完整内容。
|
||||
* 对应 manager.js#convertSelectedTablesToCsvString。
|
||||
*
|
||||
* @param {TableState | null} state
|
||||
* @param {number[]} selectedIndices
|
||||
* @returns {string}
|
||||
*/
|
||||
export function tablesToCsvWithSelection(state, selectedIndices) {
|
||||
if (!state || state.length === 0) return '';
|
||||
const selected = Array.isArray(selectedIndices) ? selectedIndices : [];
|
||||
|
||||
let fullString = '';
|
||||
state.forEach((table, tableIndex) => {
|
||||
const isSelected = selected.includes(tableIndex);
|
||||
|
||||
// 标题
|
||||
fullString += `\n* ${tableIndex}:${table.name}`;
|
||||
if (!isSelected) fullString += ' (本表格无需重新整理,仅供参考)';
|
||||
fullString += '\n';
|
||||
|
||||
// 说明
|
||||
fullString += `【说明】:\n${table.note || '无'}\n`;
|
||||
|
||||
const tagName = table.name.replace(/\s/g, '') + '内容';
|
||||
fullString += `<${tagName}>\n`;
|
||||
const headerWithIndex = ['rowIndex', ...table.headers.map((h, i) => `${i}:${h}`)];
|
||||
fullString += `| ${headerWithIndex.join(' | ')} |\n`;
|
||||
fullString += `|${headerWithIndex.map(() => '---').join('|')}|\n`;
|
||||
|
||||
if (isSelected) {
|
||||
fullString += _renderTableBody(table, tagName);
|
||||
const warnings = _checkTableRules(table);
|
||||
if (warnings) fullString += `${warnings}\n`;
|
||||
} else {
|
||||
fullString += '(此处省略未选中的表格内容,仅提供表头供索引参考)\n';
|
||||
}
|
||||
fullString += `</${tagName}>\n`;
|
||||
|
||||
// 规则
|
||||
if (isSelected) {
|
||||
fullString += `【增加】: ${table.rule_add || '允许'}\n`;
|
||||
fullString += `【删除】: ${table.rule_delete || '允许'}\n`;
|
||||
fullString += `【修改】: ${table.rule_update || '允许'}\n`;
|
||||
} else {
|
||||
fullString += `【操作权限】: 禁止修改此表格\n`;
|
||||
}
|
||||
|
||||
if (tableIndex < state.length - 1) fullString += '\n---\n';
|
||||
});
|
||||
|
||||
return fullString;
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅内容渲染:不带规则、不带 rowIndex 列、不带说明。
|
||||
* 用于"分步填表"和"优化中填表"模式下的 prompt 注入(只展示数据本身)。
|
||||
* 对应 manager.js#convertTablesToCsvStringForContentOnly。
|
||||
*
|
||||
* @param {TableState | null} state
|
||||
* @returns {string}
|
||||
*/
|
||||
export function tablesToCsvContentOnly(state) {
|
||||
if (!state || state.length === 0) return '';
|
||||
|
||||
let outputString = '';
|
||||
state.forEach(table => {
|
||||
outputString += `\n<${table.name}>\n`;
|
||||
|
||||
// Markdown 表头
|
||||
outputString += `| ${table.headers.join(' | ')} |\n`;
|
||||
outputString += `|${table.headers.map(() => '---').join('|')}|\n`;
|
||||
|
||||
// 数据
|
||||
const activeRows = table.rows.filter((row, i) => !table.rowStatuses || table.rowStatuses[i] !== 'pending-deletion');
|
||||
if (activeRows.length > 0) {
|
||||
activeRows.forEach(row => {
|
||||
if (Array.isArray(row)) {
|
||||
const rowContent = row.map(cell => (cell === null || cell === undefined || cell === '') ? ' ' : cell.toString());
|
||||
outputString += `| ${rowContent.join(' | ')} |\n`;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
outputString += '(该表当前内容为空)\n';
|
||||
}
|
||||
|
||||
outputString += `</${table.name}>\n`;
|
||||
});
|
||||
|
||||
return outputString.trim();
|
||||
}
|
||||
@@ -71,8 +71,8 @@ export async function reorganizeTableContent(selectedTableIndices) {
|
||||
console.log('[Amily2-重新整理] 使用 Nccs API 进行表格重整...');
|
||||
rawContent = await callNccsAI(messages);
|
||||
} else {
|
||||
console.log('[Amily2-重新整理] 使用默认 API 进行表格重整...');
|
||||
rawContent = await callAI(messages);
|
||||
console.log('[Amily2-重新整理] 使用 tableFilling slot 进行表格重整...');
|
||||
rawContent = await callAI(messages, { slot: 'tableFilling' });
|
||||
}
|
||||
|
||||
if (!rawContent) {
|
||||
|
||||
@@ -277,8 +277,8 @@ export async function fillWithSecondaryApi(latestMessage, forceRun = false) {
|
||||
console.log('[Amily2-副API] 使用 Nccs API 进行分步填表...');
|
||||
rawContent = await callNccsAI(messages);
|
||||
} else {
|
||||
console.log('[Amily2-副API] 使用默认 API 进行分步填表...');
|
||||
rawContent = await callAI(messages);
|
||||
console.log('[Amily2-副API] 使用 tableFilling slot 进行分步填表...');
|
||||
rawContent = await callAI(messages, { slot: 'tableFilling' });
|
||||
}
|
||||
|
||||
if (!rawContent) {
|
||||
|
||||
78
core/table-system/templates.js
Normal file
78
core/table-system/templates.js
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* @file 表格 prompt 模板的 getter/setter 集中点。
|
||||
*
|
||||
* 三套模板:
|
||||
* - batch_filler_rule_template 规则模板(系统提示词部分)
|
||||
* - batch_filler_flow_template 流程模板(含 {{{Amily2TableData}}} 占位符)
|
||||
* - amily2_ai_template 注入模板(主 API 模式下走的注入)
|
||||
*
|
||||
* 所有读写都落到 extension_settings[extensionName],saveSettingsDebounced 触发持久化。
|
||||
*
|
||||
* 历史来源:从 manager.js 抽出
|
||||
* - getBatchFillerRuleTemplate / saveBatchFillerRuleTemplate
|
||||
* - getBatchFillerFlowTemplate / saveBatchFillerFlowTemplate
|
||||
* - getAiFlowTemplateForInjection
|
||||
* - saveAiTemplate / getAiTemplate
|
||||
*/
|
||||
|
||||
import { extension_settings } from '/scripts/extensions.js';
|
||||
import { saveSettingsDebounced } from '/script.js';
|
||||
import { extensionName } from '../../utils/settings.js';
|
||||
import { DEFAULT_AI_RULE_TEMPLATE, DEFAULT_AI_FLOW_TEMPLATE } from './settings.js';
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getBatchFillerRuleTemplate() {
|
||||
return extension_settings[extensionName]?.batch_filler_rule_template ?? DEFAULT_AI_RULE_TEMPLATE;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} template
|
||||
*/
|
||||
export function saveBatchFillerRuleTemplate(template) {
|
||||
if (!extension_settings[extensionName]) extension_settings[extensionName] = {};
|
||||
extension_settings[extensionName].batch_filler_rule_template = template;
|
||||
saveSettingsDebounced();
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getBatchFillerFlowTemplate() {
|
||||
return extension_settings[extensionName]?.batch_filler_flow_template ?? DEFAULT_AI_FLOW_TEMPLATE;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} template
|
||||
*/
|
||||
export function saveBatchFillerFlowTemplate(template) {
|
||||
if (!extension_settings[extensionName]) extension_settings[extensionName] = {};
|
||||
extension_settings[extensionName].batch_filler_flow_template = template;
|
||||
saveSettingsDebounced();
|
||||
}
|
||||
|
||||
/**
|
||||
* 主 API 模式下注入用的流程模板。与 batch_filler_flow_template 是两套独立配置。
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getAiFlowTemplateForInjection() {
|
||||
return extension_settings[extensionName]?.amily2_ai_template ?? DEFAULT_AI_FLOW_TEMPLATE;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} template
|
||||
*/
|
||||
export function saveAiTemplate(template) {
|
||||
if (!extension_settings[extensionName]) extension_settings[extensionName] = {};
|
||||
extension_settings[extensionName].amily2_ai_template = template;
|
||||
saveSettingsDebounced();
|
||||
}
|
||||
|
||||
/**
|
||||
* 别名 —— 历史 manager.js 同名函数,等价于 getAiFlowTemplateForInjection。
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getAiTemplate() {
|
||||
return getAiFlowTemplateForInjection();
|
||||
}
|
||||
@@ -393,8 +393,12 @@ class AmilyHelper {
|
||||
keys: entry.key || [],
|
||||
enabled: !entry.disable,
|
||||
constant: entry.constant || false,
|
||||
position: positionMap[entry.position] || 'at_depth_as_system',
|
||||
depth: entry.depth || 998,
|
||||
position: positionMap[entry.position ?? entry.extensions?.position] || 'at_depth_as_system',
|
||||
depth: entry.depth ?? entry.extensions?.depth ?? 998,
|
||||
scanDepth: entry.scanDepth ?? entry.extensions?.scan_depth,
|
||||
order: entry.order ?? entry.extensions?.display_index,
|
||||
exclude_recursion: entry.excludeRecursion ?? entry.extensions?.exclude_recursion ?? false,
|
||||
prevent_recursion: entry.preventRecursion ?? entry.extensions?.prevent_recursion ?? false,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error(`[Amily助手] 获取世界书《${bookName}》条目时出错:`, error);
|
||||
@@ -429,13 +433,36 @@ class AmilyHelper {
|
||||
'at_depth': 4,
|
||||
'at_depth_as_system': 4
|
||||
};
|
||||
existingEntry.position = positionMap[entryUpdate.position] ?? 4;
|
||||
const mappedPos = positionMap[entryUpdate.position] ?? 4;
|
||||
existingEntry.position = mappedPos;
|
||||
if (!existingEntry.extensions) existingEntry.extensions = {};
|
||||
existingEntry.extensions.position = mappedPos;
|
||||
}
|
||||
if (entryUpdate.depth !== undefined) {
|
||||
existingEntry.depth = entryUpdate.depth;
|
||||
if (!existingEntry.extensions) existingEntry.extensions = {};
|
||||
existingEntry.extensions.depth = entryUpdate.depth;
|
||||
}
|
||||
if (entryUpdate.scanDepth !== undefined) {
|
||||
existingEntry.scanDepth = entryUpdate.scanDepth;
|
||||
if (!existingEntry.extensions) existingEntry.extensions = {};
|
||||
existingEntry.extensions.scan_depth = entryUpdate.scanDepth;
|
||||
}
|
||||
if (entryUpdate.order !== undefined) {
|
||||
existingEntry.order = entryUpdate.order;
|
||||
if (!existingEntry.extensions) existingEntry.extensions = {};
|
||||
existingEntry.extensions.display_index = entryUpdate.order;
|
||||
}
|
||||
if (entryUpdate.exclude_recursion !== undefined) {
|
||||
existingEntry.excludeRecursion = entryUpdate.exclude_recursion;
|
||||
if (!existingEntry.extensions) existingEntry.extensions = {};
|
||||
existingEntry.extensions.exclude_recursion = entryUpdate.exclude_recursion;
|
||||
}
|
||||
if (entryUpdate.prevent_recursion !== undefined) {
|
||||
existingEntry.preventRecursion = entryUpdate.prevent_recursion;
|
||||
if (!existingEntry.extensions) existingEntry.extensions = {};
|
||||
existingEntry.extensions.prevent_recursion = entryUpdate.prevent_recursion;
|
||||
}
|
||||
if (entryUpdate.depth !== undefined) existingEntry.depth = entryUpdate.depth;
|
||||
if (entryUpdate.scanDepth !== undefined) existingEntry.scanDepth = entryUpdate.scanDepth;
|
||||
if (entryUpdate.order !== undefined) existingEntry.order = entryUpdate.order;
|
||||
if (entryUpdate.exclude_recursion !== undefined) existingEntry.excludeRecursion = entryUpdate.exclude_recursion;
|
||||
if (entryUpdate.prevent_recursion !== undefined) existingEntry.preventRecursion = entryUpdate.prevent_recursion;
|
||||
}
|
||||
}
|
||||
await saveWorldInfo(bookName, bookData, true);
|
||||
@@ -470,19 +497,37 @@ class AmilyHelper {
|
||||
'at_depth': 4,
|
||||
'at_depth_as_system': 4
|
||||
};
|
||||
const mappedPos = typeof newEntryData.position === 'string' ? (positionMap[newEntryData.position] ?? 4) : (newEntryData.position ?? 4);
|
||||
Object.assign(newEntry, {
|
||||
comment: newEntryData.comment || '新条目',
|
||||
content: newEntryData.content || '',
|
||||
key: newEntryData.keys || newEntryData.key || [],
|
||||
constant: newEntryData.type === 'constant' ? true : (newEntryData.constant || false),
|
||||
position: typeof newEntryData.position === 'string' ? (positionMap[newEntryData.position] ?? 4) : (newEntryData.position ?? 4),
|
||||
position: mappedPos,
|
||||
depth: newEntryData.depth ?? 998,
|
||||
scanDepth: newEntryData.scanDepth ?? null,
|
||||
order: newEntryData.order ?? 100,
|
||||
disable: !(newEntryData.enabled ?? true),
|
||||
excludeRecursion: newEntryData.excludeRecursion ?? newEntryData.exclude_recursion ?? false,
|
||||
preventRecursion: newEntryData.preventRecursion ?? newEntryData.prevent_recursion ?? false,
|
||||
});
|
||||
if (newEntryData.type === 'selective') newEntry.constant = false;
|
||||
|
||||
// 兼容新版酒馆的防递归等扩展逻辑 (v1.17.0+)
|
||||
if (!newEntry.extensions) newEntry.extensions = {};
|
||||
newEntry.extensions.position = mappedPos;
|
||||
newEntry.extensions.depth = newEntry.depth;
|
||||
if (newEntry.scanDepth !== null) newEntry.extensions.scan_depth = newEntry.scanDepth;
|
||||
if (newEntryData.order !== undefined) newEntry.extensions.display_index = newEntryData.order;
|
||||
|
||||
const hasExclude = newEntryData.excludeRecursion !== undefined || newEntryData.exclude_recursion !== undefined;
|
||||
const hasPrevent = newEntryData.preventRecursion !== undefined || newEntryData.prevent_recursion !== undefined;
|
||||
if (hasExclude) {
|
||||
newEntry.extensions.exclude_recursion = newEntryData.excludeRecursion ?? newEntryData.exclude_recursion ?? false;
|
||||
}
|
||||
if (hasPrevent) {
|
||||
newEntry.extensions.prevent_recursion = newEntryData.preventRecursion ?? newEntryData.prevent_recursion ?? false;
|
||||
}
|
||||
}
|
||||
await saveWorldInfo(bookName, bookData, true);
|
||||
reloadEditor(bookName);
|
||||
|
||||
@@ -24,6 +24,7 @@ export { characters, this_chid, eventSource, event_types, saveSettingsDebounced
|
||||
// Core Systems
|
||||
export { injectTableData, generateTableContent } from "./core/table-system/injector.js";
|
||||
export { initialize as initializeRagProcessor } from "./core/rag-processor.js";
|
||||
export { loadSettingsToUI as loadHanlinyuanSettingsToUI } from "./ui/hanlinyuan-bindings.js";
|
||||
export { loadTables, clearHighlights, rollbackAndRefill, rollbackState, commitPendingDeletions, saveStateToMessage, getMemoryState, clearUpdatedTables } from './core/table-system/manager.js';
|
||||
export { fillWithSecondaryApi } from './core/table-system/secondary-filler.js';
|
||||
export { renderTables } from './ui/table-bindings.js';
|
||||
|
||||
10
index.js
10
index.js
@@ -8,6 +8,7 @@ import {
|
||||
characters, this_chid, eventSource, event_types, saveSettingsDebounced,
|
||||
injectTableData, generateTableContent,
|
||||
initializeRagProcessor,
|
||||
loadHanlinyuanSettingsToUI,
|
||||
loadTables, clearHighlights, rollbackAndRefill, rollbackState, commitPendingDeletions, saveStateToMessage, getMemoryState, clearUpdatedTables,
|
||||
fillWithSecondaryApi,
|
||||
renderTables,
|
||||
@@ -773,6 +774,15 @@ function initializeRagAndInjection() {
|
||||
console.error('[Amily2-翰林院] RAG处理器初始化失败:', error);
|
||||
}
|
||||
|
||||
// 此时 ST settings hydration 已完成,且 RAG 第二次 init 拿到的是真实 saved settings 引用。
|
||||
// mount 阶段那次 loadSettingsToUI 跑得过早(hydration 之前),UI 拿到的是默认值;
|
||||
// 在此重跑一次以让翰林院面板显示真实持久化值。
|
||||
try {
|
||||
loadHanlinyuanSettingsToUI();
|
||||
} catch (error) {
|
||||
console.error('[Amily2-翰林院] 步骤五重载面板设置失败:', error);
|
||||
}
|
||||
|
||||
console.log("[Amily2号-开国大典] 步骤六:智能冲突检测与注入策略...");
|
||||
console.log('[Amily2-策略] 采用“完全主导”策略,覆盖 `vectors_rearrangeChat`。');
|
||||
window['vectors_rearrangeChat'] = executeAmily2Injection;
|
||||
|
||||
27
jsconfig.json
Normal file
27
jsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"noEmit": true,
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": false,
|
||||
"noImplicitAny": false,
|
||||
"strictNullChecks": false,
|
||||
"lib": ["esnext", "dom"]
|
||||
},
|
||||
"include": [
|
||||
"**/*.js",
|
||||
"types/**/*.d.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"build"
|
||||
]
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "Amily2号聊天优化助手",
|
||||
"display_name": "Amily2号助手",
|
||||
"version": "2.1.0",
|
||||
"version": "2.2.1",
|
||||
"author": "Wx-2025",
|
||||
"description": "一个拥有独立UI的智能引擎,正文优化、自动总结、记忆表格、rag向量、隐藏楼层、剧情推进等多功能整合。",
|
||||
"minSillyTavernVersion": "1.10.0",
|
||||
|
||||
68
types/sillytavern.d.ts
vendored
Normal file
68
types/sillytavern.d.ts
vendored
Normal file
@@ -0,0 +1,68 @@
|
||||
// SillyTavern 全局模块的环境声明。
|
||||
// 让 import { ... } from '/script.js' 这类绝对路径在 TS 引擎眼里能解析。
|
||||
// 此文件不被运行时加载,仅供 jsconfig.json 的 checkJs 使用。
|
||||
//
|
||||
// 字段类型一律 any —— 不做强约束,只为消除 "Cannot find module" 红线。
|
||||
// 想要真类型,把对应字段改成具体签名即可。
|
||||
|
||||
declare module '/script.js' {
|
||||
export const saveChat: any;
|
||||
export const saveChatDebounced: any;
|
||||
export const saveSettingsDebounced: any;
|
||||
export const saveSettings: any;
|
||||
export const characters: any;
|
||||
export const this_chid: any;
|
||||
export const eventSource: any;
|
||||
export const event_types: any;
|
||||
export const getRequestHeaders: any;
|
||||
export const name1: any;
|
||||
export const name2: any;
|
||||
export const chat: any;
|
||||
export const reloadCurrentChat: any;
|
||||
export const saveChatConditional: any;
|
||||
const _default: any;
|
||||
export default _default;
|
||||
}
|
||||
|
||||
declare module '/scripts/extensions.js' {
|
||||
export const extension_settings: any;
|
||||
export const getContext: any;
|
||||
export const renderExtensionTemplate: any;
|
||||
export const renderExtensionTemplateAsync: any;
|
||||
export const writeExtensionField: any;
|
||||
const _default: any;
|
||||
export default _default;
|
||||
}
|
||||
|
||||
declare module '/scripts/world-info.js' {
|
||||
export const loadWorldInfo: any;
|
||||
export const saveWorldInfo: any;
|
||||
export const world_names: any;
|
||||
export const getWorldInfoPrompt: any;
|
||||
const _default: any;
|
||||
export default _default;
|
||||
}
|
||||
|
||||
declare module '/scripts/slash-commands.js' {
|
||||
const anything: any;
|
||||
export = anything;
|
||||
}
|
||||
|
||||
declare module '/scripts/extensions/*' {
|
||||
const anything: any;
|
||||
export = anything;
|
||||
}
|
||||
|
||||
// 全局对象 —— 在 .js 文件里直接用 toastr / window.Amily2Bus 不会被标红。
|
||||
declare global {
|
||||
const toastr: any;
|
||||
interface Window {
|
||||
Amily2Bus: any;
|
||||
AMILY2_SYSTEM_PARALYZED: boolean;
|
||||
AMILY2_MACRO_REPLACED: boolean;
|
||||
MiZheSi_Global: any;
|
||||
_amilySafeConsole: any;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -6,7 +6,7 @@
|
||||
* ApiKeyStore(密钥存储)
|
||||
*/
|
||||
|
||||
import { apiProfileManager, PROFILE_TYPES, SLOTS } from '../utils/config/ApiProfileManager.js';
|
||||
import { apiProfileManager, PROFILE_TYPES, SLOTS, clearLegacyConfig } from '../utils/config/ApiProfileManager.js';
|
||||
import { apiKeyStore } from '../utils/config/api-key-store/ApiKeyStore.js';
|
||||
import { configManager } from '../utils/config/ConfigManager.js';
|
||||
import { getRequestHeaders, saveSettingsDebounced } from '/script.js';
|
||||
@@ -17,6 +17,12 @@ import { testJqyhApiConnection } from '../core/api/JqyhApi.js';
|
||||
import { testConcurrentApiConnection } from '../core/api/ConcurrentApi.js';
|
||||
import { testNgmsApiConnection } from '../core/api/Ngms_api.js';
|
||||
import { testNccsApiConnection } from '../core/api/NccsApi.js';
|
||||
import {
|
||||
getRegistry,
|
||||
detectVendorSync,
|
||||
listVendorParamsSync,
|
||||
getVendorEntry,
|
||||
} from '../utils/api-vendor.js';
|
||||
|
||||
// 槽位 → 真实测试函数映射(发送聊天请求验证连接)
|
||||
// plotOpt 槽位同时服务剧情优化和 JQYH(互斥),根据启用状态选择测试函数
|
||||
@@ -45,11 +51,21 @@ const SLOT_TOGGLES = {
|
||||
|
||||
let _editingId = null; // 当前编辑的 Profile ID(null = 新建)
|
||||
let _currentFilter = 'all'; // 当前类型筛选
|
||||
let _slotAssignmentPanel = null;
|
||||
let _slotAssignmentRefreshBound = false;
|
||||
|
||||
// ── 入口:绑定整个面板 ────────────────────────────────────────────────────────
|
||||
|
||||
export function bindApiConfigPanel(container) {
|
||||
const $c = $(container);
|
||||
_slotAssignmentPanel = $c;
|
||||
|
||||
if (!_slotAssignmentRefreshBound) {
|
||||
_slotAssignmentRefreshBound = true;
|
||||
document.addEventListener('amily2:slotAssigned', () => {
|
||||
if (_slotAssignmentPanel) renderSlotAssignments(_slotAssignmentPanel);
|
||||
});
|
||||
}
|
||||
|
||||
// 存储模式
|
||||
_bindStorageMode($c);
|
||||
@@ -70,9 +86,11 @@ export function bindApiConfigPanel(container) {
|
||||
_switchParamSections($c, $(this).val());
|
||||
});
|
||||
|
||||
// 弹窗:接口类型切换(Google 自动填 URL)
|
||||
$c.find('#amily2_pf_provider').on('change', function () {
|
||||
_handleProviderChange($c, $(this).val());
|
||||
// 弹窗:接口类型切换 —— vendor preset 自动填 defaultUrl + 切换提示框
|
||||
$c.find('#amily2_pf_provider').on('change', async function () {
|
||||
const provider = $(this).val();
|
||||
_handleProviderChange($c, provider);
|
||||
await _autofillVendorUrl($c, provider);
|
||||
});
|
||||
|
||||
// 弹窗:获取模型列表
|
||||
@@ -81,6 +99,30 @@ export function bindApiConfigPanel(container) {
|
||||
// 弹窗:测试连接
|
||||
$c.find('#amily2_pf_test_conn').on('click', () => _testConnection($c));
|
||||
|
||||
// 弹窗:URL 变更 → 更新 customParams hint
|
||||
$c.find('#amily2_pf_url').on('input change blur', () => _updateCustomParamsHint($c));
|
||||
|
||||
// 弹窗:customParams 文本框实时校验 JSON
|
||||
$c.find('#amily2_pf_custom_params').on('blur input', () => {
|
||||
_validateCustomParamsLive($c);
|
||||
_updateCustomParamsHint($c);
|
||||
});
|
||||
|
||||
$c.on('click', '.amily2_param_hint_btn', function () {
|
||||
if (this.disabled) return;
|
||||
_insertParamToCustomParams(
|
||||
$c,
|
||||
$(this).data('paramName'),
|
||||
$(this).data('paramType')
|
||||
);
|
||||
});
|
||||
|
||||
// 预加载 vendor registry(异步,UI 不阻塞)
|
||||
getRegistry().catch(() => { /* 失败已在 api-vendor 内部 fallback,无需再处理 */ });
|
||||
|
||||
// 旧配置清理按钮
|
||||
$c.find('#amily2_clear_legacy_config').on('click', () => _handleClearLegacyConfig($c));
|
||||
|
||||
// 表单:取消
|
||||
$c.find('#amily2_profile_modal_cancel').on('click', () => closeModal($c));
|
||||
|
||||
@@ -404,6 +446,11 @@ async function openModal($c, id) {
|
||||
$c.find('#amily2_pf_max_tokens').val(p.maxTokens);
|
||||
$c.find('#amily2_pf_temperature').val(p.temperature);
|
||||
$c.find('#amily2_pf_fake_stream').prop('checked', p.fakeStream ?? false);
|
||||
// customParams 写回成格式化 JSON 字符串
|
||||
const cp = p.customParams ?? {};
|
||||
$c.find('#amily2_pf_custom_params').val(
|
||||
Object.keys(cp).length ? JSON.stringify(cp, null, 2) : ''
|
||||
);
|
||||
} else if (p.type === 'embedding') {
|
||||
$c.find('#amily2_pf_dimensions').val(p.dimensions ?? '');
|
||||
$c.find('#amily2_pf_encoding_format').val(p.encodingFormat);
|
||||
@@ -421,9 +468,12 @@ async function openModal($c, id) {
|
||||
$c.find('#amily2_pf_name, #amily2_pf_url, #amily2_pf_key, #amily2_pf_model').val('');
|
||||
$c.find('#amily2_pf_provider').val('openai');
|
||||
_handleProviderChange($c, 'openai');
|
||||
// 新建模式下自动填充默认 URL(编辑模式不调,避免覆盖用户已配置的代理 URL)
|
||||
_autofillVendorUrl($c, 'openai');
|
||||
$c.find('#amily2_pf_max_tokens').val(65500);
|
||||
$c.find('#amily2_pf_temperature').val(1.0);
|
||||
$c.find('#amily2_pf_fake_stream').prop('checked', false);
|
||||
$c.find('#amily2_pf_custom_params').val('');
|
||||
$c.find('#amily2_pf_dimensions').val('');
|
||||
$c.find('#amily2_pf_encoding_format').val('float');
|
||||
$c.find('#amily2_pf_top_n').val(5);
|
||||
@@ -436,6 +486,10 @@ async function openModal($c, id) {
|
||||
$c.find('#amily2_pf_model_select').hide().empty();
|
||||
$c.find('#amily2_pf_model').show();
|
||||
|
||||
// 刷新 customParams 旁的 vendor 提示 + 清空错误
|
||||
_updateCustomParamsHint($c);
|
||||
_validateCustomParamsLive($c);
|
||||
|
||||
const $details = $c.find('#amily2_profile_form_details');
|
||||
$details.prop('open', true);
|
||||
$details[0].scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
@@ -464,6 +518,14 @@ async function saveProfile($c) {
|
||||
data.maxTokens = parseInt($c.find('#amily2_pf_max_tokens').val(), 10) || 65500;
|
||||
data.temperature = parseFloat($c.find('#amily2_pf_temperature').val()) || 1.0;
|
||||
data.fakeStream = $c.find('#amily2_pf_fake_stream').prop('checked');
|
||||
|
||||
// customParams:JSON 校验失败则中止保存
|
||||
const cp = _parseCustomParamsOrFail($c);
|
||||
if (cp === null) {
|
||||
toastr.error('自定义参数 JSON 解析失败,请修正后再保存。', '保存中止');
|
||||
return;
|
||||
}
|
||||
data.customParams = cp;
|
||||
} else if (type === 'embedding') {
|
||||
const dim = $c.find('#amily2_pf_dimensions').val();
|
||||
data.dimensions = dim ? parseInt(dim, 10) : null;
|
||||
@@ -576,6 +638,8 @@ async function _fetchModels($c) {
|
||||
return;
|
||||
}
|
||||
|
||||
models.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
const currentVal = $c.find('#amily2_pf_model').val().trim();
|
||||
const $sel = $c.find('#amily2_pf_model_select');
|
||||
$sel.html(models.map(m => `<option value="${_escapeHtml(m)}">${_escapeHtml(m)}</option>`).join(''));
|
||||
@@ -703,15 +767,71 @@ async function _testConnection($c) {
|
||||
|
||||
// ── Provider 切换 ─────────────────────────────────────────────────────────────
|
||||
|
||||
const GOOGLE_API_BASE = 'https://generativelanguage.googleapis.com/v1beta/openai';
|
||||
/**
|
||||
* 6 个享受 defaultUrl 自动填充的 vendor preset id。registry 之外的 provider
|
||||
* (sillytavern_backend / sillytavern_preset / custom_oai)走各自的特殊逻辑。
|
||||
*/
|
||||
const VENDOR_PRESETS = new Set(['anthropic', 'openai', 'google', 'openrouter', 'deepseek', 'xai']);
|
||||
|
||||
function _handleProviderChange($c, provider) {
|
||||
const isGoogle = provider === 'google';
|
||||
$c.find('#amily2_pf_url_row').toggle(!isGoogle);
|
||||
$c.find('#amily2_pf_google_note').toggle(isGoogle);
|
||||
/**
|
||||
* 处理 provider 变化的"展示侧"逻辑:URL row 可见性 + vendor 提示框。
|
||||
* 不修改 URL 输入值(避免编辑现有 profile 时被覆盖)。
|
||||
* URL 自动填充由 _autofillVendorUrl 单独负责,仅在用户主动 change 时触发。
|
||||
*/
|
||||
async function _handleProviderChange($c, provider) {
|
||||
const $urlRow = $c.find('#amily2_pf_url_row');
|
||||
const $note = $c.find('#amily2_pf_vendor_note');
|
||||
const $noteText = $c.find('#amily2_pf_vendor_note_text');
|
||||
const $linkWrap = $c.find('#amily2_pf_vendor_note_link_wrap');
|
||||
const $link = $c.find('#amily2_pf_vendor_note_link');
|
||||
|
||||
if (isGoogle) {
|
||||
$c.find('#amily2_pf_url').val(GOOGLE_API_BASE);
|
||||
// URL row 一律可见(包括 preset vendor —— 用户可能要切到代理/镜像)
|
||||
$urlRow.show();
|
||||
|
||||
if (VENDOR_PRESETS.has(provider)) {
|
||||
try {
|
||||
const entry = await getVendorEntry(provider);
|
||||
if (entry) {
|
||||
$noteText.text(`${entry.displayName} — 默认接口地址已自动填写,如需走代理/镜像可在下方修改。`);
|
||||
if (entry.doc) {
|
||||
$link.attr('href', entry.doc).text('查看官方文档');
|
||||
$linkWrap.show();
|
||||
} else {
|
||||
$linkWrap.hide();
|
||||
}
|
||||
$note.show();
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[ApiConfig] vendor entry 加载失败:', e);
|
||||
}
|
||||
}
|
||||
$note.hide();
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户主动切换 provider 时,把 URL 字段写为该 vendor 的 defaultUrl。
|
||||
* Custom 模式清空 URL;ST backend/preset 不动 URL。
|
||||
* 同时刷新 customParams hint 与校验状态。
|
||||
*/
|
||||
async function _autofillVendorUrl($c, provider) {
|
||||
if (provider === 'custom_oai') {
|
||||
$c.find('#amily2_pf_url').val('');
|
||||
_updateCustomParamsHint($c);
|
||||
return;
|
||||
}
|
||||
if (!VENDOR_PRESETS.has(provider)) {
|
||||
// sillytavern_backend / sillytavern_preset 等不修改 URL
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const entry = await getVendorEntry(provider);
|
||||
if (entry?.defaultUrl) {
|
||||
$c.find('#amily2_pf_url').val(entry.defaultUrl);
|
||||
_updateCustomParamsHint($c);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[ApiConfig] autofill defaultUrl 失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -739,3 +859,153 @@ function _escapeHtml(str) {
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function _getCustomParamsEditorState($c) {
|
||||
const raw = ($c.find('#amily2_pf_custom_params').val() || '').trim();
|
||||
if (!raw) {
|
||||
return { valid: true, parsed: {}, empty: true };
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (typeof parsed !== 'object' || Array.isArray(parsed) || parsed === null) {
|
||||
return { valid: false, parsed: null, empty: false };
|
||||
}
|
||||
return { valid: true, parsed, empty: false };
|
||||
} catch {
|
||||
return { valid: false, parsed: null, empty: false };
|
||||
}
|
||||
}
|
||||
|
||||
function _getDefaultValueForParamType(type) {
|
||||
const normalized = String(type || '').toLowerCase();
|
||||
if (normalized.includes('array')) return [];
|
||||
if (normalized.includes('object')) return {};
|
||||
if (normalized.includes('integer') || normalized.includes('number')) return 0;
|
||||
if (normalized.includes('boolean')) return false;
|
||||
return '';
|
||||
}
|
||||
|
||||
// ── customParams 辅助 ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 根据当前 URL 输入识别 vendor,并把已知参数列表渲染到 hint 行。
|
||||
* registry 还没异步加载完时(detectVendorSync 返回 null)静默跳过。
|
||||
*/
|
||||
function _updateCustomParamsHint($c) {
|
||||
const $hint = $c.find('#amily2_pf_custom_params_hint');
|
||||
if (!$hint.length) return;
|
||||
|
||||
const apiUrl = $c.find('#amily2_pf_url').val()?.trim() || '';
|
||||
const vendorId = detectVendorSync(apiUrl);
|
||||
if (!vendorId) {
|
||||
$hint.empty();
|
||||
return;
|
||||
}
|
||||
|
||||
const params = listVendorParamsSync(vendorId);
|
||||
if (!params.length) {
|
||||
$hint.empty();
|
||||
return;
|
||||
}
|
||||
|
||||
const editorState = _getCustomParamsEditorState($c);
|
||||
getVendorEntry(vendorId).then(entry => {
|
||||
const label = entry?.displayName || vendorId;
|
||||
const disabledAttr = editorState.valid ? '' : ' disabled';
|
||||
const buttons = params.map(param => `
|
||||
<button type="button"
|
||||
class="menu_button small_button amily2_param_hint_btn"
|
||||
data-param-name="${_escapeHtml(param.name)}"
|
||||
data-param-type="${_escapeHtml(param.type || '')}"
|
||||
style="margin:2px 6px 2px 0;"
|
||||
${disabledAttr}>${_escapeHtml(param.name)}</button>
|
||||
`).join('');
|
||||
const invalidNote = editorState.valid
|
||||
? ''
|
||||
: '<span style="margin-left:6px; color:var(--warning, #d9534f);">请先修复 JSON,再插入参数。</span>';
|
||||
$hint.html(`${_escapeHtml(label)} 已知参数:${buttons}${invalidNote}`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 实时校验 customParams 文本框内容。空 / 合法 JSON object → 清空错误。
|
||||
* 非 JSON 或非 object → 在 #_error 行显示。仅做提示,不阻断输入。
|
||||
*/
|
||||
function _validateCustomParamsLive($c) {
|
||||
const $err = $c.find('#amily2_pf_custom_params_error');
|
||||
if (!$err.length) return;
|
||||
|
||||
const state = _getCustomParamsEditorState($c);
|
||||
if (state.empty) {
|
||||
$err.hide().text('');
|
||||
return;
|
||||
}
|
||||
if (state.valid) {
|
||||
$err.hide().text('');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
JSON.parse(($c.find('#amily2_pf_custom_params').val() || '').trim());
|
||||
$err.show().text('需要是 JSON 对象({} 形式),不能是数组或基本类型。');
|
||||
} catch (e) {
|
||||
$err.show().text(`JSON 解析失败:${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function _insertParamToCustomParams($c, paramName, paramType) {
|
||||
const state = _getCustomParamsEditorState($c);
|
||||
if (!state.valid) return;
|
||||
|
||||
const next = { ...(state.parsed || {}) };
|
||||
if (Object.prototype.hasOwnProperty.call(next, paramName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
next[paramName] = _getDefaultValueForParamType(paramType);
|
||||
$c.find('#amily2_pf_custom_params').val(JSON.stringify(next, null, 2));
|
||||
_validateCustomParamsLive($c);
|
||||
_updateCustomParamsHint($c);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除旧配置残留 —— 二次确认 → 调 clearLegacyConfig → 反馈结果。
|
||||
*/
|
||||
function _handleClearLegacyConfig($c) {
|
||||
const confirmed = window.confirm(
|
||||
'【清除旧配置残留】\n\n' +
|
||||
'即将删除以下数据:\n' +
|
||||
'• extension_settings 中各模块的旧 URL / Model / 温度 / maxTokens / 模式等字段\n' +
|
||||
'• localStorage 中各模块的旧 API Key\n\n' +
|
||||
'⚠️ 操作不可恢复。如果某个槽位还没分配 profile,操作会被阻止。\n\n' +
|
||||
'确定继续吗?'
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const result = clearLegacyConfig();
|
||||
if (!result.ok) {
|
||||
toastr.error(result.error || '清除失败,未知错误。', '清除被阻止');
|
||||
return;
|
||||
}
|
||||
toastr.success(
|
||||
`已清除 ${result.clearedFields} 个旧字段、${result.clearedKeys} 个旧 API Key。建议刷新页面验证。`,
|
||||
'清除完成',
|
||||
{ timeOut: 6000 }
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('[ApiConfig] 清除旧配置失败:', e);
|
||||
toastr.error(`清除失败: ${e.message}`, '错误');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* saveProfile 调用:解析 customParams 文本,失败返回 null(调用方中止保存)。
|
||||
* 空文本视为空对象 {}。
|
||||
*
|
||||
* @returns {Object | null}
|
||||
*/
|
||||
function _parseCustomParamsOrFail($c) {
|
||||
const state = _getCustomParamsEditorState($c);
|
||||
return state.valid ? (state.parsed || {}) : null;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import * as IngestionManager from '../core/ingestion-manager.js';
|
||||
import { showContentModal, showHtmlModal } from './page-window.js';
|
||||
import { extractBlocksByTags, applyExclusionRules } from '../core/utils/rag-tag-extractor.js';
|
||||
import { ruleProfileManager, resolveCondensationRuleConfig } from '../utils/config/RuleProfileManager.js';
|
||||
import { syncSlot } from './profile-sync.js';
|
||||
import {
|
||||
filterWorldbooks,
|
||||
filterWorldbookEntries,
|
||||
@@ -152,6 +153,8 @@ export function bindHanlinyuanEvents() {
|
||||
}
|
||||
|
||||
setupGlobalEventHandlers();
|
||||
syncSlot('ragEmbed');
|
||||
syncSlot('ragRerank');
|
||||
bindPanelToggleEvents();
|
||||
bindInternalUIEvents();
|
||||
bindTutorialEvents(); // 【新增】绑定教程按钮事件
|
||||
@@ -161,14 +164,26 @@ export function bindHanlinyuanEvents() {
|
||||
|
||||
// 确保核心已经初始化
|
||||
if (HanlinyuanCore.initialize) {
|
||||
HanlinyuanCore.initialize();
|
||||
try {
|
||||
HanlinyuanCore.initialize();
|
||||
} catch (e) {
|
||||
console.error('[翰林院-枢纽] 核心初始化抛出异常:', e);
|
||||
}
|
||||
} else {
|
||||
console.error('[翰林院-枢纽] 核心法典未能提供初始化圣旨!');
|
||||
return;
|
||||
}
|
||||
|
||||
loadSettingsToUI();
|
||||
loadWorldbookList(); // 【新增】加载书库列表
|
||||
try {
|
||||
loadSettingsToUI();
|
||||
} catch (e) {
|
||||
console.error('[翰林院-枢纽] loadSettingsToUI 抛出异常:', e);
|
||||
}
|
||||
try {
|
||||
loadWorldbookList();
|
||||
} catch (e) {
|
||||
console.error('[翰林院-枢纽] loadWorldbookList 抛出异常:', e);
|
||||
}
|
||||
log('[翰林院-枢纽] 已成功连接各部,政令畅通。', 'info');
|
||||
const fileInput = document.getElementById('hanlinyuan-ingest-novel-file-input');
|
||||
const fileNameSpan = document.getElementById('hanlinyuan-ingest-novel-file-name');
|
||||
@@ -603,7 +618,7 @@ function handleApiModeChange() {
|
||||
}
|
||||
}
|
||||
|
||||
function loadSettingsToUI() {
|
||||
export function loadSettingsToUI() {
|
||||
const settings = HanlinyuanCore.getSettings();
|
||||
if (!settings) return;
|
||||
|
||||
@@ -649,14 +664,17 @@ function loadSettingsToUI() {
|
||||
histMaxRetriesEl.value = settings.historiographyMaxRetries ?? 2;
|
||||
}
|
||||
|
||||
// 新增:加载标签提取设置
|
||||
// 注:hly-tag-extraction-toggle / hly-tag-input / hly-tag-input-container 已从 HTML 移除,
|
||||
// 标签提取规则改由 RuleProfileManager 管理。此处保留兼容性 null 检查,避免抛错吞掉后续段落加载。
|
||||
const tagExtractionToggle = document.getElementById('hly-tag-extraction-toggle');
|
||||
const tagInput = document.getElementById('hly-tag-input');
|
||||
const tagInputContainer = document.getElementById('hly-tag-input-container');
|
||||
|
||||
tagExtractionToggle.checked = settings.condensation.tagExtractionEnabled;
|
||||
tagInput.value = settings.condensation.tags; // 直接使用从核心获取的值
|
||||
tagInputContainer.style.display = tagExtractionToggle.checked ? 'block' : 'none';
|
||||
if (tagExtractionToggle) tagExtractionToggle.checked = settings.condensation.tagExtractionEnabled;
|
||||
if (tagInput) tagInput.value = settings.condensation.tags;
|
||||
if (tagInputContainer && tagExtractionToggle) {
|
||||
tagInputContainer.style.display = tagExtractionToggle.checked ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// Rerank 设置
|
||||
document.getElementById('hly-rerank-enabled').checked = settings.rerank.enabled;
|
||||
|
||||
@@ -490,28 +490,6 @@ function bindNgmsApiEvents() {
|
||||
}
|
||||
});
|
||||
|
||||
// 滑块控件绑定
|
||||
const sliderFields = [
|
||||
{ id: 'amily2_ngms_max_tokens', key: 'ngmsMaxTokens', defaultValue: 4000 },
|
||||
{ id: 'amily2_ngms_temperature', key: 'ngmsTemperature', defaultValue: 0.7 }
|
||||
];
|
||||
|
||||
sliderFields.forEach(field => {
|
||||
const slider = document.getElementById(field.id);
|
||||
const display = document.getElementById(field.id + '_value');
|
||||
if (slider && display) {
|
||||
const value = extension_settings[extensionName][field.key] || field.defaultValue;
|
||||
slider.value = value;
|
||||
display.textContent = value;
|
||||
|
||||
slider.addEventListener('input', function() {
|
||||
const newValue = parseFloat(this.value);
|
||||
display.textContent = newValue;
|
||||
updateAndSaveSetting(field.key, newValue);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// SillyTavern预设选择器
|
||||
const tavernProfileSelect = document.getElementById('amily2_ngms_tavern_profile');
|
||||
if (tavernProfileSelect) {
|
||||
|
||||
@@ -661,8 +661,6 @@ function opt_loadSettings(panel) {
|
||||
}
|
||||
|
||||
syncModelMirror(modelInput.get(0), modelSelect.get(0));
|
||||
panel.find('#amily2_opt_max_tokens').val(settings.plotOpt_max_tokens);
|
||||
panel.find('#amily2_opt_temperature').val(settings.plotOpt_temperature);
|
||||
panel.find('#amily2_opt_top_p').val(settings.plotOpt_top_p);
|
||||
panel.find('#amily2_opt_presence_penalty').val(settings.plotOpt_presence_penalty);
|
||||
panel.find('#amily2_opt_frequency_penalty').val(settings.plotOpt_frequency_penalty);
|
||||
@@ -695,8 +693,6 @@ function opt_loadSettings(panel) {
|
||||
opt_updateApiUrlVisibility(panel, settings.plotOpt_apiMode);
|
||||
opt_updateWorldbookSourceVisibility(panel, settings.plotOpt_worldbookSource || 'character');
|
||||
|
||||
opt_bindSlider(panel, '#amily2_opt_max_tokens', '#amily2_opt_max_tokens_value');
|
||||
opt_bindSlider(panel, '#amily2_opt_temperature', '#amily2_opt_temperature_value');
|
||||
opt_bindSlider(panel, '#amily2_opt_top_p', '#amily2_opt_top_p_value');
|
||||
opt_bindSlider(panel, '#amily2_opt_presence_penalty', '#amily2_opt_presence_penalty_value');
|
||||
opt_bindSlider(panel, '#amily2_opt_frequency_penalty', '#amily2_opt_frequency_penalty_value');
|
||||
@@ -1043,12 +1039,32 @@ function bindConcurrentWorldbookEvents() {
|
||||
});
|
||||
}
|
||||
|
||||
function opt_purgeGarbageKeys() {
|
||||
const store = extension_settings[extensionName];
|
||||
if (!store) return;
|
||||
let removed = 0;
|
||||
for (const key of Object.keys(store)) {
|
||||
// 历史 bug 造成的污染 key:handleSettingChange 误把世界书/条目复选框当作设置项,
|
||||
// 生成形如 plotOpt_amily2-opt-wb-*、plotOpt_amily2-opt-entry-*、plotOpt_amily2-opt-concurrent-wb-* 的键
|
||||
if (/^plotOpt_amily2-opt-/.test(key)) {
|
||||
delete store[key];
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
if (removed > 0) {
|
||||
console.log(`[${extensionName}] 清理残留的 ${removed} 条无效 plotOpt_* 设置键。`);
|
||||
saveSettingsDebounced();
|
||||
}
|
||||
}
|
||||
|
||||
export function initializePlotOptimizationBindings() {
|
||||
const panel = $('#amily2_plot_optimization_panel');
|
||||
if (panel.length === 0 || panel.data('events-bound')) {
|
||||
return;
|
||||
}
|
||||
|
||||
opt_purgeGarbageKeys();
|
||||
|
||||
// Tab switching logic
|
||||
panel.find('.sinan-navigation-deck').on('click', '.sinan-nav-item', function() {
|
||||
const tabButton = $(this);
|
||||
@@ -1179,7 +1195,11 @@ export function initializePlotOptimizationBindings() {
|
||||
|
||||
const handleSettingChange = function(element) {
|
||||
const el = $(element);
|
||||
const key_part = (element.name || element.id).replace('amily2_opt_', '');
|
||||
const rawName = element.name || element.id || '';
|
||||
// 仅处理下划线前缀的真实设置项;动态生成的世界书/条目复选框用连字符命名(amily2-opt-wb-*、amily2-opt-entry-*),
|
||||
// 它们有自己的专属 handler,若被此处捕获会生成 plotOpt_amily2-opt-... 的垃圾 key 污染 settings
|
||||
if (!rawName.startsWith('amily2_opt_')) return;
|
||||
const key_part = rawName.replace('amily2_opt_', '');
|
||||
const key = 'plotOpt_' + key_part.replace(/_([a-z])/g, (g) => g[1].toUpperCase());
|
||||
|
||||
let value = element.type === 'checkbox' ? element.checked : el.val();
|
||||
|
||||
@@ -1,144 +1,200 @@
|
||||
/**
|
||||
* ui/profile-sync.js — API Profile → 子面板 UI 同步
|
||||
* ui/profile-sync.js - Synchronize central API profiles into legacy sub-panels.
|
||||
*
|
||||
* 当某功能槽分配了 Profile 时:
|
||||
* 1. 隐藏对应功能区的 API 连接配置字段(保留温度/Token 等生成参数)
|
||||
* 2. 注入一张状态卡,显示 Profile 信息 + 测试连接 / 获取模型按钮
|
||||
*
|
||||
* 当槽位未分配时:恢复旧字段显示,移除状态卡。
|
||||
*
|
||||
* 用法:
|
||||
* import { syncAllSlots, syncSlot } from './profile-sync.js';
|
||||
* await syncAllSlots(); // 面板初始化时全量同步
|
||||
* await syncSlot('main'); // 单个槽位分配变更时调用
|
||||
*
|
||||
* 外部事件:
|
||||
* document 上监听 'amily2:slotAssigned',detail = { slot }
|
||||
* 由 api-config-bindings.js 在分配变更后 dispatch。
|
||||
* The central API profile assignment is authoritative. Sub-panels only show a
|
||||
* profile selector card and keep legacy URL/key/model fields hidden. When a
|
||||
* profile is assigned we still backfill those hidden fields so older fallback
|
||||
* code that reads from DOM continues to work during the migration.
|
||||
*/
|
||||
|
||||
import { apiProfileManager } from '../utils/config/ApiProfileManager.js';
|
||||
import { apiProfileManager, PROFILE_TYPES, SLOTS } from '../utils/config/ApiProfileManager.js';
|
||||
import { getRequestHeaders } from '/script.js';
|
||||
import { testApiConnection } from '../core/api.js';
|
||||
import { testJqyhApiConnection } from '../core/api/JqyhApi.js';
|
||||
import { testConcurrentApiConnection } from '../core/api/ConcurrentApi.js';
|
||||
import { testNgmsApiConnection } from '../core/api/Ngms_api.js';
|
||||
import { testNccsApiConnection } from '../core/api/NccsApi.js';
|
||||
import { testSybdApiConnection } from '../core/api/SybdApi.js';
|
||||
import { testCwbConnection } from '../CharacterWorldBook/src/cwb_apiService.js';
|
||||
import { testConnection as testAutoCharCardConnection } from '../core/auto-char-card/api.js';
|
||||
import {
|
||||
executeRerank as executeRagRerank,
|
||||
fetchEmbeddingModels as fetchRagEmbeddingModels,
|
||||
fetchRerankModels as fetchRagRerankModels,
|
||||
testApiConnection as testRagEmbeddingConnection,
|
||||
} from '../core/rag-api.js';
|
||||
|
||||
// ── 常量 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
// 用于通过子元素定位父 block 的选择器
|
||||
const BLOCK_SEL = '.amily2_settings_block, .control-group, .amily2_opt_settings_block';
|
||||
|
||||
// 每个槽位在回填 Profile 值前的 DOM 字段快照(用于取消分配时还原)
|
||||
// 结构:{ [slot]: { [selector]: value } }
|
||||
const _fieldSnapshots = {};
|
||||
|
||||
const CARD_CLASS = 'amily2_profile_status_card';
|
||||
const BLOCK_SEL = '.amily2_settings_block, .control-group, .amily2_opt_settings_block, .acc-form-group, .hly-control-block';
|
||||
const CARD_CLASS = 'amily2_profile_status_card';
|
||||
const CARD_SLOT_ATTR = 'data-card-slot';
|
||||
const HIDDEN_ATTR = 'data-profile-hidden';
|
||||
const HIDDEN_ATTR = 'data-profile-hidden';
|
||||
const MASKED_KEY = '••••••••';
|
||||
|
||||
// ── 槽位 → DOM 映射 ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// container : 状态卡注入的父容器(CSS 选择器或 'closest-fieldset:xxx')
|
||||
// hideParentBlock: 通过子元素选择器找到其最近的 BLOCK_SEL 父元素并隐藏
|
||||
// hideDirectly : 直接隐藏的元素选择器
|
||||
// hideWithLabel : 隐藏元素(上溯到容器直接子元素)+ 前一个兄弟 <label>(inline-grid 布局用)
|
||||
// hideInContainer: 在容器内 querySelector 查找并隐藏
|
||||
// fields : { profileKey: domSelector } — 用于回填值(向下兼容 fallback 读取)
|
||||
// keyField : API Key 输入框(回填遮蔽值)
|
||||
// testFn : 测试连接函数(发送真实聊天请求)
|
||||
const _fieldSnapshots = {};
|
||||
|
||||
const SLOT_CONFIGS = {
|
||||
main: {
|
||||
container: 'closest-fieldset:#amily2_api_provider',
|
||||
container: 'closest-fieldset:#amily2_api_provider',
|
||||
hideParentBlock: ['#amily2_api_provider', '#amily2_model_selector'],
|
||||
hideDirectly: ['#amily2_api_url_wrapper', '#amily2_api_key_wrapper', '#amily2_preset_wrapper'],
|
||||
hideWithLabel: [],
|
||||
hideInContainer: [],
|
||||
fields: { provider: '#amily2_api_provider', apiUrl: '#amily2_api_url', model: '#amily2_manual_model_input' },
|
||||
hideDirectly: ['#amily2_api_url_wrapper', '#amily2_api_key_wrapper', '#amily2_preset_wrapper'],
|
||||
fields: { provider: '#amily2_api_provider', apiUrl: '#amily2_api_url', model: '#amily2_manual_model_input' },
|
||||
keyField: '#amily2_api_key',
|
||||
testFn: testApiConnection,
|
||||
testFn: testApiConnection,
|
||||
},
|
||||
plotOpt: {
|
||||
container: '#amily2_opt_custom_api_settings_block',
|
||||
hideParentBlock: [],
|
||||
hideDirectly: [],
|
||||
hideWithLabel: [],
|
||||
hideInContainer: [],
|
||||
fields: { apiUrl: '#amily2_opt_api_url', model: '#amily2_opt_model' },
|
||||
keyField: '#amily2_opt_api_key',
|
||||
testFn: null,
|
||||
container: '#amily2_jqyh_content',
|
||||
hideParentBlock: ['#amily2_jqyh_api_mode'],
|
||||
hideDirectly: ['#amily2_jqyh_compatible_config', '#amily2_jqyh_preset_config'],
|
||||
hideInContainer: ['.jqyh-button-row'],
|
||||
fields: { provider: '#amily2_jqyh_api_mode', apiUrl: '#amily2_jqyh_api_url', model: '#amily2_jqyh_model' },
|
||||
keyField: '#amily2_jqyh_api_key',
|
||||
testFn: testJqyhApiConnection,
|
||||
},
|
||||
plotOptConc: {
|
||||
container: '#amily2_concurrent_content',
|
||||
hideParentBlock: [],
|
||||
hideDirectly: [],
|
||||
hideWithLabel: [
|
||||
container: '#amily2_concurrent_content',
|
||||
hideWithLabel: [
|
||||
'#amily2_plotOpt_concurrentApiProvider',
|
||||
'#amily2_plotOpt_concurrentApiUrl',
|
||||
'#amily2_plotOpt_concurrentApiKey',
|
||||
'#amily2_plotOpt_concurrentModel',
|
||||
],
|
||||
hideInContainer: ['.jqyh-button-row'],
|
||||
fields: { provider: '#amily2_plotOpt_concurrentApiProvider', apiUrl: '#amily2_plotOpt_concurrentApiUrl', model: '#amily2_plotOpt_concurrentModel' },
|
||||
fields: {
|
||||
provider: '#amily2_plotOpt_concurrentApiProvider',
|
||||
apiUrl: '#amily2_plotOpt_concurrentApiUrl',
|
||||
model: '#amily2_plotOpt_concurrentModel',
|
||||
},
|
||||
keyField: '#amily2_plotOpt_concurrentApiKey',
|
||||
testFn: testConcurrentApiConnection,
|
||||
testFn: testConcurrentApiConnection,
|
||||
},
|
||||
nccs: {
|
||||
container: '#nccs-api-config',
|
||||
hideParentBlock: ['#nccs-api-mode', '#nccs-api-url', '#nccs-api-key', '#nccs-api-model', '#nccs-api-fakestream-enabled', '#nccs-sillytavern-preset'],
|
||||
hideDirectly: [],
|
||||
hideWithLabel: [],
|
||||
container: '#nccs-api-config',
|
||||
hideParentBlock: [
|
||||
'#nccs-api-mode',
|
||||
'#nccs-api-url',
|
||||
'#nccs-api-key',
|
||||
'#nccs-api-model',
|
||||
'#nccs-api-fakestream-enabled',
|
||||
'#nccs-sillytavern-preset',
|
||||
],
|
||||
hideInContainer: ['.nccs-button-row'],
|
||||
fields: { apiUrl: '#nccs-api-url', model: '#nccs-api-model' },
|
||||
fields: { provider: '#nccs-api-mode', apiUrl: '#nccs-api-url', model: '#nccs-api-model' },
|
||||
keyField: '#nccs-api-key',
|
||||
testFn: testNccsApiConnection,
|
||||
testFn: testNccsApiConnection,
|
||||
},
|
||||
ngms: {
|
||||
container: '#amily2_ngms_content',
|
||||
container: '#amily2_ngms_content',
|
||||
hideParentBlock: ['#amily2_ngms_api_mode', '#amily2_ngms_fakestream_enabled'],
|
||||
hideDirectly: ['#amily2_ngms_compatible_config', '#amily2_ngms_preset_config'],
|
||||
hideWithLabel: [],
|
||||
hideDirectly: ['#amily2_ngms_compatible_config', '#amily2_ngms_preset_config'],
|
||||
hideInContainer: ['.ngms-button-row'],
|
||||
fields: { apiUrl: '#amily2_ngms_api_url', model: '#amily2_ngms_model' },
|
||||
fields: { provider: '#amily2_ngms_api_mode', apiUrl: '#amily2_ngms_api_url', model: '#amily2_ngms_model' },
|
||||
keyField: '#amily2_ngms_api_key',
|
||||
testFn: testNgmsApiConnection,
|
||||
testFn: testNgmsApiConnection,
|
||||
},
|
||||
sybd: {
|
||||
container: '#amily2_sybd_content',
|
||||
hideParentBlock: ['#amily2_sybd_api_mode'],
|
||||
hideDirectly: ['#amily2_sybd_compatible_config', '#amily2_sybd_preset_config'],
|
||||
hideInContainer: ['.sybd-button-row'],
|
||||
fields: { provider: '#amily2_sybd_api_mode', apiUrl: '#amily2_sybd_api_url', model: '#amily2_sybd_model' },
|
||||
keyField: '#amily2_sybd_api_key',
|
||||
testFn: testSybdApiConnection,
|
||||
},
|
||||
cwb: {
|
||||
container: '#cwb-api-settings-tab',
|
||||
hideDirectly: [
|
||||
'label[for="cwb-api-mode"]',
|
||||
'#cwb-api-mode',
|
||||
'label[for="cwb-api-url"]',
|
||||
'#cwb-api-url',
|
||||
'label[for="cwb-api-key"]',
|
||||
'#cwb-api-key',
|
||||
'label[for="cwb-api-model"]',
|
||||
'#cwb-api-model',
|
||||
'label[for="cwb-tavern-profile"]',
|
||||
'#cwb-tavern-profile',
|
||||
],
|
||||
hideInContainer: ['.jqyh-button-row'],
|
||||
fields: { provider: '#cwb-api-mode', apiUrl: '#cwb-api-url', model: '#cwb-api-model' },
|
||||
keyField: '#cwb-api-key',
|
||||
testFn: testCwbConnection,
|
||||
},
|
||||
autoCharCard: {
|
||||
container: '#acc-api-settings-content',
|
||||
hideParentBlock: ['#acc-executor-url', '#acc-executor-key', '#acc-executor-model'],
|
||||
hideDirectly: ['#acc-executor-refresh-models', '#acc-executor-test', '#acc-save-api'],
|
||||
fields: { apiUrl: '#acc-executor-url', model: '#acc-executor-model' },
|
||||
keyField: '#acc-executor-key',
|
||||
testFn: async () => testAutoCharCardConnection('executor'),
|
||||
},
|
||||
ragEmbed: {
|
||||
container: '#hly-retrieval-tab .hly-settings-group',
|
||||
hideParentBlock: ['#hly-api-endpoint', '#hly-custom-api-url', '#hly-api-key', '#hly-embedding-model'],
|
||||
hideDirectly: [
|
||||
'button[onclick="testHLYApi()"]',
|
||||
'button[onclick="fetchHLYEmbeddingModels()"]',
|
||||
],
|
||||
fields: { provider: '#hly-api-endpoint', apiUrl: '#hly-custom-api-url', model: '#hly-embedding-model' },
|
||||
keyField: '#hly-api-key',
|
||||
testFn: async () => {
|
||||
await testRagEmbeddingConnection();
|
||||
return true;
|
||||
},
|
||||
fetchModelsFn: fetchRagEmbeddingModels,
|
||||
},
|
||||
ragRerank: {
|
||||
container: '#hly-rerank-tab .hly-settings-group',
|
||||
hideParentBlock: ['#hly-rerank-api-mode', '#hly-rerank-url', '#hly-rerank-api-key', '#hly-rerank-model'],
|
||||
fields: { provider: '#hly-rerank-api-mode', apiUrl: '#hly-rerank-url', model: '#hly-rerank-model' },
|
||||
keyField: '#hly-rerank-api-key',
|
||||
testFn: async () => {
|
||||
await executeRagRerank('test', ['test'], null);
|
||||
return true;
|
||||
},
|
||||
fetchModelsFn: fetchRagRerankModels,
|
||||
},
|
||||
};
|
||||
|
||||
// ── 公开 API ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** 同步单个槽位到对应 DOM 区域。 */
|
||||
export async function syncSlot(slot) {
|
||||
const config = SLOT_CONFIGS[slot];
|
||||
if (!config) return;
|
||||
|
||||
const profile = await apiProfileManager.getAssignedProfile(slot);
|
||||
|
||||
// 先清理:移除旧卡片、恢复被隐藏的元素
|
||||
_removeCard(slot);
|
||||
_restoreHidden(slot);
|
||||
|
||||
if (!profile) {
|
||||
// 取消分配:将 DOM 字段值还原为分配 Profile 前的快照,
|
||||
// 防止残留的 Profile 回填值(尤其是 '••••••••' 的 Key 占位符)
|
||||
// 因 blur 事件被误存入 extension_settings / localStorage。
|
||||
const snap = _fieldSnapshots[slot];
|
||||
if (snap) {
|
||||
for (const [sel, val] of Object.entries(snap)) {
|
||||
const el = document.querySelector(sel);
|
||||
if (el) el.value = val;
|
||||
}
|
||||
delete _fieldSnapshots[slot];
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const container = _resolveContainer(config.container);
|
||||
if (!container) return;
|
||||
|
||||
// 回填前先快照各字段当前值(即 extension_settings / configManager 中的真实值),
|
||||
// 以便取消分配时能还原,避免 Profile 值污染旧配置。
|
||||
_removeCard(slot);
|
||||
_restoreHidden(slot);
|
||||
_snapshotLegacyFields(slot, config);
|
||||
|
||||
const profile = await apiProfileManager.getAssignedProfile(slot);
|
||||
if (profile) _fillLegacyFields(config, profile);
|
||||
|
||||
_hideApiFields(config, container, slot);
|
||||
_injectCard(slot, profile, config, container);
|
||||
}
|
||||
|
||||
export async function syncAllSlots() {
|
||||
await Promise.all(Object.keys(SLOT_CONFIGS).map(syncSlot));
|
||||
}
|
||||
|
||||
document.addEventListener('amily2:slotAssigned', (e) => {
|
||||
const slot = e.detail?.slot;
|
||||
if (slot) syncSlot(slot);
|
||||
});
|
||||
|
||||
function _resolveContainer(spec) {
|
||||
if (!spec) return null;
|
||||
if (spec.startsWith('closest-fieldset:')) {
|
||||
const anchorSel = spec.slice('closest-fieldset:'.length);
|
||||
const anchor = document.querySelector(anchorSel);
|
||||
return anchor?.closest('fieldset') ?? null;
|
||||
}
|
||||
return document.querySelector(spec);
|
||||
}
|
||||
|
||||
function _snapshotLegacyFields(slot, config) {
|
||||
if (_fieldSnapshots[slot]) return;
|
||||
|
||||
const snap = {};
|
||||
for (const sel of Object.values(config.fields || {})) {
|
||||
const el = document.querySelector(sel);
|
||||
@@ -149,53 +205,19 @@ export async function syncSlot(slot) {
|
||||
if (keyEl) snap[config.keyField] = keyEl.value;
|
||||
}
|
||||
_fieldSnapshots[slot] = snap;
|
||||
}
|
||||
|
||||
// 回填值(向下兼容:部分代码仍从 DOM 读取 fallback)
|
||||
function _fillLegacyFields(config, profile) {
|
||||
for (const [key, sel] of Object.entries(config.fields || {})) {
|
||||
const el = document.querySelector(sel);
|
||||
if (el) el.value = profile[key] ?? '';
|
||||
}
|
||||
if (config.keyField) {
|
||||
const keyEl = document.querySelector(config.keyField);
|
||||
if (keyEl) keyEl.value = profile.apiKey ? '••••••••' : '';
|
||||
if (keyEl) keyEl.value = profile.apiKey ? MASKED_KEY : '';
|
||||
}
|
||||
|
||||
// 隐藏 API 连接字段(保留温度 / 最大 Token 等生成参数)
|
||||
_hideApiFields(config, container, slot);
|
||||
|
||||
// 注入状态卡
|
||||
_injectCard(slot, profile, config, container);
|
||||
}
|
||||
|
||||
/** 同步所有槽位(面板初始化时调用)。 */
|
||||
export async function syncAllSlots() {
|
||||
await Promise.all(Object.keys(SLOT_CONFIGS).map(syncSlot));
|
||||
}
|
||||
|
||||
// ── 事件监听:响应 api-config-bindings 的 slotAssigned 事件 ──────────────────
|
||||
|
||||
document.addEventListener('amily2:slotAssigned', (e) => {
|
||||
const slot = e.detail?.slot;
|
||||
if (slot) syncSlot(slot);
|
||||
});
|
||||
|
||||
// ── 内部:容器定位 ──────────────────────────────────────────────────────────────
|
||||
|
||||
function _resolveContainer(spec) {
|
||||
if (!spec) return null;
|
||||
|
||||
// 'closest-fieldset:#amily2_api_provider' → 从该元素向上找 fieldset
|
||||
if (spec.startsWith('closest-fieldset:')) {
|
||||
const anchorSel = spec.slice('closest-fieldset:'.length);
|
||||
const anchor = document.querySelector(anchorSel);
|
||||
return anchor?.closest('fieldset') ?? null;
|
||||
}
|
||||
|
||||
return document.querySelector(spec);
|
||||
}
|
||||
|
||||
// ── 内部:隐藏 / 恢复 API 字段 ──────────────────────────────────────────────────
|
||||
|
||||
function _hideEl(el, slot) {
|
||||
if (!el || el.hasAttribute(HIDDEN_ATTR)) return;
|
||||
el.setAttribute(HIDDEN_ATTR, slot);
|
||||
@@ -212,7 +234,6 @@ function _restoreHidden(slot) {
|
||||
}
|
||||
|
||||
function _hideApiFields(config, container, slot) {
|
||||
// 1. 通过子元素找到其父 block 并隐藏
|
||||
(config.hideParentBlock || []).forEach(sel => {
|
||||
const el = document.querySelector(sel);
|
||||
if (!el) return;
|
||||
@@ -220,90 +241,112 @@ function _hideApiFields(config, container, slot) {
|
||||
if (block && block !== container) _hideEl(block, slot);
|
||||
});
|
||||
|
||||
// 2. 直接隐藏指定元素
|
||||
(config.hideDirectly || []).forEach(sel => {
|
||||
const el = document.querySelector(sel);
|
||||
if (el) _hideEl(el, slot);
|
||||
});
|
||||
|
||||
// 3. 隐藏元素(上溯到容器直接子元素)+ 前一个兄弟 label(inline-grid 布局)
|
||||
(config.hideWithLabel || []).forEach(sel => {
|
||||
const el = document.querySelector(sel);
|
||||
if (!el) return;
|
||||
// 沿 DOM 树上溯到容器的直接子元素
|
||||
|
||||
let target = el;
|
||||
while (target.parentElement && target.parentElement !== container) {
|
||||
target = target.parentElement;
|
||||
}
|
||||
|
||||
_hideEl(target, slot);
|
||||
const prev = target.previousElementSibling;
|
||||
if (prev && prev.tagName === 'LABEL') _hideEl(prev, slot);
|
||||
});
|
||||
|
||||
// 4. 在容器内查找并隐藏
|
||||
(config.hideInContainer || []).forEach(sel => {
|
||||
const el = container.querySelector(sel);
|
||||
if (el) _hideEl(el, slot);
|
||||
container.querySelectorAll(sel).forEach(el => _hideEl(el, slot));
|
||||
});
|
||||
}
|
||||
|
||||
// ── 内部:状态卡 ──────────────────────────────────────────────────────────────
|
||||
|
||||
function _removeCard(slot) {
|
||||
document.querySelectorAll(`.${CARD_CLASS}[${CARD_SLOT_ATTR}="${slot}"]`)
|
||||
.forEach(el => el.remove());
|
||||
}
|
||||
|
||||
function _injectCard(slot, profile, _config, container) {
|
||||
const slotInfo = SLOTS[slot] || { label: slot, type: 'chat' };
|
||||
const typeInfo = PROFILE_TYPES[slotInfo.type] || {};
|
||||
const assigned = apiProfileManager.getAssignment(slot) || '';
|
||||
const profiles = apiProfileManager.getProfiles(slotInfo.type);
|
||||
const providerLabel = _providerLabel(profile?.provider);
|
||||
|
||||
const options = [
|
||||
`<option value="">-- 未分配,请选择 API 连接 --</option>`,
|
||||
...profiles.map(p =>
|
||||
`<option value="${_esc(p.id)}" ${p.id === assigned ? 'selected' : ''}>${_esc(p.name)}</option>`
|
||||
),
|
||||
].join('');
|
||||
|
||||
const detailHtml = profile ? `
|
||||
<span style="color:var(--SmartThemeQuoteColor); font-size:0.85em;">
|
||||
${providerLabel ? `<i class="fas fa-cloud"></i> ${_esc(providerLabel)}` : ''}
|
||||
${profile.model ? ` · <i class="fas fa-robot"></i> ${_esc(profile.model)}` : ''}
|
||||
</span>
|
||||
` : `
|
||||
<span style="color:var(--warning-color); font-size:0.85em;">
|
||||
未分配时该模块不会继续展示/保存独立 API 输入项。
|
||||
</span>
|
||||
`;
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = CARD_CLASS;
|
||||
card.setAttribute(CARD_SLOT_ATTR, slot);
|
||||
card.style.cssText = [
|
||||
'padding:10px 14px', 'margin:6px 0 10px',
|
||||
'padding:10px 14px',
|
||||
'margin:6px 0 10px',
|
||||
'background:var(--black10a)',
|
||||
'border:1px solid var(--SmartThemeBorderColor)',
|
||||
'border-radius:6px', 'font-size:0.88em',
|
||||
'border-radius:6px',
|
||||
'font-size:0.88em',
|
||||
].join(';');
|
||||
|
||||
const providerLabel = {
|
||||
openai: 'OpenAI 兼容',
|
||||
openai_test: '全兼容',
|
||||
google: 'Google Gemini',
|
||||
sillytavern_backend: 'ST 后端',
|
||||
sillytavern_preset: 'ST 预设',
|
||||
}[profile.provider] || profile.provider || '';
|
||||
|
||||
card.innerHTML = `
|
||||
<div style="display:flex; align-items:center; gap:8px; margin-bottom:8px;">
|
||||
<i class="fas fa-link" style="color:var(--green,#4caf50);"></i>
|
||||
<span style="font-weight:600;">${_esc(profile.name)}</span>
|
||||
<span style="color:var(--SmartThemeQuoteColor); font-size:0.85em;">
|
||||
${providerLabel ? `<i class="fas fa-cloud"></i> ${_esc(providerLabel)}` : ''}
|
||||
${profile.model ? ` · <i class="fas fa-robot"></i> ${_esc(profile.model)}` : ''}
|
||||
</span>
|
||||
<span class="amily2_psc_goto" style="margin-left:auto; opacity:0.6; font-size:0.85em; cursor:pointer;"
|
||||
title="前往 API 配置页面">
|
||||
<div style="display:flex; align-items:center; gap:8px; margin-bottom:8px; flex-wrap:wrap;">
|
||||
<i class="fas ${_esc(typeInfo.icon || 'fa-link')}" style="color:var(--green,#4caf50);"></i>
|
||||
<span style="font-weight:600;">${_esc(slotInfo.label)}</span>
|
||||
${detailHtml}
|
||||
<span class="amily2_psc_goto" style="margin-left:auto; opacity:0.7; font-size:0.85em; cursor:pointer;"
|
||||
title="前往统一 API 配置页">
|
||||
<i class="fas fa-cog"></i> 管理
|
||||
</span>
|
||||
</div>
|
||||
<select class="text_pole amily2_psc_select" data-slot="${_esc(slot)}" style="width:100%; margin-bottom:8px;">
|
||||
${options}
|
||||
</select>
|
||||
<div style="display:flex; gap:6px; flex-wrap:wrap;">
|
||||
<button class="menu_button small_button interactable amily2_psc_test" type="button">
|
||||
<button class="menu_button small_button interactable amily2_psc_test" type="button" ${profile ? '' : 'disabled'}>
|
||||
<i class="fas fa-plug"></i> 测试连接
|
||||
</button>
|
||||
<button class="menu_button small_button interactable amily2_psc_fetch" type="button">
|
||||
<button class="menu_button small_button interactable amily2_psc_fetch" type="button" ${profile ? '' : 'disabled'}>
|
||||
<i class="fas fa-list"></i> 获取模型
|
||||
</button>
|
||||
<span class="amily2_psc_result" style="font-size:0.85em; display:flex; align-items:center; margin-left:4px;"></span>
|
||||
</div>`;
|
||||
|
||||
// 绑定按钮事件
|
||||
card.querySelector('.amily2_psc_goto').addEventListener('click', () => {
|
||||
document.getElementById('amily2_open_api_config')?.click();
|
||||
});
|
||||
|
||||
card.querySelector('.amily2_psc_select').addEventListener('change', function () {
|
||||
const id = this.value || null;
|
||||
if (!apiProfileManager.setAssignment(slot, id)) {
|
||||
toastr.error('配置类型不匹配,分配失败。');
|
||||
syncSlot(slot);
|
||||
return;
|
||||
}
|
||||
document.dispatchEvent(new CustomEvent('amily2:slotAssigned', { detail: { slot } }));
|
||||
});
|
||||
|
||||
card.querySelector('.amily2_psc_test').addEventListener('click', () => _testSlot(slot, card));
|
||||
card.querySelector('.amily2_psc_fetch').addEventListener('click', () => _fetchSlotModels(slot, card));
|
||||
|
||||
// 插入到 legend 之后(fieldset)或容器开头
|
||||
const legend = container.querySelector(':scope > legend');
|
||||
if (legend) {
|
||||
legend.insertAdjacentElement('afterend', card);
|
||||
@@ -312,30 +355,33 @@ function _injectCard(slot, profile, _config, container) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 内部:测试连接(调用各模块的真实测试函数,发送聊天请求)──────────────────────
|
||||
|
||||
async function _testSlot(slot, card) {
|
||||
const $btn = $(card.querySelector('.amily2_psc_test')).prop('disabled', true);
|
||||
const $btn = $(card.querySelector('.amily2_psc_test')).prop('disabled', true);
|
||||
const $result = $(card.querySelector('.amily2_psc_result'));
|
||||
$btn.html('<i class="fas fa-spinner fa-spin"></i> 测试中...');
|
||||
$result.text('').css('color', '');
|
||||
|
||||
try {
|
||||
const testFn = SLOT_CONFIGS[slot]?.testFn;
|
||||
if (!testFn) {
|
||||
$result.text('该槽位不支持测试').css('color', 'var(--warning-color)');
|
||||
const profile = await apiProfileManager.getAssignedProfile(slot);
|
||||
if (!profile) {
|
||||
$result.text('槽位未分配').css('color', 'var(--warning-color)');
|
||||
return;
|
||||
}
|
||||
|
||||
// 调用模块原生测试函数(发送 "你好!" 聊天请求验证连接)
|
||||
const success = await testFn();
|
||||
const testFn = SLOT_CONFIGS[slot]?.testFn;
|
||||
if (!testFn) {
|
||||
$result.text('该槽位暂不支持快捷测试').css('color', 'var(--warning-color)');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await testFn();
|
||||
const success = typeof result === 'object' ? result?.success : result;
|
||||
|
||||
if (success === true) {
|
||||
$result.text('测试通过').css('color', 'var(--green)');
|
||||
} else if (success === false) {
|
||||
$result.text('测试失败(详见弹窗)').css('color', 'var(--warning-color)');
|
||||
$result.text(result?.error || '测试失败,请查看弹窗/控制台').css('color', 'var(--warning-color)');
|
||||
}
|
||||
// undefined = 函数未执行(如 DOM 依赖缺失),不更新卡片
|
||||
} catch (e) {
|
||||
$result.text(`错误:${e.message}`).css('color', 'var(--warning-color)');
|
||||
} finally {
|
||||
@@ -343,10 +389,8 @@ async function _testSlot(slot, card) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 内部:获取模型列表 ──────────────────────────────────────────────────────────
|
||||
|
||||
async function _fetchSlotModels(slot, card) {
|
||||
const $btn = $(card.querySelector('.amily2_psc_fetch')).prop('disabled', true);
|
||||
const $btn = $(card.querySelector('.amily2_psc_fetch')).prop('disabled', true);
|
||||
const $result = $(card.querySelector('.amily2_psc_result'));
|
||||
$btn.html('<i class="fas fa-spinner fa-spin"></i> 获取中...');
|
||||
$result.text('').css('color', '');
|
||||
@@ -358,60 +402,20 @@ async function _fetchSlotModels(slot, card) {
|
||||
return;
|
||||
}
|
||||
|
||||
// ST 预设由酒馆管理,无法获取模型列表
|
||||
if (profile.provider === 'sillytavern_preset' || profile.provider === 'sillytavern_backend') {
|
||||
$result.text('ST 预设/后端管理,无需获取').css('color', 'var(--SmartThemeQuoteColor)');
|
||||
return;
|
||||
}
|
||||
|
||||
let models = [];
|
||||
|
||||
if (profile.provider === 'google') {
|
||||
if (!profile.apiKey) {
|
||||
$result.text('API Key 为空').css('color', 'var(--warning-color)');
|
||||
return;
|
||||
}
|
||||
const resp = await fetch(
|
||||
'https://generativelanguage.googleapis.com/v1beta/models',
|
||||
{ headers: { 'x-goog-api-key': profile.apiKey } }
|
||||
);
|
||||
if (!resp.ok) {
|
||||
$result.text(`失败:HTTP ${resp.status}`).css('color', 'var(--warning-color)');
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
models = (data.models ?? [])
|
||||
.filter(m => m.supportedGenerationMethods?.some(
|
||||
method => ['generateContent', 'embedContent'].includes(method)
|
||||
))
|
||||
.map(m => m.name.replace(/^models\//, ''));
|
||||
} else {
|
||||
// OpenAI 兼容 — 通过 ST 后端代理获取模型列表
|
||||
const resp = await fetch('/api/backends/chat-completions/status', {
|
||||
method: 'POST',
|
||||
headers: { ...getRequestHeaders(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
reverse_proxy: profile.apiUrl,
|
||||
proxy_password: profile.apiKey,
|
||||
chat_completion_source: 'openai',
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
$result.text(`失败:HTTP ${resp.status}`).css('color', 'var(--warning-color)');
|
||||
return;
|
||||
}
|
||||
const rawData = await resp.json();
|
||||
const list = Array.isArray(rawData) ? rawData : (rawData.data ?? rawData.models ?? []);
|
||||
models = list.map(m => m.id ?? m.name ?? m).filter(m => typeof m === 'string' && m);
|
||||
}
|
||||
|
||||
const customFetch = SLOT_CONFIGS[slot]?.fetchModelsFn;
|
||||
const models = customFetch ? await customFetch() : await _loadModels(profile);
|
||||
if (models.length === 0) {
|
||||
$result.text('未获取到模型').css('color', 'var(--warning-color)');
|
||||
return;
|
||||
}
|
||||
|
||||
const current = profile.model;
|
||||
const inList = current && models.includes(current);
|
||||
const inList = current && models.includes(current);
|
||||
$result.html(
|
||||
`<span style="color:var(--green);">${models.length} 个模型</span>` +
|
||||
(current ? ` · 当前: <b>${_esc(current)}</b> ${inList ? '✓' : '<span style="color:var(--warning-color);">(不在列表中)</span>'}` : '')
|
||||
@@ -424,10 +428,54 @@ async function _fetchSlotModels(slot, card) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 工具 ──────────────────────────────────────────────────────────────────────
|
||||
async function _loadModels(profile) {
|
||||
if (profile.provider === 'google') {
|
||||
if (!profile.apiKey) throw new Error('API Key 为空');
|
||||
const resp = await fetch(
|
||||
'https://generativelanguage.googleapis.com/v1beta/models',
|
||||
{ headers: { 'x-goog-api-key': profile.apiKey } }
|
||||
);
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
return (data.models ?? [])
|
||||
.filter(m => m.supportedGenerationMethods?.some(method => ['generateContent', 'embedContent'].includes(method)))
|
||||
.map(m => m.name.replace(/^models\//, ''))
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
const resp = await fetch('/api/backends/chat-completions/status', {
|
||||
method: 'POST',
|
||||
headers: { ...getRequestHeaders(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
reverse_proxy: profile.apiUrl,
|
||||
proxy_password: profile.apiKey,
|
||||
chat_completion_source: 'openai',
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
|
||||
const rawData = await resp.json();
|
||||
const list = Array.isArray(rawData) ? rawData : (rawData.data ?? rawData.models ?? []);
|
||||
return list
|
||||
.map(m => m.id ?? m.name ?? m)
|
||||
.filter(m => typeof m === 'string' && m)
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
function _providerLabel(provider) {
|
||||
return {
|
||||
openai: 'OpenAI 兼容',
|
||||
openai_test: '全兼容',
|
||||
google: 'Google Gemini',
|
||||
sillytavern_backend: 'ST 后端',
|
||||
sillytavern_preset: 'ST 预设',
|
||||
}[provider] || provider || '';
|
||||
}
|
||||
|
||||
function _esc(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"');
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
157
utils/api-vendor.js
Normal file
157
utils/api-vendor.js
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* @file API 厂商识别 + 参数 registry 查询。
|
||||
*
|
||||
* Registry 文件:assets/api-vendor-params.json
|
||||
* 加载策略:模块首次调用时 fetch 一次,缓存到 _registry。
|
||||
*
|
||||
* 提供的能力:
|
||||
* - detectVendor(apiUrl) → vendorId | null
|
||||
* - getVendorEntry(vendorId) → 完整 vendor 对象(含 params 元信息)
|
||||
* - listVendorParams(vendorId) → [{ name, type, desc, ... }]
|
||||
* - getRegistry() → 整个 registry(debug 用)
|
||||
*
|
||||
* Phase A 仅用于 customParams 编辑器的提示展示,不做强制校验。
|
||||
* Phase B 计划:迁移现有 15 处散乱的 `apiUrl.includes('googleapis.com')` 等检查到 detectVendor 单一入口。
|
||||
*/
|
||||
|
||||
import { extensionName } from './settings.js';
|
||||
|
||||
const REGISTRY_PATH = `scripts/extensions/third-party/${extensionName}/assets/api-vendor-params.json`;
|
||||
|
||||
/** @type {Promise<any> | null} */
|
||||
let _registryPromise = null;
|
||||
/** @type {any | null} */
|
||||
let _registry = null;
|
||||
|
||||
/**
|
||||
* 懒加载 registry,缓存到模块作用域。多次调用只 fetch 一次。
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
async function _loadRegistry() {
|
||||
if (_registry) return _registry;
|
||||
if (!_registryPromise) {
|
||||
_registryPromise = fetch(REGISTRY_PATH)
|
||||
.then(res => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} 加载 ${REGISTRY_PATH} 失败`);
|
||||
return res.json();
|
||||
})
|
||||
.then(data => {
|
||||
_registry = data;
|
||||
return data;
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('[api-vendor] registry 加载失败:', err);
|
||||
// 降级到内置最小 fallback,保证业务不中断
|
||||
_registry = {
|
||||
version: 0,
|
||||
vendors: [],
|
||||
fallback: { id: 'openai-compat', displayName: 'OpenAI-compatible', params: {} },
|
||||
};
|
||||
return _registry;
|
||||
});
|
||||
}
|
||||
return _registryPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制刷新 registry(开发期热更新或测试用)。
|
||||
*/
|
||||
export async function reloadRegistry() {
|
||||
_registry = null;
|
||||
_registryPromise = null;
|
||||
return _loadRegistry();
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回完整 registry。供 UI 列举所有 vendor、debug 用。
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
export async function getRegistry() {
|
||||
return _loadRegistry();
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 apiUrl 识别 vendor。匹配不上时返回 fallback.id(默认 'openai-compat')。
|
||||
* 大小写不敏感的 substring 匹配。
|
||||
*
|
||||
* @param {string} apiUrl
|
||||
* @returns {Promise<string | null>}
|
||||
*/
|
||||
export async function detectVendor(apiUrl) {
|
||||
const reg = await _loadRegistry();
|
||||
const url = (apiUrl || '').toLowerCase();
|
||||
if (!url) return reg.fallback?.id || null;
|
||||
|
||||
for (const vendor of reg.vendors || []) {
|
||||
const matches = vendor.match || [];
|
||||
if (matches.some(m => url.includes(String(m).toLowerCase()))) {
|
||||
return vendor.id;
|
||||
}
|
||||
}
|
||||
return reg.fallback?.id || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 vendorId 取完整 vendor 对象(含 params 元信息)。
|
||||
* fallback id 也能查到。
|
||||
*
|
||||
* @param {string | null | undefined} vendorId
|
||||
* @returns {Promise<any | null>}
|
||||
*/
|
||||
export async function getVendorEntry(vendorId) {
|
||||
if (!vendorId) return null;
|
||||
const reg = await _loadRegistry();
|
||||
if (reg.fallback && vendorId === reg.fallback.id) return reg.fallback;
|
||||
return (reg.vendors || []).find(v => v.id === vendorId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出指定 vendor 的所有标准参数(已过滤掉 _doc / _warning_* 这类 meta 字段)。
|
||||
*
|
||||
* @param {string | null | undefined} vendorId
|
||||
* @returns {Promise<Array<{ name: string, type?: string, range?: number[], values?: string[], desc?: string }>>}
|
||||
*/
|
||||
export async function listVendorParams(vendorId) {
|
||||
const entry = await getVendorEntry(vendorId);
|
||||
if (!entry || !entry.params) return [];
|
||||
return Object.entries(entry.params)
|
||||
.filter(([k]) => !k.startsWith('_'))
|
||||
.map(([name, meta]) => ({ name, ...(meta || {}) }));
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步版:从已加载的 registry 直接查询。仅在确知已 await 过 _loadRegistry 后使用,
|
||||
* 主要给 UI render 循环用(避免 React-style 异步重渲染)。
|
||||
* registry 未加载时返回空。
|
||||
*
|
||||
* @param {string} apiUrl
|
||||
* @returns {string | null}
|
||||
*/
|
||||
export function detectVendorSync(apiUrl) {
|
||||
if (!_registry) return null;
|
||||
const url = (apiUrl || '').toLowerCase();
|
||||
if (!url) return _registry.fallback?.id || null;
|
||||
for (const vendor of _registry.vendors || []) {
|
||||
const matches = vendor.match || [];
|
||||
if (matches.some(m => url.includes(String(m).toLowerCase()))) {
|
||||
return vendor.id;
|
||||
}
|
||||
}
|
||||
return _registry.fallback?.id || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步版 listVendorParams。同样要求 registry 已 preload。
|
||||
* @param {string | null | undefined} vendorId
|
||||
* @returns {Array<{ name: string, type?: string, range?: number[], values?: string[], desc?: string }>}
|
||||
*/
|
||||
export function listVendorParamsSync(vendorId) {
|
||||
if (!_registry || !vendorId) return [];
|
||||
let entry = null;
|
||||
if (_registry.fallback && vendorId === _registry.fallback.id) entry = _registry.fallback;
|
||||
else entry = (_registry.vendors || []).find(v => v.id === vendorId) || null;
|
||||
if (!entry || !entry.params) return [];
|
||||
return Object.entries(entry.params)
|
||||
.filter(([k]) => !k.startsWith('_'))
|
||||
.map(([name, meta]) => ({ name, ...(meta || {}) }));
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -35,6 +35,7 @@ import { extension_settings } from "/scripts/extensions.js";
|
||||
import { saveSettingsDebounced } from "/script.js";
|
||||
import { extensionName } from "../settings.js";
|
||||
import { apiKeyStore } from "./api-key-store/ApiKeyStore.js";
|
||||
import { configManager } from "./ConfigManager.js";
|
||||
|
||||
// ── 类型与功能槽定义 ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -71,6 +72,7 @@ export const SLOTS = {
|
||||
cwb: { label: '角色世界书', type: 'chat' },
|
||||
autoCharCard: { label: '一键生卡', type: 'chat' },
|
||||
sybd: { label: '术语表填写', type: 'chat' },
|
||||
tableFilling: { label: '表格填表 / 重整', type: 'chat' },
|
||||
// Embedding 槽
|
||||
ragEmbed: { label: 'RAG 向量化', type: 'embedding' },
|
||||
// Rerank 槽
|
||||
@@ -252,6 +254,11 @@ class ApiProfileManager {
|
||||
...base,
|
||||
maxTokens: data.maxTokens ?? 65500,
|
||||
temperature: data.temperature ?? 1.0,
|
||||
// 自定义参数:透传到 LLM 请求 body 的额外 key/value(top_p、frequency_penalty 等)
|
||||
// 由 utils/api-vendor.js 提供 vendor 标准参数提示,但不强校验。
|
||||
customParams: (typeof data.customParams === 'object' && data.customParams !== null)
|
||||
? data.customParams
|
||||
: {},
|
||||
};
|
||||
}
|
||||
if (type === 'embedding') {
|
||||
@@ -295,6 +302,278 @@ export const apiProfileManager = new ApiProfileManager();
|
||||
}
|
||||
})();
|
||||
|
||||
// ── Profile.provider 迁移 ────────────────────────────────────────────────────
|
||||
// Phase B 改造:旧 'openai' 是"OpenAI 兼容总称",现在拆为 6 个具体 vendor + 'custom_oai'。
|
||||
// 按 URL substring 推断真实 vendor;推断不出来 → 改成 'custom_oai';URL 为空 → 保持 'openai'。
|
||||
// 仅迁移 provider==='openai' 的旧 profile,新值(anthropic/openrouter/deepseek/xai/custom_oai/google/...)一概不动。
|
||||
function _detectVendorFromUrlSync(url) {
|
||||
if (!url) return null;
|
||||
const lower = String(url).toLowerCase();
|
||||
if (lower.includes('anthropic.com')) return 'anthropic';
|
||||
if (lower.includes('openrouter.ai')) return 'openrouter';
|
||||
if (lower.includes('googleapis.com') || lower.includes('aistudio.google.com')) return 'google';
|
||||
if (lower.includes('deepseek.com')) return 'deepseek';
|
||||
if (lower.includes('x.ai') || lower.includes('xai.com')) return 'xai';
|
||||
if (lower.includes('openai.com')) return 'openai';
|
||||
return null;
|
||||
}
|
||||
|
||||
;(() => {
|
||||
try {
|
||||
const s = extension_settings[extensionName];
|
||||
if (!s || !Array.isArray(s[EXT_PROFILES])) return;
|
||||
let migratedCount = 0;
|
||||
for (const profile of s[EXT_PROFILES]) {
|
||||
if (profile?.provider !== 'openai') continue; // 已是新值或非 chat profile
|
||||
const detected = _detectVendorFromUrlSync(profile.apiUrl);
|
||||
if (detected && detected !== 'openai') {
|
||||
profile.provider = detected;
|
||||
migratedCount++;
|
||||
} else if (profile.apiUrl && !detected) {
|
||||
// URL 填了但不匹配任何已知厂商 → 标记为 custom_oai
|
||||
profile.provider = 'custom_oai';
|
||||
migratedCount++;
|
||||
}
|
||||
// URL 为空(新建中)或确实是 openai.com → 保持 'openai'
|
||||
}
|
||||
if (migratedCount > 0) {
|
||||
console.info(`[ApiProfiles] 迁移: ${migratedCount} 个 profile 的 provider 字段已按 URL 重分类。`);
|
||||
saveSettingsDebounced();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[ApiProfiles] provider 迁移失败:', e);
|
||||
}
|
||||
})();
|
||||
|
||||
// ── Legacy → Profile 自动迁移(v2.1.x)─────────────────────────────────────
|
||||
// 对每个 chat slot:若没分配 profile 且旧字段(apiUrl + model 都填了)存在,
|
||||
// 自动建一个 profile + 迁移 API Key + 分配给该 slot。
|
||||
// 幂等:通过 _legacyProfileMigrationDone 标记,只在首次 ship 后跑一次。
|
||||
// 旧字段保留不动,由"清除旧配置残留"按钮显式清理。
|
||||
|
||||
/**
|
||||
* 每个 slot 的 legacy 字段映射。jqyh 已合并到 plotOpt 不单独迁移。
|
||||
* cwb / autoCharCard / ragEmbed / ragRerank 字段结构差异较大,留作后续。
|
||||
*/
|
||||
const LEGACY_PROFILE_MIGRATION_MAP = [
|
||||
{
|
||||
slot: 'main',
|
||||
urlKey: 'apiUrl',
|
||||
modelKey: 'model',
|
||||
keyName: 'apiKey',
|
||||
maxTokensKey: 'maxTokens',
|
||||
temperatureKey: 'temperature',
|
||||
name: '主面板 旧配置',
|
||||
},
|
||||
{
|
||||
slot: 'plotOpt',
|
||||
urlKey: 'plotOpt_apiUrl',
|
||||
modelKey: 'plotOpt_model',
|
||||
keyName: 'plotOpt_apiKey',
|
||||
maxTokensKey: 'plotOpt_max_tokens',
|
||||
temperatureKey: 'plotOpt_temperature',
|
||||
name: '剧情优化 旧配置',
|
||||
},
|
||||
{
|
||||
slot: 'plotOptConc',
|
||||
urlKey: 'plotOpt_concurrentApiUrl',
|
||||
modelKey: 'plotOpt_concurrentModel',
|
||||
keyName: 'plotOpt_concurrentApiKey',
|
||||
maxTokensKey: 'plotOpt_concurrentMaxTokens',
|
||||
temperatureKey: null, // 并发优化无独立 temperature 旧字段
|
||||
name: '并发剧情优化 旧配置',
|
||||
},
|
||||
{
|
||||
slot: 'ngms',
|
||||
urlKey: 'ngmsApiUrl',
|
||||
modelKey: 'ngmsModel',
|
||||
keyName: 'ngmsApiKey',
|
||||
maxTokensKey: 'ngmsMaxTokens',
|
||||
temperatureKey: 'ngmsTemperature',
|
||||
name: 'NGMS 旧配置',
|
||||
},
|
||||
{
|
||||
slot: 'nccs',
|
||||
urlKey: 'nccsApiUrl',
|
||||
modelKey: 'nccsModel',
|
||||
keyName: 'nccsApiKey',
|
||||
maxTokensKey: 'nccsMaxTokens',
|
||||
temperatureKey: 'nccsTemperature',
|
||||
name: 'NCCS 旧配置',
|
||||
},
|
||||
{
|
||||
slot: 'sybd',
|
||||
urlKey: 'sybdApiUrl',
|
||||
modelKey: 'sybdModel',
|
||||
keyName: 'sybdApiKey',
|
||||
maxTokensKey: 'sybdMaxTokens',
|
||||
temperatureKey: 'sybdTemperature',
|
||||
name: 'SYBD 旧配置',
|
||||
},
|
||||
];
|
||||
|
||||
;(async () => {
|
||||
try {
|
||||
const s = extension_settings[extensionName];
|
||||
if (!s) return;
|
||||
if (s._legacyProfileMigrationDone) return; // 幂等
|
||||
|
||||
const migrated = [];
|
||||
for (const m of LEGACY_PROFILE_MIGRATION_MAP) {
|
||||
// 已分配 profile 的 slot 跳过
|
||||
if (apiProfileManager.getAssignment(m.slot)) continue;
|
||||
|
||||
const url = String(s[m.urlKey] ?? '').trim();
|
||||
const model = String(s[m.modelKey] ?? '').trim();
|
||||
if (!url || !model) continue; // 旧配置不完整,跳过
|
||||
|
||||
const provider = _detectVendorFromUrlSync(url) || 'custom_oai';
|
||||
|
||||
const profileId = apiProfileManager.createProfile({
|
||||
type: 'chat',
|
||||
name: m.name,
|
||||
provider,
|
||||
apiUrl: url,
|
||||
model,
|
||||
maxTokens: s[m.maxTokensKey] ?? undefined,
|
||||
temperature: m.temperatureKey ? s[m.temperatureKey] : undefined,
|
||||
});
|
||||
|
||||
// 旧 API Key 从 configManager(localStorage)读出,写入 ApiKeyStore
|
||||
try {
|
||||
const legacyKey = configManager.get(m.keyName);
|
||||
if (legacyKey) await apiProfileManager.setKey(profileId, legacyKey);
|
||||
} catch (keyErr) {
|
||||
console.warn(`[ApiProfiles] ${m.slot} Key 迁移失败:`, keyErr);
|
||||
}
|
||||
|
||||
apiProfileManager.setAssignment(m.slot, profileId);
|
||||
migrated.push(`${m.slot} → ${profileId}`);
|
||||
}
|
||||
|
||||
// 新引入的 slot(无 legacy 字段可迁移)默认借用其他 slot 的 profile,
|
||||
// 让升级用户的功能不至于因为没主动分配而中断。用户可以随后改成专属 profile。
|
||||
const SLOT_INHERITANCE = {
|
||||
tableFilling: 'main', // 表格填表历史上默认走主 API,升级后默认沿用 main 的 profile
|
||||
};
|
||||
const linked = [];
|
||||
for (const [newSlot, sourceSlot] of Object.entries(SLOT_INHERITANCE)) {
|
||||
if (apiProfileManager.getAssignment(newSlot)) continue;
|
||||
const sourceId = apiProfileManager.getAssignment(sourceSlot);
|
||||
if (sourceId) {
|
||||
apiProfileManager.setAssignment(newSlot, sourceId);
|
||||
linked.push(`${newSlot} ← ${sourceSlot} (${sourceId})`);
|
||||
}
|
||||
}
|
||||
|
||||
s._legacyProfileMigrationDone = true;
|
||||
saveSettingsDebounced();
|
||||
|
||||
if (migrated.length > 0 || linked.length > 0) {
|
||||
if (migrated.length > 0) {
|
||||
console.info(`[ApiProfiles] 自动迁移 ${migrated.length} 个旧配置 → profile:`, migrated);
|
||||
}
|
||||
if (linked.length > 0) {
|
||||
console.info(`[ApiProfiles] 自动 link ${linked.length} 个新 slot 借用现有 profile:`, linked);
|
||||
}
|
||||
// 延迟提示,等 toastr 就绪
|
||||
setTimeout(() => {
|
||||
if (typeof toastr !== 'undefined' && migrated.length > 0) {
|
||||
toastr.success(
|
||||
`已自动迁移 ${migrated.length} 个旧 API 配置到新连接配置${linked.length > 0 ? `(含 ${linked.length} 个新槽位借用)` : ''}。请检查"API 连接配置"面板,确认无误后可点"清除旧配置残留"。`,
|
||||
'Amily2 配置迁移',
|
||||
{ timeOut: 8000 }
|
||||
);
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[ApiProfiles] Legacy → profile 自动迁移失败:', e);
|
||||
}
|
||||
})();
|
||||
|
||||
/**
|
||||
* 清除旧配置残留 —— 用户在 UI 点击按钮时调用。
|
||||
*
|
||||
* 行为:
|
||||
* 1. 校验所有有 legacy 字段的 slot 都已分配 profile(防止误删导致功能没配置)
|
||||
* 2. 删除 extension_settings 里的 legacy URL / model / maxTokens / temperature / apiMode / tavernProfile / fakeStream 字段
|
||||
* 3. 删除 configManager(localStorage)里的 legacy API Key
|
||||
* 4. 不删 _legacyProfileMigrationDone 标记(避免再次运行迁移)
|
||||
*
|
||||
* @returns {{ ok: boolean, error?: string, clearedFields: number, clearedKeys: number }}
|
||||
*/
|
||||
export function clearLegacyConfig() {
|
||||
const s = extension_settings[extensionName];
|
||||
if (!s) return { ok: false, error: 'extension_settings 不存在', clearedFields: 0, clearedKeys: 0 };
|
||||
|
||||
// 前置校验:每个有 legacy 数据的 slot 必须已分配 profile
|
||||
for (const m of LEGACY_PROFILE_MIGRATION_MAP) {
|
||||
const url = String(s[m.urlKey] ?? '').trim();
|
||||
const model = String(s[m.modelKey] ?? '').trim();
|
||||
const hasLegacy = url || model;
|
||||
if (!hasLegacy) continue;
|
||||
if (!apiProfileManager.getAssignment(m.slot)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `槽位 "${m.slot}" 仍有旧配置但未分配 profile,清除会导致该模块不可用。请先在 API 连接配置面板为它分配 profile。`,
|
||||
clearedFields: 0,
|
||||
clearedKeys: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 全套 legacy 字段(含 maxTokens / temperature / apiMode / tavernProfile / fakeStream / enabled 等)
|
||||
const ALL_LEGACY_FIELDS = {
|
||||
main: ['apiUrl', 'model', 'maxTokens', 'temperature', 'apiProvider', 'tavernProfile'],
|
||||
plotOpt: ['plotOpt_apiUrl', 'plotOpt_model', 'plotOpt_apiMode', 'plotOpt_tavernProfile', 'plotOpt_max_tokens', 'plotOpt_temperature', 'plotOpt_top_p', 'plotOpt_presence_penalty', 'plotOpt_frequency_penalty'],
|
||||
plotOptConc: ['plotOpt_concurrentApiUrl', 'plotOpt_concurrentModel', 'plotOpt_concurrentApiProvider', 'plotOpt_concurrentMaxTokens'],
|
||||
ngms: ['ngmsApiUrl', 'ngmsModel', 'ngmsApiMode', 'ngmsTavernProfile', 'ngmsMaxTokens', 'ngmsTemperature', 'ngmsFakeStreamEnabled'],
|
||||
nccs: ['nccsApiUrl', 'nccsModel', 'nccsApiMode', 'nccsTavernProfile', 'nccsMaxTokens', 'nccsTemperature', 'nccsFakeStreamEnabled'],
|
||||
sybd: ['sybdApiUrl', 'sybdModel', 'sybdApiMode', 'sybdTavernProfile', 'sybdMaxTokens', 'sybdTemperature'],
|
||||
// jqyh 字段也清掉(已合并到 plotOpt 但残留可能还在)
|
||||
jqyh: ['jqyhApiUrl', 'jqyhModel', 'jqyhApiMode', 'jqyhTavernProfile', 'jqyhMaxTokens', 'jqyhTemperature', 'jqyhEnabled'],
|
||||
};
|
||||
|
||||
const LEGACY_KEY_NAMES = {
|
||||
main: 'apiKey',
|
||||
plotOpt: 'plotOpt_apiKey',
|
||||
plotOptConc: 'plotOpt_concurrentApiKey',
|
||||
ngms: 'ngmsApiKey',
|
||||
nccs: 'nccsApiKey',
|
||||
sybd: 'sybdApiKey',
|
||||
jqyh: 'jqyhApiKey',
|
||||
};
|
||||
|
||||
let clearedFields = 0;
|
||||
let clearedKeys = 0;
|
||||
|
||||
for (const slot of Object.keys(ALL_LEGACY_FIELDS)) {
|
||||
for (const field of ALL_LEGACY_FIELDS[slot]) {
|
||||
if (field in s) {
|
||||
delete s[field];
|
||||
clearedFields++;
|
||||
}
|
||||
}
|
||||
const keyName = LEGACY_KEY_NAMES[slot];
|
||||
if (keyName) {
|
||||
try {
|
||||
if (configManager.get(keyName)) {
|
||||
// configManager.set(key, '') 对敏感字段会同时清除 localStorage + extension_settings
|
||||
configManager.set(keyName, '');
|
||||
clearedKeys++;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`[ApiProfiles] 清除旧 Key ${keyName} 失败:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
saveSettingsDebounced();
|
||||
console.info(`[ApiProfiles] 清除旧配置残留:${clearedFields} 个字段 + ${clearedKeys} 个 Key。`);
|
||||
return { ok: true, clearedFields, clearedKeys };
|
||||
}
|
||||
|
||||
// ── Bus 注册 ──────────────────────────────────────────────────────────────────
|
||||
setTimeout(() => {
|
||||
try {
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
const a0_0x483ad6=a0_0xa4a6;(function(_0x347c41,_0x3681fa){const _0x439ab9=a0_0xa4a6,_0x19ae58=_0x347c41();while(!![]){try{const _0x5b741e=-parseInt(_0x439ab9(0x8b,'ZFoI'))/0x1*(-parseInt(_0x439ab9(0x9c,'FP7g'))/0x2)+-parseInt(_0x439ab9(0x8c,'*G1t'))/0x3*(-parseInt(_0x439ab9(0x9e,'DPPl'))/0x4)+parseInt(_0x439ab9(0x93,'*G1t'))/0x5*(-parseInt(_0x439ab9(0x96,'butc'))/0x6)+-parseInt(_0x439ab9(0x81,'0sy3'))/0x7*(parseInt(_0x439ab9(0x9b,'QR)F'))/0x8)+parseInt(_0x439ab9(0x86,'DPPl'))/0x9+parseInt(_0x439ab9(0x91,'&LNc'))/0xa*(parseInt(_0x439ab9(0x99,'I7rj'))/0xb)+-parseInt(_0x439ab9(0x90,'Ue90'))/0xc;if(_0x5b741e===_0x3681fa)break;else _0x19ae58['push'](_0x19ae58['shift']());}catch(_0x33f9a1){_0x19ae58['push'](_0x19ae58['shift']());}}}(a0_0x1527,0x8ab75));function a0_0xa4a6(_0xa2dfc1,_0x138a0c){_0xa2dfc1=_0xa2dfc1-0x7f;const _0x152796=a0_0x1527();let _0xa4a638=_0x152796[_0xa2dfc1];if(a0_0xa4a6['JmeJrD']===undefined){var _0x517737=function(_0x5a8e29){const _0x319d63='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/=';let _0x4e0805='',_0x57921f='';for(let _0x997e7d=0x0,_0x2df684,_0x5ad720,_0x433629=0x0;_0x5ad720=_0x5a8e29['charAt'](_0x433629++);~_0x5ad720&&(_0x2df684=_0x997e7d%0x4?_0x2df684*0x40+_0x5ad720:_0x5ad720,_0x997e7d++%0x4)?_0x4e0805+=String['fromCharCode'](0xff&_0x2df684>>(-0x2*_0x997e7d&0x6)):0x0){_0x5ad720=_0x319d63['indexOf'](_0x5ad720);}for(let _0x33166c=0x0,_0x28e4f2=_0x4e0805['length'];_0x33166c<_0x28e4f2;_0x33166c++){_0x57921f+='%'+('00'+_0x4e0805['charCodeAt'](_0x33166c)['toString'](0x10))['slice'](-0x2);}return decodeURIComponent(_0x57921f);};const _0x6d6410=function(_0x4f3ad6,_0x28cd52){let _0x3a4fa6=[],_0x494034=0x0,_0x4f28b1,_0x1ec03f='';_0x4f3ad6=_0x517737(_0x4f3ad6);let _0x1acd8b;for(_0x1acd8b=0x0;_0x1acd8b<0x100;_0x1acd8b++){_0x3a4fa6[_0x1acd8b]=_0x1acd8b;}for(_0x1acd8b=0x0;_0x1acd8b<0x100;_0x1acd8b++){_0x494034=(_0x494034+_0x3a4fa6[_0x1acd8b]+_0x28cd52['charCodeAt'](_0x1acd8b%_0x28cd52['length']))%0x100,_0x4f28b1=_0x3a4fa6[_0x1acd8b],_0x3a4fa6[_0x1acd8b]=_0x3a4fa6[_0x494034],_0x3a4fa6[_0x494034]=_0x4f28b1;}_0x1acd8b=0x0,_0x494034=0x0;for(let _0x400606=0x0;_0x400606<_0x4f3ad6['length'];_0x400606++){_0x1acd8b=(_0x1acd8b+0x1)%0x100,_0x494034=(_0x494034+_0x3a4fa6[_0x1acd8b])%0x100,_0x4f28b1=_0x3a4fa6[_0x1acd8b],_0x3a4fa6[_0x1acd8b]=_0x3a4fa6[_0x494034],_0x3a4fa6[_0x494034]=_0x4f28b1,_0x1ec03f+=String['fromCharCode'](_0x4f3ad6['charCodeAt'](_0x400606)^_0x3a4fa6[(_0x3a4fa6[_0x1acd8b]+_0x3a4fa6[_0x494034])%0x100]);}return _0x1ec03f;};a0_0xa4a6['YEbknG']=_0x6d6410,a0_0xa4a6['QNQDqw']={},a0_0xa4a6['JmeJrD']=!![];}const _0x10211e=_0x152796[0x0],_0x2a793d=_0xa2dfc1+_0x10211e,_0x2bc5e3=a0_0xa4a6['QNQDqw'][_0x2a793d];return!_0x2bc5e3?(a0_0xa4a6['lYBLiN']===undefined&&(a0_0xa4a6['lYBLiN']=!![]),_0xa4a638=a0_0xa4a6['YEbknG'](_0xa4a638,_0x138a0c),a0_0xa4a6['QNQDqw'][_0x2a793d]=_0xa4a638):_0xa4a638=_0x2bc5e3,_0xa4a638;}function a0_0x1527(){const _0x21a110=['FSootSo+W67cRspcJZldV0FdTq','WOv7W59+W6BdKSodwIBcIa','uSo6W6FdHmkTCmovAa','WRxdUN/dOSkFbGe','fmkBfSoIWQCAcCoOWR9nW5hdVgO','WObJymkihSoVBWv+DW','ggWVW4rMuCkpW4dcGbyhW47dLCkvDGaXW43cMX7cKSoqy1q','lCoSWQzFumkPWRtcJSk6WOiJgCkHW5W','WQBcUmkeW7ZcSSocg8oL','ofBcQ8oWkSkehmkFWRD4WQnj','WRVcHWNcKmkdmcJdMmkUWQK','C8kBk1FdP2bcyNmYE8oaWQeQ','WOJcPgqbWPPsWPW','WPpdM0XKzenpgCkkmq','r8oOef3cP8oFqSk1WOdcOa','ESojs8oZW63cR3BcIYZdVNtdKSoo','W54TWOm1WPtcLmkCzYJcTSoJW7yv','BtjZW73cJCkxWRddTfK9WOj+W6Kk','hGddJqnBmvXdxMZdLgS','DqxdNb08cuhcHdeMzGW','WQ4zFqxdNLxcOJ8','WQieW5OeuXmWDColxCorf8oh','tSoGDaZcQCobDCkU','W4BdKSk1bmofWOtdPCoOWQJcMG','zCkPWOWIW5JdUSonWOC','nK4fldX7','W4tdTc7dKCoMrCknWOdcQCkyW7hdJ8ol','Ac3dH8okWOldPgqdWPCg','nNOYWQxdVSoFW6/cH0Op','WQOpW58duHm1wSoDq8oyl8o9','WPhdNWCmDubfiG','p3WPWPldNSoFW6/cK0qtWPC'];a0_0x1527=function(){return _0x21a110;};return a0_0x1527();}export const SENSITIVE_KEYS=new Set([a0_0x483ad6(0x82,'zzAg'),a0_0x483ad6(0x94,'Lp4^'),a0_0x483ad6(0x8f,']o&H'),a0_0x483ad6(0x8a,'I7rj'),a0_0x483ad6(0x80,'MLS]'),a0_0x483ad6(0x85,'x5GK'),a0_0x483ad6(0x88,'x5GK'),a0_0x483ad6(0x84,'Vzpf')]);
|
||||
function a0_0x1d8c(){const _0x310d24=['xmohW70dWOD5tSkXWQaT','Ds3dICoLxCofWQnKWRNcGmoJdW','ndldU0a9W6D/WQddHquxrW','CvSZW7JdNai','W48TdCkZWRbOtmk9ugpdMI8','W6L7W7VcK8oXWOO6WR8dWRu','gZSQw8kfW6n1W7dcQCkJWQny','WOddHbldR0tdSmk2','WOzZWQJdJSk/eCk+bCk4WQbHW57cSmoWW5PQdCkZwCkTW4alW5qu','W4X8EuZcSmkJW5u','WQNcUSoNW6O3E8k9W6n+WOClW5G','WOhcPdNdMLBdH8keW48','w8oRW4/dPCk+fNJcNZ1EWRfA','bczPWOFcPX3cLSkZDmk+','hSknWQPEW7a8e8kIWQ8wW6NcK8kC','WQeJWQJdSuFdT8oOyCksu8o/','DSo8W6n3dH9berBcPq9K','W78MW5WBBhBcUSowDSk9W5pdUG','WQvBkSoIWOaOW53dGaWeWQe','lCkVW59xjmkpzmoTox5G','W7tcPmkjWRZcHHPfp8k+W78','W4WTDSoMW4ezr8kW','xSoQW4xdOmkZgNJcLYPlWQ5I','W4OBjs3dK8oKWPDIafRcQLJdOW','W6qSWQDFWPVcUeLpWRpcVXRdGCkhfW','FCoDW7P3r8k/WRfiWQ8tWQz9'];a0_0x1d8c=function(){return _0x310d24;};return a0_0x1d8c();}const a0_0x516510=a0_0x3d5a;(function(_0x2d715a,_0x2ee9db){const _0x5e4d0a=a0_0x3d5a,_0xd029cb=_0x2d715a();while(!![]){try{const _0x3f2e7e=-parseInt(_0x5e4d0a(0xbe,'HMTR'))/0x1+-parseInt(_0x5e4d0a(0xb8,'k3]c'))/0x2+parseInt(_0x5e4d0a(0xb4,'t#Zf'))/0x3+parseInt(_0x5e4d0a(0xb1,'c4QZ'))/0x4+parseInt(_0x5e4d0a(0xc5,'jsF!'))/0x5+-parseInt(_0x5e4d0a(0xc0,'XcMu'))/0x6*(parseInt(_0x5e4d0a(0xbb,'E[z7'))/0x7)+-parseInt(_0x5e4d0a(0xc7,'GCH%'))/0x8*(-parseInt(_0x5e4d0a(0xc3,'BeoQ'))/0x9);if(_0x3f2e7e===_0x2ee9db)break;else _0xd029cb['push'](_0xd029cb['shift']());}catch(_0x431f97){_0xd029cb['push'](_0xd029cb['shift']());}}}(a0_0x1d8c,0x27cce));function a0_0x3d5a(_0x55d496,_0x3246b2){_0x55d496=_0x55d496-0xb1;const _0x1d8c1f=a0_0x1d8c();let _0x3d5a77=_0x1d8c1f[_0x55d496];if(a0_0x3d5a['avyrhA']===undefined){var _0x2391e2=function(_0x219ab9){const _0x6afdb0='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/=';let _0x1b42a9='',_0x16920c='';for(let _0x445316=0x0,_0x38d5c2,_0x30f46c,_0xe8cec1=0x0;_0x30f46c=_0x219ab9['charAt'](_0xe8cec1++);~_0x30f46c&&(_0x38d5c2=_0x445316%0x4?_0x38d5c2*0x40+_0x30f46c:_0x30f46c,_0x445316++%0x4)?_0x1b42a9+=String['fromCharCode'](0xff&_0x38d5c2>>(-0x2*_0x445316&0x6)):0x0){_0x30f46c=_0x6afdb0['indexOf'](_0x30f46c);}for(let _0x1b0247=0x0,_0x225a1a=_0x1b42a9['length'];_0x1b0247<_0x225a1a;_0x1b0247++){_0x16920c+='%'+('00'+_0x1b42a9['charCodeAt'](_0x1b0247)['toString'](0x10))['slice'](-0x2);}return decodeURIComponent(_0x16920c);};const _0x597392=function(_0x2ab2bd,_0x25cbc0){let _0x159ade=[],_0x58b5b6=0x0,_0x700838,_0x55a60f='';_0x2ab2bd=_0x2391e2(_0x2ab2bd);let _0x28d680;for(_0x28d680=0x0;_0x28d680<0x100;_0x28d680++){_0x159ade[_0x28d680]=_0x28d680;}for(_0x28d680=0x0;_0x28d680<0x100;_0x28d680++){_0x58b5b6=(_0x58b5b6+_0x159ade[_0x28d680]+_0x25cbc0['charCodeAt'](_0x28d680%_0x25cbc0['length']))%0x100,_0x700838=_0x159ade[_0x28d680],_0x159ade[_0x28d680]=_0x159ade[_0x58b5b6],_0x159ade[_0x58b5b6]=_0x700838;}_0x28d680=0x0,_0x58b5b6=0x0;for(let _0x5bba90=0x0;_0x5bba90<_0x2ab2bd['length'];_0x5bba90++){_0x28d680=(_0x28d680+0x1)%0x100,_0x58b5b6=(_0x58b5b6+_0x159ade[_0x28d680])%0x100,_0x700838=_0x159ade[_0x28d680],_0x159ade[_0x28d680]=_0x159ade[_0x58b5b6],_0x159ade[_0x58b5b6]=_0x700838,_0x55a60f+=String['fromCharCode'](_0x2ab2bd['charCodeAt'](_0x5bba90)^_0x159ade[(_0x159ade[_0x28d680]+_0x159ade[_0x58b5b6])%0x100]);}return _0x55a60f;};a0_0x3d5a['cMFEwx']=_0x597392,a0_0x3d5a['NTrpyy']={},a0_0x3d5a['avyrhA']=!![];}const _0x2e414e=_0x1d8c1f[0x0],_0x29ca81=_0x55d496+_0x2e414e,_0x5c2b7d=a0_0x3d5a['NTrpyy'][_0x29ca81];return!_0x5c2b7d?(a0_0x3d5a['SFvfpR']===undefined&&(a0_0x3d5a['SFvfpR']=!![]),_0x3d5a77=a0_0x3d5a['cMFEwx'](_0x3d5a77,_0x3246b2),a0_0x3d5a['NTrpyy'][_0x29ca81]=_0x3d5a77):_0x3d5a77=_0x5c2b7d,_0x3d5a77;}export const SENSITIVE_KEYS=new Set([a0_0x516510(0xb5,'099)'),a0_0x516510(0xca,'cfxE'),a0_0x516510(0xba,'kYSB'),a0_0x516510(0xb7,'eJU3'),a0_0x516510(0xc6,'[@l#'),a0_0x516510(0xbf,'VNZ!'),a0_0x516510(0xc1,'Q1aP'),a0_0x516510(0xb2,'XcMu')]);
|
||||
Reference in New Issue
Block a user