dsh-consistency-guard 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +95 -0
- package/cordis.patch.yml +7 -0
- package/lib/client.js +827 -0
- package/lib/index.js +13 -0
- package/package.json +46 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dsh-consistency-guard contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# dsh-consistency-guard
|
|
2
|
+
|
|
3
|
+
DeepSeek Harness(dsh)长文创作一致性守卫插件:扫描会话正文中的**人物设定、时间线、数值型设定**,自动发现跨章节矛盾,并**点击直接跳转到冲突原文位置**。
|
|
4
|
+
|
|
5
|
+
> 背景:百万字长篇创作中,人物年龄/等级/境界、时间线、数值设定在数十次对话迭代后极易失控。dsh 内置没有跨章节内容审计能力,现有写作类插件做的是编辑与导出,本插件补上「一致性守卫」这一环。
|
|
6
|
+
|
|
7
|
+
## 功能
|
|
8
|
+
|
|
9
|
+
| 能力 | 说明 |
|
|
10
|
+
| --- | --- |
|
|
11
|
+
| **实体提取引擎** | 纯前端规则引擎,从会话正文中提取属性设定:年龄(25 岁 / 二十七岁)、年份(1998 年 / 星历 412 年)、身高(180cm / 身高 180)、等级、境界。中英文数字统一归一化,`25岁` 与 `二十七岁` 可正确比对。 |
|
|
12
|
+
| **实体归属** | 优先匹配设定字典中的已知人名,其次按常见姓氏识别,最后用「动词/属性词邻接」启发式回退;内置停用词拦截,避免把「境界三层的修士」误判成人名。 |
|
|
13
|
+
| **设定字典** | `settings.section` 插槽提供编辑器:维护 `实体 / 属性 / 标准值` 条目,保存在浏览器本地存储(localStorage),编辑后 dock 面板即时生效,无需重载。 |
|
|
14
|
+
| **两级冲突检测** | **数值矛盾**(numeric):同一实体同一属性出现两个不同数值——硬性矛盾,如林默第 1 轮 25 岁、第 7 轮 27 岁。<br>**表述差异**(wording):非数值属性的表述漂移,低置信度提示。<br>无归属的 mention 归入 `(unattributed)` 桶,只给提示、不误报硬冲突。 |
|
|
15
|
+
| **冲突 dock 面板** | 输入框上方( `conversation.input.dock` 插槽)显示冲突数胶囊:无冲突为绿色、有冲突为红色;点击展开冲突列表(portal 到页面根部,不受布局容器裁剪),按「数值矛盾优先 + 轮次」排序。 |
|
|
16
|
+
| **点击定位跳转** | 点击任意冲突项 → 切回「对话」视图 → 平滑滚动到**第二个冲突值**所在消息并红色高亮。若目标在未加载的更早记录中,自动调用 `loadOlder`(最多 3 次)后重试。 |
|
|
17
|
+
|
|
18
|
+
## 安装
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
dsh plugin --profile web add file:/path/to/dsh-consistency-guard
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
然后在 `~/.dsh/profiles/web/package.json` 的 `dsh.profile.bundles` 数组中加入 `"dsh-consistency-guard"`,重启 DSH.app(或硬刷新浏览器窗口)生效。
|
|
25
|
+
|
|
26
|
+
## 卸载
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
dsh plugin --profile web remove dsh-consistency-guard
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## 工作原理
|
|
33
|
+
|
|
34
|
+
- **插槽注册**(客户端):通过 `ctx.slots` 注册两个服务——
|
|
35
|
+
- `conversation.input.dock`(id `consistency-guard-dock`):输入框上方的冲突胶囊 + 展开列表。
|
|
36
|
+
- `settings.section`(id `consistency-guard-settings`):设定字典编辑区。
|
|
37
|
+
- **数据来源**:插槽组件拿到会话快照,读取 `snapshot.chat.order` / `snapshot.chat.nodes`,取 `assistant` 的 `data.blocks[kind=text]` 与 `user`/`steering` 的文本作为正文来源;**工具调用与代码输出刻意跳过**,避免把代码里的数字当成设定。
|
|
38
|
+
- **扫描流水线**:正文 → 按句切分 → 句内属性模式匹配(`年龄/岁/身高/cm/等级/境界/年份`)→ 值归一化 → 实体归属 → 按 `(实体, 属性)` 分组 → 组内出现两个及以上不同值即判定冲突。
|
|
39
|
+
- **属性轴区分**:`境界/修为` 与 `等级/级别` 是中文类型小说中两个独立维度,**刻意不合并**,否则「境界三层,等级 9」会被误判为矛盾。
|
|
40
|
+
- **跳转机制**:复用 dsh 聊天视图的内置语义锚点 `data-chat-anchor-key="<key>"`,跳转 = 找到该行 → `scrollIntoView` + 红色高亮;点击胶囊先按 label 点击 `[role="tab"]` 切回「对话」(中文「对话」/ 英文「Chat」,回退首个 Tab),再轮询等待目标行挂载。
|
|
41
|
+
- **字典联动**:字典保存在 localStorage,`apply` 内维护一个极简订阅器(subscribe/getDict/setDict),设置区保存后即时广播给 dock 面板。
|
|
42
|
+
|
|
43
|
+
## 目录结构
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
dsh-consistency-guard/
|
|
47
|
+
├── package.json # dsh.bundle.patch + dsh.client + exports["./client"]
|
|
48
|
+
├── cordis.patch.yml # 挂载插件到 profile 层叠
|
|
49
|
+
├── lib/
|
|
50
|
+
│ ├── index.js # 服务端挂载入口(UI-only,最小实现)
|
|
51
|
+
│ └── client.js # 客户端 bundle(window.__ModuleLoader__.load 格式)
|
|
52
|
+
└── test/
|
|
53
|
+
└── smoke.cjs # 冒烟测试(mock ModuleLoader + ctx + 纯函数断言)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## 开发说明
|
|
57
|
+
|
|
58
|
+
- 纯手写客户端 bundle(仅依赖 `react`,零构建步骤),格式遵循 dsh web 的 lazy-CJS 模块表契约:`window.__ModuleLoader__.load({ id, factory })`。
|
|
59
|
+
- `dsh.client.inject` 声明了插件可用的客户端服务:`slots` / `sessions` / `locale`。
|
|
60
|
+
- 所有核心逻辑(实体提取、归一化、人名校验、冲突检测、字典读写)都是**不依赖 react 的纯函数**,通过 `exports.__test` 暴露给冒烟测试。
|
|
61
|
+
- 校验:`node --check lib/client.js`;冒烟测试 `node test/smoke.cjs`(覆盖工厂物化、两个插槽注册、中文数字归一、实体归属、去重、人名校验、端到端冲突检测)。
|
|
62
|
+
|
|
63
|
+
### 冒烟测试覆盖
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
factory materialized; exports: [ 'apply', 'inject', '__test' ]
|
|
67
|
+
registered slots: ["conversation.input.dock[consistency-guard-dock]","settings.section[consistency-guard-settings]"]
|
|
68
|
+
cnNumToInt: ok
|
|
69
|
+
normalizeValue: ok
|
|
70
|
+
extractMentions attribution + dedup: ok
|
|
71
|
+
plausibleName: ok
|
|
72
|
+
mentions: 8 conflicts: 2
|
|
73
|
+
detectConflicts end-to-end: ok
|
|
74
|
+
loadDict without localStorage: ok
|
|
75
|
+
ALL CHECKS PASSED
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
端到端用例模拟《归墟》修订场景:林默第 1 轮 25 岁 / 第 7 轮 27 岁(报数值矛盾)、沈砚青等级 9 → 12(报数值矛盾)、苏晚晴两处均为 27 岁(不误报)、境界三层与等级 9 不合并(不误报)。
|
|
79
|
+
|
|
80
|
+
## 已知限制
|
|
81
|
+
|
|
82
|
+
- 规则引擎覆盖常见中文小说设定的属性词,未收录的属性不会参与比对;设定字典用于补强专有名词。
|
|
83
|
+
- 人名识别基于常见姓氏表 + 启发式,生僻姓名建议加入设定字典以确保归属准确。
|
|
84
|
+
- 设定字典保存在浏览器 localStorage,换浏览器或清理站点数据后需要重新录入。
|
|
85
|
+
|
|
86
|
+
## 路线图(二期)
|
|
87
|
+
|
|
88
|
+
- 伏笔追踪:标记「伏笔-回收」对,扫描未回收伏笔
|
|
89
|
+
- 时间线校验:事件先后顺序矛盾检测
|
|
90
|
+
- 语料库统计:章节字数、出场角色频次、设定引用热图
|
|
91
|
+
- 一致性审计报告(Markdown)一键导出
|
|
92
|
+
|
|
93
|
+
## License
|
|
94
|
+
|
|
95
|
+
MIT
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# dsh-consistency-guard bundle patch: mounts the plugin into a profile's
|
|
2
|
+
# layer stack. All functionality ships in the client bundle (declared by
|
|
3
|
+
# dsh.client in package.json) — this server entry exists so the cordis loader
|
|
4
|
+
# can mount the bundle entry declared by the profile's bundle list.
|
|
5
|
+
- insert:
|
|
6
|
+
- id: consistency-guard
|
|
7
|
+
name: 'dsh-consistency-guard'
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,827 @@
|
|
|
1
|
+
// dsh-consistency-guard — client bundle.
|
|
2
|
+
//
|
|
3
|
+
// Scans the conversation prose for long-form writing inconsistencies
|
|
4
|
+
// (character attributes, timeline markers, numeric settings) and surfaces
|
|
5
|
+
// them in a dock bar above the composer, with click-to-jump back to the
|
|
6
|
+
// conflicting message.
|
|
7
|
+
//
|
|
8
|
+
// Registered surfaces:
|
|
9
|
+
// 1. conversation.input.dock — conflict count chip + expandable list
|
|
10
|
+
// 2. settings.section — canonical setting dictionary editor
|
|
11
|
+
//
|
|
12
|
+
// Bundle format: window.__ModuleLoader__.load({ id, factory }) — the lazy-CJS
|
|
13
|
+
// table contract consumed by dsh-web-app. The factory requires only "react" /
|
|
14
|
+
// "react-dom", so all logic is inlined in this file. Data source: the
|
|
15
|
+
// conversation snapshot (chat.nodes / chat.order) exposed to session-scope
|
|
16
|
+
// slot components.
|
|
17
|
+
|
|
18
|
+
window.__ModuleLoader__.load({
|
|
19
|
+
id: "dsh-consistency-guard",
|
|
20
|
+
factory: (require) => {
|
|
21
|
+
var module = { exports: {} };
|
|
22
|
+
var exports = module.exports;
|
|
23
|
+
|
|
24
|
+
// ── react (defensive default interop) ────────────────────────────────
|
|
25
|
+
var reactModule = require("react");
|
|
26
|
+
var react = reactModule && reactModule.default ? reactModule.default : reactModule;
|
|
27
|
+
var useState = react.useState;
|
|
28
|
+
var useEffect = react.useEffect;
|
|
29
|
+
var useMemo = react.useMemo;
|
|
30
|
+
var useRef = react.useRef;
|
|
31
|
+
var createElement = react.createElement;
|
|
32
|
+
var reactDomModule = require("react-dom");
|
|
33
|
+
var reactDom = reactDomModule && reactDomModule.default ? reactDomModule.default : reactDomModule;
|
|
34
|
+
var createPortal = reactDom.createPortal || (reactDom.default && reactDom.default.createPortal);
|
|
35
|
+
|
|
36
|
+
var NS = "consistency-guard";
|
|
37
|
+
var STYLE_ID = "dsh-consistency-guard-style";
|
|
38
|
+
var STORE_KEY = "dsh-consistency-guard:dict";
|
|
39
|
+
|
|
40
|
+
// ── i18n ─────────────────────────────────────────────────────────────
|
|
41
|
+
var zh = {
|
|
42
|
+
"dock.title": "一致性守卫",
|
|
43
|
+
"dock.empty": "无冲突",
|
|
44
|
+
"dock.conflicts": "{n} 处冲突",
|
|
45
|
+
"dock.expand": "展开冲突列表",
|
|
46
|
+
"dock.collapse": "收起",
|
|
47
|
+
"dock.noData": "尚未扫描到正文",
|
|
48
|
+
"conflict.numeric": "数值矛盾",
|
|
49
|
+
"conflict.wording": "表述差异",
|
|
50
|
+
"attr.age": "年龄",
|
|
51
|
+
"attr.year": "年份",
|
|
52
|
+
"attr.height": "身高",
|
|
53
|
+
"attr.level": "等级",
|
|
54
|
+
"attr.rank": "境界",
|
|
55
|
+
"conflict.at": "第 {n} 轮",
|
|
56
|
+
"conflict.values": "{a} → {b}",
|
|
57
|
+
"conflict.unknown": "未知位置",
|
|
58
|
+
"list.empty": "未检测到设定冲突",
|
|
59
|
+
"list.hint": "点击任意冲突项跳转到原文位置",
|
|
60
|
+
"settings.title": "设定字典",
|
|
61
|
+
"settings.desc": "维护人物/地点/设定的标准值,用于一致性比对",
|
|
62
|
+
"settings.add": "添加",
|
|
63
|
+
"settings.remove": "删除",
|
|
64
|
+
"settings.entity": "实体",
|
|
65
|
+
"settings.attr": "属性",
|
|
66
|
+
"settings.value": "标准值",
|
|
67
|
+
"settings.empty": "暂无条目",
|
|
68
|
+
"settings.save": "保存",
|
|
69
|
+
"settings.saved": "已保存到浏览器本地"
|
|
70
|
+
};
|
|
71
|
+
var en = {
|
|
72
|
+
"dock.title": "Consistency guard",
|
|
73
|
+
"dock.empty": "No conflicts",
|
|
74
|
+
"dock.conflicts": "{n} conflicts",
|
|
75
|
+
"dock.expand": "Expand conflict list",
|
|
76
|
+
"dock.collapse": "Collapse",
|
|
77
|
+
"dock.noData": "No prose scanned yet",
|
|
78
|
+
"conflict.numeric": "Numeric conflict",
|
|
79
|
+
"conflict.wording": "Wording variance",
|
|
80
|
+
"attr.age": "Age",
|
|
81
|
+
"attr.year": "Year",
|
|
82
|
+
"attr.height": "Height",
|
|
83
|
+
"attr.level": "Level",
|
|
84
|
+
"attr.rank": "Rank",
|
|
85
|
+
"conflict.at": "Turn {n}",
|
|
86
|
+
"conflict.values": "{a} → {b}",
|
|
87
|
+
"conflict.unknown": "Unknown location",
|
|
88
|
+
"list.empty": "No setting conflicts detected",
|
|
89
|
+
"list.hint": "Click any conflict to jump to its source",
|
|
90
|
+
"settings.title": "Setting dictionary",
|
|
91
|
+
"settings.desc": "Canonical values for characters / places / settings",
|
|
92
|
+
"settings.add": "Add",
|
|
93
|
+
"settings.remove": "Remove",
|
|
94
|
+
"settings.entity": "Entity",
|
|
95
|
+
"settings.attr": "Attribute",
|
|
96
|
+
"settings.value": "Canonical value",
|
|
97
|
+
"settings.empty": "No entries yet",
|
|
98
|
+
"settings.save": "Save",
|
|
99
|
+
"settings.saved": "Saved to browser storage"
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// ── styles (light flat theme, matches the app's flat-ui skin) ────────
|
|
103
|
+
var cssText = [
|
|
104
|
+
".dcg-dock{box-sizing:border-box;display:flex;align-items:center;gap:10px;min-height:42px;padding:6px 14px;background:#fff;border:1px solid rgba(23,32,51,.08);border-radius:12px;max-width:var(--dsh-chat-content-width,748px);margin:0 auto}",
|
|
105
|
+
".dcg-dockTitle{flex:none;display:inline-flex;align-items:center;gap:7px;font-size:12px;font-weight:600;color:#5d6b80;white-space:nowrap}",
|
|
106
|
+
".dcg-dockTitle::before{content:'';width:3px;height:13px;border-radius:2px;background:#4f6ef7}",
|
|
107
|
+
".dcg-chip{flex:none;display:inline-flex;align-items:center;gap:7px;border:1px solid rgba(23,32,51,.1);background:#f8f9fc;border-radius:14px;padding:4px 11px;cursor:pointer;font-family:inherit;font-size:12px;color:#1b2434;transition:border-color .15s ease,background .15s ease}",
|
|
108
|
+
".dcg-chip:hover{background:#fff;border-color:rgba(79,110,247,.45)}",
|
|
109
|
+
".dcg-chipClean{border-color:rgba(25,179,155,.4);background:rgba(25,179,155,.07);color:#0f6e56}",
|
|
110
|
+
".dcg-chipDirty{border-color:rgba(214,64,95,.45);background:rgba(214,64,95,.08);color:#a01f3c}",
|
|
111
|
+
".dcg-dot{flex:none;width:8px;height:8px;border-radius:50%;background:#b4b2a9}",
|
|
112
|
+
".dcg-chipClean .dcg-dot{background:#19b39b}",
|
|
113
|
+
".dcg-chipDirty .dcg-dot{background:#d6405f}",
|
|
114
|
+
".dcg-spacer{flex:1;min-width:0}",
|
|
115
|
+
".dcg-list{position:fixed;z-index:2147483000;max-width:min(560px,calc(100vw - 32px));max-height:340px;overflow-y:auto;background:#fff;border:1px solid rgba(23,32,51,.12);border-radius:12px;box-shadow:0 10px 34px rgba(23,32,51,.16);padding:8px}",
|
|
116
|
+
".dcg-listEmpty{padding:14px 12px;font-size:12px;color:#8a94a3;text-align:center}",
|
|
117
|
+
".dcg-item{display:flex;align-items:center;gap:10px;width:100%;text-align:left;border:0;background:transparent;border-radius:8px;padding:7px 9px;cursor:pointer;font-family:inherit;color:#1b2434}",
|
|
118
|
+
".dcg-item:hover{background:#f4f6fb}",
|
|
119
|
+
".dcg-badge{flex:none;font-size:11px;border-radius:6px;padding:2px 7px;white-space:nowrap}",
|
|
120
|
+
".dcg-badgeNumeric{background:rgba(214,64,95,.12);color:#a01f3c}",
|
|
121
|
+
".dcg-badgeWording{background:rgba(186,117,23,.12);color:#854f0b}",
|
|
122
|
+
".dcg-itemBody{flex:1;min-width:0;font-size:12px;line-height:1.5}",
|
|
123
|
+
".dcg-itemTop{font-weight:600}",
|
|
124
|
+
".dcg-itemSub{color:#8a94a3;font-size:11px}",
|
|
125
|
+
".dcg-hint{padding:6px 10px 2px;font-size:11px;color:#8a94a3}",
|
|
126
|
+
".dcg-settings{display:flex;flex-direction:column;gap:10px;font-size:13px}",
|
|
127
|
+
".dcg-row{display:flex;align-items:center;gap:8px}",
|
|
128
|
+
".dcg-row input{flex:1;min-width:0}",
|
|
129
|
+
".dcg-empty{color:#8a94a3;font-size:12px;padding:4px 0}",
|
|
130
|
+
".dcg-actions{display:flex;align-items:center;gap:8px}",
|
|
131
|
+
".dcg-saved{font-size:12px;color:#0f6e56}"
|
|
132
|
+
].join("\n");
|
|
133
|
+
|
|
134
|
+
function ensureStyle() {
|
|
135
|
+
if (typeof document === "undefined") return;
|
|
136
|
+
if (document.getElementById(STYLE_ID)) return;
|
|
137
|
+
var style = document.createElement("style");
|
|
138
|
+
style.id = STYLE_ID;
|
|
139
|
+
style.textContent = cssText;
|
|
140
|
+
document.head.appendChild(style);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── text helpers ─────────────────────────────────────────────────────
|
|
144
|
+
function oneLine(text) {
|
|
145
|
+
if (typeof text !== "string") return "";
|
|
146
|
+
return text.replace(/\s+/g, " ").trim();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── node text extraction (defensive against shape variance) ──────────
|
|
150
|
+
// Pulls the human-readable prose out of a chat node. Only narrative
|
|
151
|
+
// content matters for consistency scanning: assistant text blocks and
|
|
152
|
+
// user/steering messages. Tool chatter is deliberately skipped so the
|
|
153
|
+
// scanner never mistakes code output for prose settings.
|
|
154
|
+
function nodeText(node) {
|
|
155
|
+
try {
|
|
156
|
+
var data = node && node.data;
|
|
157
|
+
var kind = node && node.kind;
|
|
158
|
+
if (!data) return "";
|
|
159
|
+
if (kind === "assistant") {
|
|
160
|
+
var blocks = data.blocks || [];
|
|
161
|
+
var parts = [];
|
|
162
|
+
for (var i = 0; i < blocks.length; i++) {
|
|
163
|
+
var b = blocks[i];
|
|
164
|
+
if (b && b.kind === "text" && b.text) parts.push(b.text);
|
|
165
|
+
}
|
|
166
|
+
return parts.join("\n");
|
|
167
|
+
}
|
|
168
|
+
if (kind === "user" || kind === "steering" || kind === "context") {
|
|
169
|
+
var content = data.content || [];
|
|
170
|
+
var out = [];
|
|
171
|
+
for (var j = 0; j < content.length; j++) {
|
|
172
|
+
var c = content[j];
|
|
173
|
+
if (c && c.type === "text" && c.text) out.push(c.text);
|
|
174
|
+
}
|
|
175
|
+
return out.join("\n");
|
|
176
|
+
}
|
|
177
|
+
return "";
|
|
178
|
+
} catch (e) { return ""; }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function nodeTurn(node) {
|
|
182
|
+
try {
|
|
183
|
+
var loc = node && node.location;
|
|
184
|
+
if (!loc) return undefined;
|
|
185
|
+
if (loc.kind === "turn") return loc.turn ? loc.turn.turn : undefined;
|
|
186
|
+
if (loc.kind === "step") return loc.turn ? loc.turn.turn : undefined;
|
|
187
|
+
return undefined;
|
|
188
|
+
} catch (e) { return undefined; }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ── entity extraction (pure, no react) ───────────────────────────────
|
|
192
|
+
// Attribute patterns are deliberately narrow: a canonical value must be
|
|
193
|
+
// adjacent to a known attribute keyword, which keeps false positives
|
|
194
|
+
// low on prose that merely mentions numbers.
|
|
195
|
+
//
|
|
196
|
+
// age : 25岁 / 25 岁 / 年龄25 / 现年三十
|
|
197
|
+
// year : 1998年 / 公元2035年 / 星历 412 年
|
|
198
|
+
// height : 身高180 / 180cm
|
|
199
|
+
// level : 等级 7 / 七级 / 境界三层
|
|
200
|
+
var CN_NUM = { 零: 0, 一: 1, 二: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9, 两: 2 };
|
|
201
|
+
// NUM accepts both arabic and Chinese numerals; normalizeValue collapses
|
|
202
|
+
// them to a canonical integer so "25岁" and "二十五岁" compare equal.
|
|
203
|
+
var NUM = "\\d{1,4}|[零一二三四五六七八九十百千两]+";
|
|
204
|
+
var ATTR_PATTERNS = [
|
|
205
|
+
{ attr: "age", re: new RegExp("(?:^|[^\\d])(" + NUM + ")\\s*(?:岁|周岁)", "g"), group: 1 },
|
|
206
|
+
{ attr: "age", re: new RegExp("(?:年龄|现年|时年)\\s*(?:是|为)?\\s*(" + NUM + ")", "g"), group: 1 },
|
|
207
|
+
{ attr: "year", re: new RegExp("(?:^|[^\\d])(" + NUM + ")\\s*年", "g"), group: 1 },
|
|
208
|
+
{ attr: "height", re: new RegExp("(?:身高)\\s*(?:是|为)?\\s*(" + NUM + ")", "g"), group: 1 },
|
|
209
|
+
{ attr: "height", re: new RegExp("(?:^|[^\\d])(\\d{2,3})\\s*(?:cm|CM|厘米)", "g"), group: 1 },
|
|
210
|
+
// 境界/修为 and 等级/级别 are distinct axes in Chinese genre fiction —
|
|
211
|
+
// merging them would invent conflicts between two unrelated numbers.
|
|
212
|
+
{ attr: "rank", re: new RegExp("(?:境界|修为)\\s*(?:是|为)?\\s*(" + NUM + ")", "g"), group: 1 },
|
|
213
|
+
{ attr: "level", re: new RegExp("(?:等级|级别)\\s*(?:是|为)?\\s*(" + NUM + ")", "g"), group: 1 }
|
|
214
|
+
];
|
|
215
|
+
|
|
216
|
+
// Chinese person names: a 2-4 CJK run that is followed by a speech or
|
|
217
|
+
// action verb, OR that sits at the head of a clause right before an
|
|
218
|
+
// attribute mention. Conservative by design — a dictionary hit always
|
|
219
|
+
// wins over this heuristic.
|
|
220
|
+
var NAME_RE = /([一-龥]{2,4})(?=\s*(?:说道|说道|说|道|问|答|心想|看着|转身|笑了|皱眉|点点头|摇头|今年|现年|时年|年龄|身高|等级|境界|修为|出生|站在|走|跑|回|抬|低|转|握|放))/g;
|
|
221
|
+
// Common surnames let us catch "林默今年25岁" where the name is followed
|
|
222
|
+
// directly by a temporal word rather than a verb.
|
|
223
|
+
var SURNAMES = "赵钱孙李周吴郑王冯陈褚卫蒋沈韩杨朱秦尤许何吕施张孔曹严华金魏陶姜戚谢邹喻柏水窦章云苏潘葛奚范彭郎鲁韦昌马苗凤花方俞任袁柳鲍史唐费廉岑薛雷贺倪汤滕殷罗毕郝邬安常乐于时傅皮卞齐康伍余元卜顾孟平黄和穆萧尹姚邵湛汪祁毛禹狄米贝明臧计伏成戴谈宋茅庞熊纪舒屈项祝董梁杜阮蓝闵席季麻强贾路娄危江童颜郭梅盛林刁钟徐邱骆高夏蔡田樊胡凌霍虞万支柯昝管卢莫经房裘缪干解应宗丁宣贲邓郁单杭洪包诸左石崔吉钮龚程嵇邢滑裴陆荣翁荀羊於惠甄曲家封芮羿储靳汲邴糜松井段富巫乌焦巴弓牧隗山谷车侯宓蓬全郗班仰秋仲伊宫宁仇栾暴甘钭厉戎祖武符刘景詹束龙叶幸司韶郜黎蓟薄印宿白怀蒲邰从鄂索咸籍赖卓蔺屠蒙池乔阴鬱胥能苍双闻莘党翟谭贡劳逄姬申扶堵冉宰郦雍卻璩桑桂濮牛寿通边扈燕冀郏浦尚农温别庄晏柴瞿阎充慕连茹习宦艾鱼容向古易慎戈廖庾终暨居衡步都耿满弘匡国文寇广禄阙东欧殳沃利蔚越夔隆师巩厍聂晁勾敖融冷訾辛阚那简饶空曾毋沙乜养鞠须丰巢关蒯相查后荆红游竺权逯盖益桓公";
|
|
224
|
+
var SURNAME_RE = new RegExp("([" + SURNAMES + "][一-龥]{1,3})(?![一-龥])", "g");
|
|
225
|
+
|
|
226
|
+
// The surname table is broad, so a bare surname match can swallow
|
|
227
|
+
// adjacent prose ("境界三层的修士" → "层的修士"). Reject any candidate
|
|
228
|
+
// that contains an attribute keyword or a common non-name particle.
|
|
229
|
+
var NAME_STOPWORDS = ["层", "级", "岁", "年", "的", "了", "是", "在", "和", "与", "个", "这", "那", "之", "地", "得", "着", "过", "们", "修士", "境界", "等级", "身高", "年龄"];
|
|
230
|
+
function plausibleName(candidate) {
|
|
231
|
+
if (typeof candidate !== "string" || candidate.length < 2) return false;
|
|
232
|
+
for (var i = 0; i < NAME_STOPWORDS.length; i++) {
|
|
233
|
+
if (candidate.indexOf(NAME_STOPWORDS[i]) !== -1) return false;
|
|
234
|
+
}
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function cnNumToInt(s) {
|
|
239
|
+
if (typeof s !== "string") return NaN;
|
|
240
|
+
if (/^\d+$/.test(s)) return parseInt(s, 10);
|
|
241
|
+
// handle 十 / 二十 / 三十五 style numerals
|
|
242
|
+
if (s.indexOf("十") === -1) {
|
|
243
|
+
var v = 0;
|
|
244
|
+
for (var i = 0; i < s.length; i++) {
|
|
245
|
+
if (!(s[i] in CN_NUM)) return NaN;
|
|
246
|
+
v = v * 10 + CN_NUM[s[i]];
|
|
247
|
+
}
|
|
248
|
+
return isNaN(v) ? NaN : v;
|
|
249
|
+
}
|
|
250
|
+
var parts = s.split("十");
|
|
251
|
+
var tens = parts[0] === "" ? 1 : (parts[0] in CN_NUM ? CN_NUM[parts[0]] : 1);
|
|
252
|
+
var ones = parts[1] === "" || parts[1] === undefined ? 0 : (parts[1] in CN_NUM ? CN_NUM[parts[1]] : NaN);
|
|
253
|
+
if (isNaN(tens) || isNaN(ones)) return NaN;
|
|
254
|
+
return tens * 10 + ones;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Normalize a raw attribute value so equivalent spellings compare equal:
|
|
258
|
+
// "25岁" and "25 岁" and "二十五岁" all collapse to the same numeric key.
|
|
259
|
+
function normalizeValue(attr, raw) {
|
|
260
|
+
var s = oneLine(String(raw));
|
|
261
|
+
if (attr === "age" || attr === "year" || attr === "height" || attr === "level") {
|
|
262
|
+
var m = s.match(/(\d+)/);
|
|
263
|
+
if (m) return String(parseInt(m[1], 10));
|
|
264
|
+
var cn = s.match(/[零一二三四五六七八九十两]+/);
|
|
265
|
+
if (cn) {
|
|
266
|
+
var v = cnNumToInt(cn[0]);
|
|
267
|
+
if (!isNaN(v)) return String(v);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return s;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Extract (entity, attr, value) mentions from one prose chunk.
|
|
274
|
+
// entities: optional known-name set used to attribute values to a
|
|
275
|
+
// speaker; when absent, values are recorded under a synthetic subject.
|
|
276
|
+
function extractMentions(text, turn, key, known) {
|
|
277
|
+
var out = [];
|
|
278
|
+
if (typeof text !== "string" || !text) return out;
|
|
279
|
+
// Split into sentences so an attribute can be attributed to the
|
|
280
|
+
// nearest name mentioned in the same sentence.
|
|
281
|
+
var sentences = text.split(/[。!?!?\n;;]/);
|
|
282
|
+
for (var s = 0; s < sentences.length; s++) {
|
|
283
|
+
var sentence = sentences[s];
|
|
284
|
+
if (!sentence) continue;
|
|
285
|
+
var subject = null;
|
|
286
|
+
// Known names from the dictionary win; then a common-surname run;
|
|
287
|
+
// then the verb-adjacent heuristic.
|
|
288
|
+
if (known && known.length) {
|
|
289
|
+
for (var kn = 0; kn < known.length; kn++) {
|
|
290
|
+
if (sentence.indexOf(known[kn]) !== -1) { subject = known[kn]; break; }
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (subject === null) {
|
|
294
|
+
SURNAME_RE.lastIndex = 0;
|
|
295
|
+
var sm;
|
|
296
|
+
while ((sm = SURNAME_RE.exec(sentence)) !== null) {
|
|
297
|
+
if (plausibleName(sm[1])) { subject = sm[1]; break; }
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if (subject === null) {
|
|
301
|
+
NAME_RE.lastIndex = 0;
|
|
302
|
+
var nm;
|
|
303
|
+
while ((nm = NAME_RE.exec(sentence)) !== null) {
|
|
304
|
+
if (plausibleName(nm[1])) { subject = nm[1]; break; }
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
for (var p = 0; p < ATTR_PATTERNS.length; p++) {
|
|
308
|
+
var pat = ATTR_PATTERNS[p];
|
|
309
|
+
pat.re.lastIndex = 0;
|
|
310
|
+
var m;
|
|
311
|
+
while ((m = pat.re.exec(sentence)) !== null) {
|
|
312
|
+
var raw = m[pat.group];
|
|
313
|
+
var norm = normalizeValue(pat.attr, raw);
|
|
314
|
+
if (norm === "") continue;
|
|
315
|
+
// Collapse only genuine pattern overlaps: the same attribute and
|
|
316
|
+
// the same normalized value inside one sentence (e.g. "身高180cm"
|
|
317
|
+
// matches both the 身高 and the cm rule). Distinct values in one
|
|
318
|
+
// sentence are real data ("境界三层,等级 9") and must be kept.
|
|
319
|
+
var dup = false;
|
|
320
|
+
for (var q = 0; q < out.length; q++) {
|
|
321
|
+
if (out[q].attr === pat.attr && out[q].value === norm &&
|
|
322
|
+
out[q].sentence === oneLine(sentence).slice(0, 120)) { dup = true; break; }
|
|
323
|
+
}
|
|
324
|
+
if (dup) continue;
|
|
325
|
+
out.push({
|
|
326
|
+
entity: subject || null,
|
|
327
|
+
attr: pat.attr,
|
|
328
|
+
raw: raw,
|
|
329
|
+
value: norm,
|
|
330
|
+
turn: turn,
|
|
331
|
+
key: key,
|
|
332
|
+
sentence: oneLine(sentence).slice(0, 120)
|
|
333
|
+
});
|
|
334
|
+
if (pat.re.lastIndex === m.index) pat.re.lastIndex++;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return out;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ── conflict detection (pure) ────────────────────────────────────────
|
|
342
|
+
// Two severity tiers:
|
|
343
|
+
// numeric — the same (entity, attribute) carries two different
|
|
344
|
+
// normalized numeric values. This is a hard contradiction
|
|
345
|
+
// (e.g. a character aged 25 in one chapter and 27 in the next).
|
|
346
|
+
// wording — the same (entity, attribute) carries values that normalize
|
|
347
|
+
// differently but neither is numeric, i.e. a spelling or
|
|
348
|
+
// phrasing drift rather than a factual break.
|
|
349
|
+
//
|
|
350
|
+
// Mentions without a resolvable entity are grouped under a synthetic
|
|
351
|
+
// "(unattributed)" bucket so they can still be reviewed, but they never
|
|
352
|
+
// raise a numeric conflict on their own — attributing a value to the
|
|
353
|
+
// wrong subject would be worse than missing it.
|
|
354
|
+
var UNATTRIBUTED = "(unattributed)";
|
|
355
|
+
var NUMERIC_ATTRS = { age: 1, year: 1, height: 1, level: 1, rank: 1 };
|
|
356
|
+
|
|
357
|
+
function detectConflicts(mentions) {
|
|
358
|
+
var groups = {};
|
|
359
|
+
var order = [];
|
|
360
|
+
for (var i = 0; i < mentions.length; i++) {
|
|
361
|
+
var m = mentions[i];
|
|
362
|
+
var entity = m.entity || UNATTRIBUTED;
|
|
363
|
+
var gkey = entity + "||" + m.attr;
|
|
364
|
+
if (!groups[gkey]) { groups[gkey] = []; order.push(gkey); }
|
|
365
|
+
groups[gkey].push(m);
|
|
366
|
+
}
|
|
367
|
+
var out = [];
|
|
368
|
+
for (var g = 0; g < order.length; g++) {
|
|
369
|
+
var items = groups[order[g]];
|
|
370
|
+
if (items.length < 2) continue;
|
|
371
|
+
var first = items[0];
|
|
372
|
+
var seen = {};
|
|
373
|
+
var values = [];
|
|
374
|
+
for (var j = 0; j < items.length; j++) {
|
|
375
|
+
if (!seen[items[j].value]) { seen[items[j].value] = true; values.push(items[j].value); }
|
|
376
|
+
}
|
|
377
|
+
if (values.length < 2) continue;
|
|
378
|
+
// Unattributed mentions only ever produce a wording-level hint.
|
|
379
|
+
var severity = (first.entity && NUMERIC_ATTRS[first.attr]) ? "numeric" : "wording";
|
|
380
|
+
// Report the two earliest differing occurrences: the canonical one
|
|
381
|
+
// and the first deviation from it.
|
|
382
|
+
var base = items[0];
|
|
383
|
+
var other = null;
|
|
384
|
+
for (var k = 1; k < items.length; k++) {
|
|
385
|
+
if (items[k].value !== base.value) { other = items[k]; break; }
|
|
386
|
+
}
|
|
387
|
+
if (!other) continue;
|
|
388
|
+
out.push({
|
|
389
|
+
entity: first.entity || UNATTRIBUTED,
|
|
390
|
+
attr: first.attr,
|
|
391
|
+
severity: severity,
|
|
392
|
+
from: base,
|
|
393
|
+
to: other,
|
|
394
|
+
count: values.length,
|
|
395
|
+
occurrences: items.length
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
// Hard contradictions first, then by earliest turn.
|
|
399
|
+
out.sort(function (a, b) {
|
|
400
|
+
if (a.severity !== b.severity) return a.severity === "numeric" ? -1 : 1;
|
|
401
|
+
return (a.from.turn || 0) - (b.from.turn || 0);
|
|
402
|
+
});
|
|
403
|
+
return out;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Collect every prose mention across the whole conversation snapshot.
|
|
407
|
+
function collectMentions(snapshot, knownNames) {
|
|
408
|
+
var out = [];
|
|
409
|
+
var chat = snapshot && snapshot.chat;
|
|
410
|
+
if (!chat || !chat.order || !chat.nodes) return out;
|
|
411
|
+
var keys = chat.order;
|
|
412
|
+
for (var i = 0; i < keys.length; i++) {
|
|
413
|
+
var node;
|
|
414
|
+
try { node = chat.nodes.get(keys[i]); } catch (e) { node = undefined; }
|
|
415
|
+
if (!node) continue;
|
|
416
|
+
if (node.visibility === "hidden") continue;
|
|
417
|
+
var text = nodeText(node);
|
|
418
|
+
if (!text) continue;
|
|
419
|
+
var turn = nodeTurn(node);
|
|
420
|
+
var found = extractMentions(text, turn === undefined ? 0 : turn, node.key, knownNames);
|
|
421
|
+
for (var f = 0; f < found.length; f++) out.push(found[f]);
|
|
422
|
+
}
|
|
423
|
+
return out;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// ── setting dictionary (browser-local persistence) ───────────────────
|
|
427
|
+
function loadDict() {
|
|
428
|
+
try {
|
|
429
|
+
if (typeof localStorage === "undefined") return [];
|
|
430
|
+
var raw = localStorage.getItem(STORE_KEY);
|
|
431
|
+
if (!raw) return [];
|
|
432
|
+
var parsed = JSON.parse(raw);
|
|
433
|
+
if (!Array.isArray(parsed)) return [];
|
|
434
|
+
return parsed.filter(function (e) {
|
|
435
|
+
return e && typeof e.entity === "string" && typeof e.attr === "string";
|
|
436
|
+
});
|
|
437
|
+
} catch (e) { return []; }
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function saveDict(entries) {
|
|
441
|
+
try {
|
|
442
|
+
if (typeof localStorage === "undefined") return false;
|
|
443
|
+
localStorage.setItem(STORE_KEY, JSON.stringify(entries));
|
|
444
|
+
return true;
|
|
445
|
+
} catch (e) { return false; }
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function dictNames(entries) {
|
|
449
|
+
var out = [];
|
|
450
|
+
for (var i = 0; i < entries.length; i++) {
|
|
451
|
+
if (entries[i] && entries[i].entity && out.indexOf(entries[i].entity) === -1) {
|
|
452
|
+
out.push(entries[i].entity);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return out;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// ── DOM jump helpers (same mechanism as dsh-conversation-timeline) ───
|
|
459
|
+
function findRow(key) {
|
|
460
|
+
try {
|
|
461
|
+
var rows = document.querySelectorAll("[data-chat-anchor-key]");
|
|
462
|
+
for (var i = 0; i < rows.length; i++) {
|
|
463
|
+
if (rows[i].dataset && rows[i].dataset.chatAnchorKey === key) return rows[i];
|
|
464
|
+
}
|
|
465
|
+
} catch (e) {}
|
|
466
|
+
return null;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function flashRow(row) {
|
|
470
|
+
try {
|
|
471
|
+
row.style.transition = "box-shadow .2s ease";
|
|
472
|
+
row.style.boxShadow = "0 0 0 2px #d6405f, 0 0 0 6px rgba(214,64,95,.18)";
|
|
473
|
+
setTimeout(function () { row.style.boxShadow = ""; }, 1800);
|
|
474
|
+
} catch (e) {}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Switch to the chat view tab, then poll until the target row mounts.
|
|
478
|
+
// Older history may need an explicit loadOlder() before the row exists.
|
|
479
|
+
function switchToChatAndJump(key, snapshot, loadOlder) {
|
|
480
|
+
try {
|
|
481
|
+
var tabs = Array.prototype.slice.call(document.querySelectorAll('[role="tab"]'));
|
|
482
|
+
var chatTab = null;
|
|
483
|
+
for (var i = 0; i < tabs.length; i++) {
|
|
484
|
+
var text = (tabs[i].textContent || "").trim();
|
|
485
|
+
if (text === "对话" || text === "Chat") { chatTab = tabs[i]; break; }
|
|
486
|
+
}
|
|
487
|
+
if (!chatTab && tabs.length > 0) chatTab = tabs[0];
|
|
488
|
+
if (chatTab) chatTab.click();
|
|
489
|
+
} catch (e) {}
|
|
490
|
+
var attempts = 0;
|
|
491
|
+
var olderCalls = 0;
|
|
492
|
+
var timer = setInterval(function () {
|
|
493
|
+
attempts++;
|
|
494
|
+
var row = findRow(key);
|
|
495
|
+
if (row) {
|
|
496
|
+
clearInterval(timer);
|
|
497
|
+
row.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
498
|
+
flashRow(row);
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
if (attempts > 120) { clearInterval(timer); return; }
|
|
502
|
+
if (olderCalls < 3 && attempts % 12 === 0 && snapshot && snapshot.hasMore && loadOlder) {
|
|
503
|
+
olderCalls++;
|
|
504
|
+
try { loadOlder(); } catch (e) {}
|
|
505
|
+
}
|
|
506
|
+
}, 50);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// ── dock bar: conflict chip + expandable list ────────────────────────
|
|
510
|
+
// The list is portaled to document.body: the dock sits inside the
|
|
511
|
+
// composer's layout container, which would clip an absolutely
|
|
512
|
+
// positioned panel.
|
|
513
|
+
function DockGuard(props) {
|
|
514
|
+
var session = props.session;
|
|
515
|
+
var t = props.t;
|
|
516
|
+
var loadOlder = props.loadOlder;
|
|
517
|
+
var dict = props.dict;
|
|
518
|
+
var known = useMemo(function () { return dictNames(dict); }, [dict]);
|
|
519
|
+
var conflicts = useMemo(function () {
|
|
520
|
+
return detectConflicts(collectMentions(session, known));
|
|
521
|
+
}, [session, known]);
|
|
522
|
+
var openState = useState(false);
|
|
523
|
+
var open = openState[0];
|
|
524
|
+
var setOpen = openState[1];
|
|
525
|
+
var posState = useState(null);
|
|
526
|
+
var pos = posState[0];
|
|
527
|
+
var setPos = posState[1];
|
|
528
|
+
var wrapRef = useRef(null);
|
|
529
|
+
|
|
530
|
+
useEffect(function () {
|
|
531
|
+
if (!open) return undefined;
|
|
532
|
+
function onDown(e) {
|
|
533
|
+
try {
|
|
534
|
+
if (wrapRef.current && wrapRef.current.contains(e.target)) return;
|
|
535
|
+
} catch (err) {}
|
|
536
|
+
setOpen(false);
|
|
537
|
+
}
|
|
538
|
+
document.addEventListener("mousedown", onDown);
|
|
539
|
+
return function () { document.removeEventListener("mousedown", onDown); };
|
|
540
|
+
}, [open]);
|
|
541
|
+
|
|
542
|
+
var dirty = conflicts.length > 0;
|
|
543
|
+
var cls = "dcg-chip " + (dirty ? "dcg-chipDirty" : "dcg-chipClean");
|
|
544
|
+
var label = dirty ? t("dock.conflicts", { n: conflicts.length }) : t("dock.empty");
|
|
545
|
+
|
|
546
|
+
var panel = null;
|
|
547
|
+
if (open && typeof document !== "undefined" && document.body && pos) {
|
|
548
|
+
panel = createPortal(
|
|
549
|
+
createElement(
|
|
550
|
+
"div",
|
|
551
|
+
{ className: "dcg-list", style: { left: pos.left + "px", top: pos.top + "px" } },
|
|
552
|
+
conflicts.length === 0
|
|
553
|
+
? createElement("div", { className: "dcg-listEmpty" }, t("list.empty"))
|
|
554
|
+
: [
|
|
555
|
+
createElement("div", { key: "hint", className: "dcg-hint" }, t("list.hint")),
|
|
556
|
+
conflicts.map(function (c, idx) {
|
|
557
|
+
return createElement(
|
|
558
|
+
"button",
|
|
559
|
+
{
|
|
560
|
+
key: "cf-" + idx,
|
|
561
|
+
type: "button",
|
|
562
|
+
className: "dcg-item",
|
|
563
|
+
onClick: function () {
|
|
564
|
+
switchToChatAndJump(c.to.key, session, loadOlder);
|
|
565
|
+
setOpen(false);
|
|
566
|
+
}
|
|
567
|
+
},
|
|
568
|
+
createElement(
|
|
569
|
+
"span",
|
|
570
|
+
{ className: "dcg-badge " + (c.severity === "numeric" ? "dcg-badgeNumeric" : "dcg-badgeWording") },
|
|
571
|
+
t("conflict." + c.severity)
|
|
572
|
+
),
|
|
573
|
+
createElement(
|
|
574
|
+
"span",
|
|
575
|
+
{ className: "dcg-itemBody" },
|
|
576
|
+
createElement(
|
|
577
|
+
"div",
|
|
578
|
+
{ className: "dcg-itemTop" },
|
|
579
|
+
c.entity + " · " + t("attr." + c.attr)
|
|
580
|
+
),
|
|
581
|
+
createElement(
|
|
582
|
+
"div",
|
|
583
|
+
{ className: "dcg-itemSub" },
|
|
584
|
+
t("conflict.values", { a: c.from.value, b: c.to.value }) +
|
|
585
|
+
" · " + t("conflict.at", { n: c.from.turn }) + " → " + t("conflict.at", { n: c.to.turn })
|
|
586
|
+
)
|
|
587
|
+
)
|
|
588
|
+
);
|
|
589
|
+
})
|
|
590
|
+
]
|
|
591
|
+
),
|
|
592
|
+
document.body
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
return createElement(
|
|
597
|
+
"div",
|
|
598
|
+
{ className: "dcg-dock", ref: wrapRef },
|
|
599
|
+
createElement("span", { className: "dcg-dockTitle" }, t("dock.title")),
|
|
600
|
+
createElement(
|
|
601
|
+
"button",
|
|
602
|
+
{
|
|
603
|
+
type: "button",
|
|
604
|
+
className: cls,
|
|
605
|
+
onClick: function (e) {
|
|
606
|
+
if (open) { setOpen(false); return; }
|
|
607
|
+
try {
|
|
608
|
+
var rect = e.currentTarget.getBoundingClientRect();
|
|
609
|
+
var top = Math.round(rect.top - 8);
|
|
610
|
+
var left = Math.round(rect.left);
|
|
611
|
+
var width = 560;
|
|
612
|
+
var maxLeft = window.innerWidth - width - 24;
|
|
613
|
+
if (left > maxLeft) left = Math.max(12, maxLeft);
|
|
614
|
+
setPos({ left: left, top: top });
|
|
615
|
+
} catch (err) {
|
|
616
|
+
setPos({ left: 24, top: 24 });
|
|
617
|
+
}
|
|
618
|
+
setOpen(true);
|
|
619
|
+
}
|
|
620
|
+
},
|
|
621
|
+
createElement("span", { className: "dcg-dot" }),
|
|
622
|
+
label
|
|
623
|
+
),
|
|
624
|
+
createElement("span", { className: "dcg-spacer" }),
|
|
625
|
+
panel
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// ── settings section: canonical setting dictionary editor ────────────
|
|
630
|
+
function SettingsGuard(props) {
|
|
631
|
+
var t = props.t;
|
|
632
|
+
var dict = props.dict;
|
|
633
|
+
var onSave = props.onSave;
|
|
634
|
+
var draftState = useState(dict);
|
|
635
|
+
var draft = draftState[0];
|
|
636
|
+
var setDraft = draftState[1];
|
|
637
|
+
var savedState = useState(false);
|
|
638
|
+
var saved = savedState[0];
|
|
639
|
+
var setSaved = savedState[1];
|
|
640
|
+
|
|
641
|
+
function update(index, field, value) {
|
|
642
|
+
var next = draft.slice();
|
|
643
|
+
next[index] = Object.assign({}, next[index], { [field]: value });
|
|
644
|
+
setDraft(next);
|
|
645
|
+
setSaved(false);
|
|
646
|
+
}
|
|
647
|
+
function addRow() {
|
|
648
|
+
setDraft(draft.concat([{ entity: "", attr: "age", value: "" }]));
|
|
649
|
+
setSaved(false);
|
|
650
|
+
}
|
|
651
|
+
function removeRow(index) {
|
|
652
|
+
var next = draft.slice();
|
|
653
|
+
next.splice(index, 1);
|
|
654
|
+
setDraft(next);
|
|
655
|
+
setSaved(false);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
return createElement(
|
|
659
|
+
"div",
|
|
660
|
+
{ className: "dcg-settings" },
|
|
661
|
+
createElement("div", null, t("settings.desc")),
|
|
662
|
+
draft.length === 0
|
|
663
|
+
? createElement("div", { className: "dcg-empty" }, t("settings.empty"))
|
|
664
|
+
: draft.map(function (entry, index) {
|
|
665
|
+
return createElement(
|
|
666
|
+
"div",
|
|
667
|
+
{ key: "row-" + index, className: "dcg-row" },
|
|
668
|
+
createElement("input", {
|
|
669
|
+
value: entry.entity || "",
|
|
670
|
+
placeholder: t("settings.entity"),
|
|
671
|
+
onChange: function (e) { update(index, "entity", e.target.value); }
|
|
672
|
+
}),
|
|
673
|
+
createElement("input", {
|
|
674
|
+
value: entry.attr || "",
|
|
675
|
+
placeholder: t("settings.attr"),
|
|
676
|
+
onChange: function (e) { update(index, "attr", e.target.value); }
|
|
677
|
+
}),
|
|
678
|
+
createElement("input", {
|
|
679
|
+
value: entry.value || "",
|
|
680
|
+
placeholder: t("settings.value"),
|
|
681
|
+
onChange: function (e) { update(index, "value", e.target.value); }
|
|
682
|
+
}),
|
|
683
|
+
createElement(
|
|
684
|
+
"button",
|
|
685
|
+
{ type: "button", onClick: function () { removeRow(index); } },
|
|
686
|
+
t("settings.remove")
|
|
687
|
+
)
|
|
688
|
+
);
|
|
689
|
+
}),
|
|
690
|
+
createElement(
|
|
691
|
+
"div",
|
|
692
|
+
{ className: "dcg-actions" },
|
|
693
|
+
createElement("button", { type: "button", onClick: addRow }, t("settings.add")),
|
|
694
|
+
createElement(
|
|
695
|
+
"button",
|
|
696
|
+
{
|
|
697
|
+
type: "button",
|
|
698
|
+
onClick: function () {
|
|
699
|
+
onSave(draft.filter(function (e) { return e.entity && e.value; }));
|
|
700
|
+
setSaved(true);
|
|
701
|
+
}
|
|
702
|
+
},
|
|
703
|
+
t("settings.save")
|
|
704
|
+
),
|
|
705
|
+
saved ? createElement("span", { className: "dcg-saved" }, t("settings.saved")) : null
|
|
706
|
+
)
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// ── plugin entry ─────────────────────────────────────────────────────
|
|
711
|
+
var inject = ["slots", "sessions", "locale"];
|
|
712
|
+
|
|
713
|
+
function apply(rawContext) {
|
|
714
|
+
var ctx = rawContext;
|
|
715
|
+
ensureStyle();
|
|
716
|
+
ctx.effect(
|
|
717
|
+
function () { return ctx.locale.register(NS, { zh: zh, en: en }); },
|
|
718
|
+
"dsh-consistency-guard: dictionaries"
|
|
719
|
+
);
|
|
720
|
+
var t = ctx.locale.bind(NS);
|
|
721
|
+
|
|
722
|
+
// The dictionary lives in browser-local storage and is shared by both
|
|
723
|
+
// surfaces through this tiny observable, so a settings edit is picked
|
|
724
|
+
// up by the dock without a reload.
|
|
725
|
+
var dict = loadDict();
|
|
726
|
+
var listeners = [];
|
|
727
|
+
function getDict() { return dict; }
|
|
728
|
+
function setDict(next) {
|
|
729
|
+
dict = next;
|
|
730
|
+
saveDict(next);
|
|
731
|
+
for (var i = 0; i < listeners.length; i++) {
|
|
732
|
+
try { listeners[i](next); } catch (e) {}
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
function subscribe(fn) {
|
|
736
|
+
listeners.push(fn);
|
|
737
|
+
return function () {
|
|
738
|
+
var idx = listeners.indexOf(fn);
|
|
739
|
+
if (idx >= 0) listeners.splice(idx, 1);
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// 1) conflict chip embedded in the conversation flow (above the composer)
|
|
744
|
+
ctx.slots.inject("conversation.input.dock", function () {
|
|
745
|
+
return ctx.slots.register({
|
|
746
|
+
name: "conversation.input.dock",
|
|
747
|
+
id: "consistency-guard-dock",
|
|
748
|
+
order: 20,
|
|
749
|
+
locale: NS,
|
|
750
|
+
inject: function (sessionId) {
|
|
751
|
+
return {
|
|
752
|
+
...(typeof sessionId === "string" && sessionId ? { sessionId: sessionId } : {}),
|
|
753
|
+
t: t,
|
|
754
|
+
getDict: getDict,
|
|
755
|
+
subscribe: subscribe,
|
|
756
|
+
loadOlder: async function () {
|
|
757
|
+
try {
|
|
758
|
+
var binding = ctx.sessions.binding(sessionId);
|
|
759
|
+
var session = binding && binding.session;
|
|
760
|
+
if (session && typeof session.loadOlder === "function") await session.loadOlder();
|
|
761
|
+
} catch (e) {}
|
|
762
|
+
}
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
}, function (owner) {
|
|
766
|
+
var dictState = useState(owner.getDict ? owner.getDict() : []);
|
|
767
|
+
var d = dictState[0];
|
|
768
|
+
var setD = dictState[1];
|
|
769
|
+
useEffect(function () {
|
|
770
|
+
if (!owner.subscribe) return undefined;
|
|
771
|
+
return owner.subscribe(setD);
|
|
772
|
+
}, [owner.subscribe]);
|
|
773
|
+
return createElement(DockGuard, {
|
|
774
|
+
session: owner.session,
|
|
775
|
+
t: owner.t,
|
|
776
|
+
loadOlder: owner.loadOlder,
|
|
777
|
+
dict: d
|
|
778
|
+
});
|
|
779
|
+
});
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
// 2) setting dictionary editor in the settings panel
|
|
783
|
+
ctx.slots.inject("settings.section", function () {
|
|
784
|
+
return ctx.slots.register({
|
|
785
|
+
name: "settings.section",
|
|
786
|
+
id: "consistency-guard-settings",
|
|
787
|
+
order: 30,
|
|
788
|
+
locale: NS,
|
|
789
|
+
inject: function () {
|
|
790
|
+
return { t: t, getDict: getDict, setDict: setDict };
|
|
791
|
+
}
|
|
792
|
+
}, function (owner) {
|
|
793
|
+
var dictState = useState(owner.getDict ? owner.getDict() : []);
|
|
794
|
+
var d = dictState[0];
|
|
795
|
+
var setD = dictState[1];
|
|
796
|
+
return createElement(SettingsGuard, {
|
|
797
|
+
t: owner.t,
|
|
798
|
+
dict: d,
|
|
799
|
+
onSave: function (next) {
|
|
800
|
+
if (owner.setDict) owner.setDict(next);
|
|
801
|
+
setD(next);
|
|
802
|
+
}
|
|
803
|
+
});
|
|
804
|
+
});
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
exports.apply = apply;
|
|
809
|
+
exports.inject = inject;
|
|
810
|
+
// Pure helpers are exposed for the smoke test (no react needed).
|
|
811
|
+
exports.__test = {
|
|
812
|
+
nodeText: nodeText,
|
|
813
|
+
nodeTurn: nodeTurn,
|
|
814
|
+
extractMentions: extractMentions,
|
|
815
|
+
normalizeValue: normalizeValue,
|
|
816
|
+
cnNumToInt: cnNumToInt,
|
|
817
|
+
oneLine: oneLine,
|
|
818
|
+
plausibleName: plausibleName,
|
|
819
|
+
detectConflicts: detectConflicts,
|
|
820
|
+
collectMentions: collectMentions,
|
|
821
|
+
loadDict: loadDict,
|
|
822
|
+
saveDict: saveDict,
|
|
823
|
+
dictNames: dictNames
|
|
824
|
+
};
|
|
825
|
+
return module.exports;
|
|
826
|
+
}
|
|
827
|
+
});
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// dsh-consistency-guard — server-side mount.
|
|
2
|
+
//
|
|
3
|
+
// This is a UI-only plugin: every feature lives in the client bundle
|
|
4
|
+
// (lib/client.js, declared under dsh.client), which scans the conversation
|
|
5
|
+
// prose for setting inconsistencies and registers the conflict dock into
|
|
6
|
+
// the conversation slots. The server entry exists so the cordis loader can
|
|
7
|
+
// mount the bundle entry declared in cordis.patch.yml.
|
|
8
|
+
|
|
9
|
+
export const name = "consistency-guard";
|
|
10
|
+
|
|
11
|
+
export function apply() {
|
|
12
|
+
// UI-only: no server-side services are provided.
|
|
13
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-consistency-guard",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Consistency guard for long-form writing in DeepSeek Harness: scans conversation prose for character, timeline and numeric setting conflicts, then jumps straight to the conflicting lines.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/bycall/dsh-consistency-guard.git"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"main": "lib/index.js",
|
|
11
|
+
"scripts": {
|
|
12
|
+
"check": "node --check lib/client.js && node --check lib/index.js",
|
|
13
|
+
"test": "node test/smoke.cjs",
|
|
14
|
+
"verify": "npm run check && npm test",
|
|
15
|
+
"prepublishOnly": "npm run verify"
|
|
16
|
+
},
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"default": "./lib/index.js"
|
|
20
|
+
},
|
|
21
|
+
"./client": {
|
|
22
|
+
"default": "./lib/client.js"
|
|
23
|
+
},
|
|
24
|
+
"./package.json": "./package.json"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"lib",
|
|
28
|
+
"cordis.patch.yml",
|
|
29
|
+
"README.md",
|
|
30
|
+
"LICENSE"
|
|
31
|
+
],
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"dsh": {
|
|
34
|
+
"bundle": {
|
|
35
|
+
"patch": "./cordis.patch.yml"
|
|
36
|
+
},
|
|
37
|
+
"client": {
|
|
38
|
+
"inject": [
|
|
39
|
+
"slots",
|
|
40
|
+
"sessions",
|
|
41
|
+
"locale"
|
|
42
|
+
],
|
|
43
|
+
"platform": "web"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|