clearai-dsh 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/CHANGELOG.md +26 -0
- package/LICENSE +201 -0
- package/README.md +138 -0
- package/README.zh-CN.md +138 -0
- package/bin/clearai.mjs +224 -0
- package/brand/README.md +41 -0
- package/brand/logo-512-dark.png +0 -0
- package/brand/logo-512.png +0 -0
- package/brand/logo-lockup-dark.png +0 -0
- package/brand/logo-lockup.png +0 -0
- package/brand/logo-lockup.svg +12 -0
- package/brand/logo-wordmark.svg +6 -0
- package/brand/logo.svg +19 -0
- package/cordis.patch.yml +39 -0
- package/lib/client.js +3071 -0
- package/lib/fold.js +1576 -0
- package/lib/host.js +605 -0
- package/package.json +65 -0
- package/presets/clearai/agent.cordis.yml +226 -0
- package/presets/clearai/plugins/brain.js +547 -0
- package/presets/clearai/plugins/clearai-kernel.js +5485 -0
- package/presets/clearai/plugins/ontology.js +306 -0
- package/presets/clearai/plugins/prompts.js +312 -0
- package/presets/clearai/preset.yml +5 -0
- package/presets/clearai/skills/clearai-loop/SKILL.md +89 -0
- package/presets/clearai/template/knowledge/README.md +25 -0
- package/presets/clearai/template/memory/README.md +34 -0
- package/presets/clearai/template/project.md +49 -0
- package/presets/clearai/template/skills/README.md +37 -0
- package/presets/clearai/template/skills/chart-diagram-qa/SKILL.md +43 -0
- package/presets/clearai/template/skills/citation-management/SKILL.md +73 -0
- package/presets/clearai/template/skills/citation-management/references/bibtex_formatting.md +908 -0
- package/presets/clearai/template/skills/citation-management/references/citation_validation.md +794 -0
- package/presets/clearai/template/skills/citation-management/references/google_scholar_search.md +725 -0
- package/presets/clearai/template/skills/citation-management/references/metadata_extraction.md +870 -0
- package/presets/clearai/template/skills/citation-management/references/pubmed_search.md +839 -0
- package/presets/clearai/template/skills/citation-management/scripts/doi_to_bibtex.py +204 -0
- package/presets/clearai/template/skills/citation-management/scripts/extract_metadata.py +569 -0
- package/presets/clearai/template/skills/citation-management/scripts/format_bibtex.py +349 -0
- package/presets/clearai/template/skills/citation-management/scripts/generate_schematic.py +139 -0
- package/presets/clearai/template/skills/citation-management/scripts/generate_schematic_ai.py +817 -0
- package/presets/clearai/template/skills/citation-management/scripts/search_google_scholar.py +282 -0
- package/presets/clearai/template/skills/citation-management/scripts/search_pubmed.py +398 -0
- package/presets/clearai/template/skills/citation-management/scripts/validate_citations.py +497 -0
- package/presets/clearai/template/skills/data-analysis/SKILL.md +92 -0
- package/presets/clearai/template/skills/data-analysis/checklists/readiness_check.md +23 -0
- package/presets/clearai/template/skills/data-analysis/templates/analysis_report.md.tpl +63 -0
- package/presets/clearai/template/skills/data-analysis/templates/cleaning_rules_draft.yaml.tpl +32 -0
- package/presets/clearai/template/skills/data-analysis/templates/data_dictionary.md.tpl +12 -0
- package/presets/clearai/template/skills/data-analysis/templates/domain_knowledge_template.md.tpl +316 -0
- package/presets/clearai/template/skills/data-analysis/templates/feature_candidates.json.tpl +20 -0
- package/presets/clearai/template/skills/data-analysis/templates/quality_scorecard.md.tpl +30 -0
- package/presets/clearai/template/skills/data-analysis/workflows/01-data-profiling.md +42 -0
- package/presets/clearai/template/skills/data-analysis/workflows/02-quality-audit.md +36 -0
- package/presets/clearai/template/skills/data-analysis/workflows/03-physical-correlation.md +25 -0
- package/presets/clearai/template/skills/data-analysis/workflows/04-unstructured-mining.md +26 -0
- package/presets/clearai/template/skills/data-qa-analysis/SKILL.md +102 -0
- package/presets/clearai/template/skills/data-qa-analysis/checklists/readiness_check.md +62 -0
- package/presets/clearai/template/skills/data-qa-analysis/templates/best_in_class_report.md.tpl +56 -0
- package/presets/clearai/template/skills/data-qa-analysis/templates/cleaning_rules_draft.yaml.tpl +56 -0
- package/presets/clearai/template/skills/data-qa-analysis/templates/data_dictionary.md.tpl +13 -0
- package/presets/clearai/template/skills/data-qa-analysis/templates/data_source_inventory_and_lineage.md.tpl +146 -0
- package/presets/clearai/template/skills/data-qa-analysis/templates/data_status_report.md.tpl +60 -0
- package/presets/clearai/template/skills/data-qa-analysis/templates/steady_state_rules.yaml.tpl +41 -0
- package/presets/clearai/template/skills/data-qa-analysis/templates/subsystem_registry.md.tpl +101 -0
- package/presets/clearai/template/skills/data-qa-analysis/templates/unified_execution_plan.md.tpl +100 -0
- package/presets/clearai/template/skills/data-qa-analysis/workflows/01-data-source-inventory-and-lineage.md +194 -0
- package/presets/clearai/template/skills/data-qa-analysis/workflows/02-data-alignment-and-tag-semantics.md +122 -0
- package/presets/clearai/template/skills/data-qa-analysis/workflows/03-steady-state-identification.md +126 -0
- package/presets/clearai/template/skills/data-qa-analysis/workflows/04-consumption-analysis.md +152 -0
- package/presets/clearai/template/skills/data-qa-analysis/workflows/05-best-in-class-and-optimization-space.md +78 -0
- package/presets/clearai/template/skills/domain-presearch/SKILL.md +131 -0
- package/presets/clearai/template/skills/domain-presearch/checklists/domain_checklist.md +24 -0
- package/presets/clearai/template/skills/domain-presearch/references/figure_code.md +78 -0
- package/presets/clearai/template/skills/domain-presearch/references/strategic_frameworks.md +38 -0
- package/presets/clearai/template/skills/exploration-loop/SKILL.md +81 -0
- package/presets/clearai/template/skills/exploratory-data-analysis/SKILL.md +77 -0
- package/presets/clearai/template/skills/exploratory-data-analysis/references/bioinformatics_genomics_formats.md +664 -0
- package/presets/clearai/template/skills/exploratory-data-analysis/references/chemistry_molecular_formats.md +664 -0
- package/presets/clearai/template/skills/exploratory-data-analysis/references/general_scientific_formats.md +518 -0
- package/presets/clearai/template/skills/exploratory-data-analysis/references/microscopy_imaging_formats.md +620 -0
- package/presets/clearai/template/skills/exploratory-data-analysis/references/proteomics_metabolomics_formats.md +517 -0
- package/presets/clearai/template/skills/exploratory-data-analysis/references/spectroscopy_analytical_formats.md +633 -0
- package/presets/clearai/template/skills/exploratory-data-analysis/scripts/eda_analyzer.py +547 -0
- package/presets/clearai/template/skills/hypothesis-generation/SKILL.md +73 -0
- package/presets/clearai/template/skills/hypothesis-generation/references/experimental_design_patterns.md +329 -0
- package/presets/clearai/template/skills/hypothesis-generation/references/hypothesis_quality_criteria.md +198 -0
- package/presets/clearai/template/skills/hypothesis-generation/references/literature_search_strategies.md +622 -0
- package/presets/clearai/template/skills/hypothesis-generation/scripts/generate_schematic.py +139 -0
- package/presets/clearai/template/skills/hypothesis-generation/scripts/generate_schematic_ai.py +817 -0
- package/presets/clearai/template/skills/literature-review/SKILL.md +72 -0
- package/presets/clearai/template/skills/literature-review/references/citation_styles.md +166 -0
- package/presets/clearai/template/skills/literature-review/references/database_strategies.md +455 -0
- package/presets/clearai/template/skills/literature-review/scripts/generate_pdf.py +176 -0
- package/presets/clearai/template/skills/literature-review/scripts/generate_schematic.py +139 -0
- package/presets/clearai/template/skills/literature-review/scripts/generate_schematic_ai.py +817 -0
- package/presets/clearai/template/skills/literature-review/scripts/search_databases.py +303 -0
- package/presets/clearai/template/skills/literature-review/scripts/verify_citations.py +221 -0
- package/presets/clearai/template/skills/paper-lookup/SKILL.md +59 -0
- package/presets/clearai/template/skills/paper-lookup/references/arxiv.md +161 -0
- package/presets/clearai/template/skills/paper-lookup/references/biorxiv.md +118 -0
- package/presets/clearai/template/skills/paper-lookup/references/core.md +150 -0
- package/presets/clearai/template/skills/paper-lookup/references/crossref.md +181 -0
- package/presets/clearai/template/skills/paper-lookup/references/medrxiv.md +104 -0
- package/presets/clearai/template/skills/paper-lookup/references/openalex.md +174 -0
- package/presets/clearai/template/skills/paper-lookup/references/pmc.md +152 -0
- package/presets/clearai/template/skills/paper-lookup/references/pubmed.md +124 -0
- package/presets/clearai/template/skills/paper-lookup/references/semantic-scholar.md +203 -0
- package/presets/clearai/template/skills/paper-lookup/references/unpaywall.md +127 -0
- package/presets/clearai/template/skills/process-presearch/SKILL.md +196 -0
- package/presets/clearai/template/skills/process-presearch/checklists/process_checklist.md +18 -0
- package/presets/clearai/template/skills/process-presearch/references/figure_code.md +107 -0
- package/presets/clearai/template/skills/process-presearch/references/source_attribution_example.md +22 -0
- package/presets/clearai/template/skills/process-understanding-extraction/SKILL.md +69 -0
- package/presets/clearai/template/skills/process-understanding-extraction/checklists/readiness_check.md +34 -0
- package/presets/clearai/template/skills/process-understanding-extraction/templates/docx_raw_dump_extractor.py.tpl +132 -0
- package/presets/clearai/template/skills/process-understanding-extraction/templates/entity_map_unit_topology.json.tpl +86 -0
- package/presets/clearai/template/skills/process-understanding-extraction/templates/process_brief.md.tpl +89 -0
- package/presets/clearai/template/skills/process-understanding-extraction/templates/process_brief_builder_from_raw_dump.py.tpl +203 -0
- package/presets/clearai/template/skills/process-understanding-extraction/templates/process_flow_mermaid.md.tpl +41 -0
- package/presets/clearai/template/skills/process-understanding-extraction/templates/unified_execution_plan.md.tpl +53 -0
- package/presets/clearai/template/skills/process-understanding-extraction/workflows/01-process-doc-discovery.md +173 -0
- package/presets/clearai/template/skills/process-understanding-extraction/workflows/02-process-understanding-and-diagramming.md +106 -0
- package/presets/clearai/template/skills/scientific-brainstorming/SKILL.md +64 -0
- package/presets/clearai/template/skills/scientific-brainstorming/references/brainstorming_methods.md +326 -0
- package/presets/clearai/template/skills/scientific-critical-thinking/SKILL.md +72 -0
- package/presets/clearai/template/skills/scientific-critical-thinking/references/common_biases.md +364 -0
- package/presets/clearai/template/skills/scientific-critical-thinking/references/evidence_hierarchy.md +485 -0
- package/presets/clearai/template/skills/scientific-critical-thinking/references/experimental_design.md +496 -0
- package/presets/clearai/template/skills/scientific-critical-thinking/references/logical_fallacies.md +478 -0
- package/presets/clearai/template/skills/scientific-critical-thinking/references/scientific_method.md +169 -0
- package/presets/clearai/template/skills/scientific-critical-thinking/references/statistical_pitfalls.md +506 -0
- package/presets/clearai/template/skills/skill-creator/SKILL.md +109 -0
- package/presets/clearai/template/skills/skill-creator/references/authoring-guide.md +89 -0
- package/presets/clearai/template/skills/statistical-analysis/SKILL.md +79 -0
- package/presets/clearai/template/skills/statistical-analysis/references/assumptions_and_diagnostics.md +369 -0
- package/presets/clearai/template/skills/statistical-analysis/references/bayesian_statistics.md +653 -0
- package/presets/clearai/template/skills/statistical-analysis/references/effect_sizes_and_power.md +578 -0
- package/presets/clearai/template/skills/statistical-analysis/references/reporting_standards.md +469 -0
- package/presets/clearai/template/skills/statistical-analysis/references/test_selection_guide.md +129 -0
- package/presets/clearai/template/skills/statistical-analysis/scripts/assumption_checks.py +538 -0
- package/presets/clearai/template/skills/web-artifact/SKILL.md +165 -0
- package/presets/clearai/template/skills/web-artifact/assets/renderer/renderer.css +229 -0
- package/presets/clearai/template/skills/web-artifact/assets/renderer/renderer.js +373 -0
- package/presets/clearai/template/skills/web-artifact/assets/vendor/elkjs/LICENSE +263 -0
- package/presets/clearai/template/skills/web-artifact/assets/vendor/elkjs/UPSTREAM.md +26 -0
- package/presets/clearai/template/skills/web-artifact/assets/vendor/elkjs/elk.bundled.js +6605 -0
- package/presets/clearai/template/skills/web-artifact/references/when-drawing-a-topology.md +150 -0
- package/presets/clearai/template/skills/web-artifact/references/when-the-page-must-work-offline.md +62 -0
- package/presets/clearai/template/skills/web-artifact/scripts/check_artifact.py +167 -0
- package/presets/clearai/template/skills/web-artifact/scripts/render_topology.js +272 -0
- package/presets/clearai/template/skills/what-if-oracle/LICENSE.txt +5 -0
- package/presets/clearai/template/skills/what-if-oracle/SKILL.md +72 -0
- package/presets/clearai/template/skills/what-if-oracle/references/scenario-templates.md +154 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,3071 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* clearai-dsh —— 浏览器半(ClearAI 的循环面板)。
|
|
3
|
+
*
|
|
4
|
+
* 这个文件承载 ClearAI 的**左栏循环面板**:目标 → 假设 → 验证与工作 → 资料 → 事实。
|
|
5
|
+
* 它刻意是**只读**的:面板里没有一个写入口,因为按 ClearAI 的设计,状态的唯一写入者是
|
|
6
|
+
* 系统(内核工具 + 准入 + 独立评估者)。人在这里读到的是**算出来的事实**,不是模型的说法。
|
|
7
|
+
*
|
|
8
|
+
* 五个域各自读什么:
|
|
9
|
+
* 目标 ← 目标文档 + 派生阶段 + 派生完成度(`plan_tree.goal_progress`)
|
|
10
|
+
* 假设 ← 目标文档 hypotheses[],状态由证据算出来(不打分)
|
|
11
|
+
* 验证与工作 ← 一张 Plan 的步骤 + 每步的 tests{hypothesis, level} 与交付状态
|
|
12
|
+
* 资料 ← 观测(只追加,带来源与 digest)
|
|
13
|
+
* 事实 ← 升格事实(evidence 够了之后由系统写进 clear/knowledge/facts/)
|
|
14
|
+
* 底部结算单四列 = 意图 / 事实 / 评估者 / 差额;
|
|
15
|
+
* 前端只渲染不重算 —— 内核给什么就显示什么。
|
|
16
|
+
*
|
|
17
|
+
* 数据通道:宿主半注册的**会话投影单元** `clearai`(key 就是 `clearai`,见 `lib/index.js`)。
|
|
18
|
+
* `conversation.view` 与 `conversation.composer.dock` 都把 `useProjection` 作为标准 prop 交下来,
|
|
19
|
+
* 面板读 `useProjection('clearai')` 拿到宿主算好的整份视图——随会话控制流推送、自带变更通知,
|
|
20
|
+
* 面板自己不取数、不重算。投影为空(`undefined`)时渲染平静的空态,绝不抛错、不显示堆栈。
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
window.__ModuleLoader__.load({
|
|
24
|
+
id: 'clearai-dsh',
|
|
25
|
+
factory: (require) => {
|
|
26
|
+
var module = { exports: {} }
|
|
27
|
+
var exports = module.exports
|
|
28
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
29
|
+
|
|
30
|
+
const React = require('react')
|
|
31
|
+
/**
|
|
32
|
+
* **原生图标**(§23):dsh 右栏页签的 guide 吃一个 `icon` 组件,原生那几张页签用的是
|
|
33
|
+
* `@deepseek-ai/dsh-client-ui-primitives` 里那套(文件页签 = FileTypeIcon ✓)。
|
|
34
|
+
* 我们照同一套用,于是"面包屑"与"世界树"在视觉上属于同一个家族。
|
|
35
|
+
*
|
|
36
|
+
* 拿不到那个模块时**不报错**:`icon` 留空,原生会用它自己的默认字形——
|
|
37
|
+
* 图标是装饰,不该让整块面板挂掉(与我们"降级要如实、不要崩"同一条纪律)。
|
|
38
|
+
*/
|
|
39
|
+
const NATIVE_ICONS = (() => {
|
|
40
|
+
try {
|
|
41
|
+
return require('@deepseek-ai/dsh-client-ui-primitives')
|
|
42
|
+
} catch {
|
|
43
|
+
return {}
|
|
44
|
+
}
|
|
45
|
+
})()
|
|
46
|
+
const h = React.createElement
|
|
47
|
+
/**
|
|
48
|
+
* **我们自己的标记**(与 `brand/logo.svg` 同一几何:开口的 c + 一颗事实点)。
|
|
49
|
+
*
|
|
50
|
+
* 为什么可以自画:右栏页签的 `guide.icon` 收的就是一个**组件**(原生那几张页签传的也是组件)。
|
|
51
|
+
* 之前我们借的是原生 `IconBranchOutline16` / `IconSkillOutline16` —— 那时讲的是"与原生同族";
|
|
52
|
+
* 现在这两个页签是我们自己的面,带我们自己的记号更诚实(它是 ClearAI 的面,不是宿主的面)。
|
|
53
|
+
*
|
|
54
|
+
* 几何是**主源里那一份**,不为小尺寸重画:16px 是品牌规则的唯一裁判(开口朝右上 -35°、
|
|
55
|
+
* 点在开口里、主笔 currentColor、点固定 emerald)。取不到标记组件时页面照常(它只是装饰)。
|
|
56
|
+
*/
|
|
57
|
+
const ClearAIMark = ({ size = 14, className }) =>
|
|
58
|
+
h(
|
|
59
|
+
'svg',
|
|
60
|
+
{ viewBox: '0 0 1024 1024', width: size, height: size, className, fill: 'none', 'aria-hidden': 'true', focusable: 'false' },
|
|
61
|
+
h('circle', { cx: 512, cy: 512, r: 326, stroke: 'currentColor', strokeWidth: 118, pathLength: 360, strokeDasharray: '270 90', strokeLinecap: 'round', transform: 'rotate(10 512 512)' }),
|
|
62
|
+
h('circle', { cx: 792, cy: 308, r: 104, fill: '#10B981' }),
|
|
63
|
+
)
|
|
64
|
+
/**
|
|
65
|
+
* 惰性表:语言是**运行时**的事,而这些表在模块加载时就被求值了——直接写 `t(...)`
|
|
66
|
+
* 会把语言冻在加载那一刻(实测:面板标题跟着语言换了,表里的词还停在中文)。
|
|
67
|
+
* 这个代理每次取键都重新求值,读起来仍与普通对象一样。
|
|
68
|
+
*/
|
|
69
|
+
const lazyTable = (make) =>
|
|
70
|
+
new Proxy(
|
|
71
|
+
{},
|
|
72
|
+
{
|
|
73
|
+
get: (_target, key) => make()[key],
|
|
74
|
+
has: (_target, key) => key in make(),
|
|
75
|
+
ownKeys: () => Reflect.ownKeys(make()),
|
|
76
|
+
getOwnPropertyDescriptor: (_target, key) => ({ configurable: true, enumerable: true, value: make()[key] }),
|
|
77
|
+
},
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
/** 语言命名空间。表按**源文**索引:键就是中文原文,所以漏翻译一条只会退回中文,不会把 key 显示给人。 */
|
|
81
|
+
const LOCALE_NS = 'clearai'
|
|
82
|
+
const LOCALE_ZH = {" · 交付 ": " · 交付 ", " · 人 ": " · 人 ", " · 在工作区外,面板不读正文": " · 在工作区外,面板不读正文", " · 尺子 ": " · 尺子 ", " · 执行没跑成": " · 执行没跑成", " · 支持到 ": " · 支持到 ", " · 最近 ": " · 最近 ", " · 证据 ": " · 证据 ", " · 车道 ": " · 车道 ", " · 随步骤作废而终止": " · 随步骤作废而终止", " 份)。": " 份)。", " 份产物,但没有任何计划声明过它们。点一条可以直接看(它们不计入交付)。": " 份产物,但没有任何计划声明过它们。点一条可以直接看(它们不计入交付)。", " 份没列出来(这一栏只列最近改动的 ": " 份没列出来(这一栏只列最近改动的 ", " 发起": " 发起", " 处声明": " 处声明", " 字节": " 字节", " 字节 · ": " 字节 · ", " 字节 · 未被任何计划声明": " 字节 · 未被任何计划声明", " 字节 · 资源 ": " 字节 · 资源 ", " 推翻:": " 推翻:", " 旁观 ": " 旁观 ", " 条": " 条", " 条)": " 条)", " 次侦察": " 次侦察", " 次评估者": " 次评估者", " 步": " 步", " 步)": " 步)", " 等 ": " 等 ", " 评估卡": " 评估卡", " 轮": " 轮", " 里打开": " 里打开", "(假设达到这一级且无推翻才升格为事实)": "(假设达到这一级且无推翻才升格为事实)", "(每次修订留痕,旧值不删)": "(每次修订留痕,旧值不删)", "(点一下开右栏「世界树」)": "(点一下开右栏「世界树」)", "(点一下开右栏「世界树」看拓扑)": "(点一下开右栏「世界树」看拓扑)", "(留档不删)": "(留档不删)", "(缺)": "(缺)", "(要独立评估)才达门槛": "(要独立评估)才达门槛", "),所以这个分叉没有归宿了:**随步骤作废而终止**——它既不是被人裁掉(没人做过这个决定),也不是被算术排掉(没有尺子排过它)。要收掉工作副本(保留 ref),对它调 AbandonFork(会弹人工确认)。": "),所以这个分叉没有归宿了:**随步骤作废而终止**——它既不是被人裁掉(没人做过这个决定),也不是被算术排掉(没有尺子排过它)。要收掉工作副本(保留 ref),对它调 AbandonFork(会弹人工确认)。", "),等目标验收时升格为事实": "),等目标验收时升格为事实", ")——它不是落选:落选意味着它跑完了、被尺子排到了后面;这一条是没跑成,所以也没有读数可以参与算术。": ")——它不是落选:落选意味着它跑完了、被尺子排到了后面;这一条是没跑成,所以也没有读数可以参与算术。", "**执行没跑成**(": "**执行没跑成**(", "**执行者未归**:这条世界线的执行者派出去之后没有回灌,而分叉已经收口——它再回来也没有归宿了,所以这里既不判它跑成也没跑成,也不再等它。它的产物(如果写过)还在它自己的工作副本里;要判断那条线到底做出了什么,直接看工作副本比看这条状态可靠。": "**执行者未归**:这条世界线的执行者派出去之后没有回灌,而分叉已经收口——它再回来也没有归宿了,所以这里既不判它跑成也没跑成,也不再等它。它的产物(如果写过)还在它自己的工作副本里;要判断那条线到底做出了什么,直接看工作副本比看这条状态可靠。", ":还差 ": ":还差 ", ";放弃缘由:": ";放弃缘由:", "clearai 面板:右栏页签类型注册失败 ": "clearai 面板:右栏页签类型注册失败 ", "clearai-loop: 席位跟着会话预设进出": "clearai-loop: 席位跟着会话预设进出", "世界树": "世界树", "世界线:": "世界线:", "为什么放弃?(留痕可考)": "为什么放弃?(留痕可考)", "事实": "事实", "产物": "产物", "人审查后决定撤回": "人审查后决定撤回", "人已撤回(记录保留)": "人已撤回(记录保留)", "人的裁决:": "人的裁决:", "仅人可引用": "仅人可引用", "会话日志里的原生审批对(不可伪造)": "会话日志里的原生审批对(不可伪造)", "依据": "依据", "侦 ": "侦 ", "侦察 · ": "侦察 · ", "候选": "候选", "做什么": "做什么", "做法": "做法", "停摆等人": "停摆等人", "其他(": "其他(", "出现推翻证据": "出现推翻证据", "出现推翻证据(终态,记录保留)": "出现推翻证据(终态,记录保留)", "分支": "分支", "切回历史的世界树(计划都还在,文档也归档在 clear/goals/plans/)": "切回历史的世界树(计划都还在,文档也归档在 clear/goals/plans/)", "判据": "判据", "判据:": "判据:", "判据待写": "判据待写", "升格门槛": "升格门槛", "原生预览打不开这条路(它在工作区外?)": "原生预览打不开这条路(它在工作区外?)", "原生预览打开它": "原生预览打开它", "原生预览打开它(计划声明的产物)": "原生预览打开它(计划声明的产物)", "合并目录、本会话用法、可引用可采纳": "合并目录、本会话用法、可引用可采纳", "命题": "命题", "命题 · ": "命题 · ", "在": "在", "在 ": "在 ", "在世界树里看这一步": "在世界树里看这一步", "在右栏预览": "在右栏预览", "声明的是目录;准入要求具体文件——目录不算物证": "声明的是目录;准入要求具体文件——目录不算物证", "多问我": "多问我", "完成度 ": "完成度 ", "完成度 —": "完成度 —", "审批记录": "审批记录", "工作区模板 · clear/skills": "工作区模板 · clear/skills", "工作区现状(本会话还没有第一轮对话,这份还没进投影)": "工作区现状(本会话还没有第一轮对话,这份还没进投影)", "已交付": "已交付", "已作废": "已作废", "已回灌": "已回灌", "已推翻": "已推翻", "已提出": "已提出", "已撤回": "已撤回", "已收尾 · 存档可看": "已收尾 · 存档可看", "已改版": "已改版", "已放弃": "已放弃", "已放弃探索:": "已放弃探索:", "已放弃的分叉": "已放弃的分叉", "已替代": "已替代", "已确认": "已确认", "已确认事实 · ": "已确认事实 · ", "已被下一版命题替代(版本留着,不参与当前推理)": "已被下一版命题替代(版本留着,不参与当前推理)", "已裁决": "已裁决", "已评估": "已评估", "已达成": "已达成", "已达门槛(": "已达门槛(", "已达门槛,已升格为事实": "已达门槛,已升格为事实", "已采纳": "已采纳", "待开计划": "待开计划", "待推进": "待推进", "待裁决": "待裁决", "我的 · ~/.dsh": "我的 · ~/.dsh", "打开 ": "打开 ", "打开「事实」那一格并展开这条命题": "打开「事实」那一格并展开这条命题", "打开世界树并选中产出这条事实的验证步": "打开世界树并选中产出这条事实的验证步", "打开世界树并选中产生这条证据的验证步": "打开世界树并选中产生这条证据的验证步", "打开失败:HTTP ": "打开失败:HTTP ", "打开计划文档(原生预览)": "打开计划文档(原生预览)", "技能 · 记忆": "技能 · 记忆", "把这道裁决摆到原生提问卡上(带你读到的判据与各分支读数)": "把这道裁决摆到原生提问卡上(带你读到的判据与各分支读数)", "探索中": "探索中", "推翻": "推翻", "推进中": "推进中", "提交中…": "提交中…", "提交失败:": "提交失败:", "支持": "支持", "支持到 ": "支持到 ", "收敛": "收敛", "收束:": "收束:", "收起": "收起", "收起详情": "收起详情", "放弃了这次探索": "放弃了这次探索", "放弃探索": "放弃探索", "放弃缘由(必填)": "放弃缘由(必填)", "放弃要留痕:先写缘由": "放弃要留痕:先写缘由", "放弃要留痕:点一下写缘由,写了才能提交": "放弃要留痕:点一下写缘由,写了才能提交", "放弃这条分叉": "放弃这条分叉", "放弃这次探索(留档不删,ref 保留)": "放弃这次探索(留档不删,ref 保留)", "放行": "放行", "旁观写这条裁决的评估者子会话(论证过程)": "旁观写这条裁决的评估者子会话(论证过程)", "旁观评估者": "旁观评估者", "旁观这条世界线的评估者会话(只读)": "旁观这条世界线的评估者会话(只读)", "无法判定": "无法判定", "是目录(不算物证)": "是目录(不算物证)", "最后改动 ": "最后改动 ", "有了第一条证据": "有了第一条证据", "有人在等你": "有人在等你", "未分类": "未分类", "未声明": "未声明", "未收口(随步骤作废而终止)": "未收口(随步骤作废而终止)", "未知": "未知", "未走:": "未走:", "未采纳": "未采纳", "本会话 模型 ": "本会话 模型 ", "本体声明还没随投影下发,这里用的是规范闭环的镜像;会话跑过一拍后会自动对齐声明。": "本体声明还没随投影下发,这里用的是规范闭环的镜像;会话跑过一拍后会自动对齐声明。", "本项目 · .dsh / .agents": "本项目 · .dsh / .agents", "本项目写的 · clear/skills": "本项目写的 · clear/skills", "核心产物(": "核心产物(", "步 ": "步 ", "没正常结束": "没正常结束", "没送出去:": "没送出去:", "派出 ": "派出 ", "派生 · ": "派生 · ", "点一条 → 右栏预览": "点一条 → 右栏预览", "点一行看细节": "点一行看细节", "点开看这一步的细节": "点开看这一步的细节", "版本": "版本", "状态": "状态", "独立评估者": "独立评估者", "用原生提问卡决定": "用原生提问卡决定", "用原生预览打开章程(它每回合整份注入模型上下文)": "用原生预览打开章程(它每回合整份注入模型上下文)", "用提问卡决定": "用提问卡决定", "盘上已有(": "盘上已有(", "盘上没有": "盘上没有", "盘上没有这个文件": "盘上没有这个文件", "目录": "目录", "目录还没到(内核下一次 pre-step 会发)": "目录还没到(内核下一次 pre-step 会发)", "目录里已经没有(": "目录里已经没有(", "目标": "目标", "目标挂起": "目标挂起", "看评估者": "看评估者", "看这一步的证据": "看这一步的证据", "确认放弃": "确认放弃", "确认放弃(留档不删,ref 保留)": "确认放弃(留档不删,ref 保留)", "等人或等世界线": "等人或等世界线", "等你说一句话": "等你说一句话", "等独立裁决": "等独立裁决", "策略暂停": "策略暂停", "策略暂停(理由没记下,已在日志里告警)": "策略暂停(理由没记下,已在日志里告警)", "算术推荐这条": "算术推荐这条", "续跑:多问我——每个阶段收尾就停下,等你给下一阶段(文档里叫「人在场」)。点一下切成「自己拿主意」。": "续跑:多问我——每个阶段收尾就停下,等你给下一阶段(文档里叫「人在场」)。点一下切成「自己拿主意」。", "续跑:自己拿主意——立约即授权,按轮数自己往下跑,只在不可约的判断上开门(文档里叫「无人值守」)。点一下切成「多问我」。": "续跑:自己拿主意——立约即授权,按轮数自己往下跑,只在不可约的判断上开门(文档里叫「无人值守」)。点一下切成「多问我」。", "续跑停着:": "续跑停着:", "续跑已撤回": "续跑已撤回", "缘由必填": "缘由必填", "缺": "缺", "能用的在上面,正在验的在下面": "能用的在上面,正在验的在下面", "自判": "自判", "自定义": "自定义", "自己拿主意": "自己拿主意", "被 ": "被 ", "被下一版命题改写": "被下一版命题改写", "裁决": "裁决", "裁决:采纳 ": "裁决:采纳 ", "要你": "要你", "观测": "观测", "计划": "计划", "计划 ": "计划 ", "计划受阻,等人处置": "计划受阻,等人处置", "计划在建,**等你确认**": "计划在建,**等你确认**", "计划已交付 ": "计划已交付 ", "计划已收尾(": "计划已收尾(", "计划待确认": "计划待确认", "计划文档": "计划文档", "计划的拓扑与闸门:脊柱、叉开的车道、收在哪、要你拍哪一下": "计划的拓扑与闸门:脊柱、叉开的车道、收在哪、要你拍哪一下", "让内核跑 ConvergeFork 落实它(合并是内核的活)": "让内核跑 ConvergeFork 落实它(合并是内核的活)", "记忆(": "记忆(", "记忆索引": "记忆索引", "证据": "证据", "证据 ": "证据 ", "评 ": "评 ", "评估": "评估", "评估卡": "评估卡", "评估者": "评估者", "评估者 ": "评估者 ", "评估者会话 ": "评估者会话 ", "评估者在裁决": "评估者在裁决", "说一句话就行 —— ": "说一句话就行 —— ", "读不到交付物:": "读不到交付物:", "读取中…": "读取中…", "读数": "读数", "读数(尺子 ": "读数(尺子 ", "起过 ": "起过 ", "跑着": "跑着", "车道 · ": "车道 · ", "边界:": "边界:", "达门槛且无推翻的命题,会在**目标验收**时由系统升格为事实(写在 clear/knowledge/facts/,模型读的 INDEX.md 同步)。": "达门槛且无推翻的命题,会在**目标验收**时由系统升格为事实(写在 clear/knowledge/facts/,模型读的 INDEX.md 同步)。", "运行时注册": "运行时注册", "还有 ": "还有 ", "还没交付": "还没交付", "还没有命题。人与模型都可以提出:每条要有一句话主张与一句「什么结果会推翻它」;通过验证的会升格到上面的事实货架。": "还没有命题。人与模型都可以提出:每条要有一句话主张与一句「什么结果会推翻它」;通过验证的会升格到上面的事实货架。", "还没有目标。目标带一份「怎样算回答了」的判据——判据在结果出现之前写下,由系统强制。": "还没有目标。目标带一份「怎样算回答了」的判据——判据在结果出现之前写下,由系统强制。", "还没有目标。立约并验证之后,达门槛且无推翻的命题会在目标验收时升格为事实。": "还没有目标。立约并验证之后,达门槛且无推翻的命题会在目标验收时升格为事实。", "还没有计划,所以还没有产物。立目标、建计划,交付过的每一步都会在这里按阶段排开。": "还没有计划,所以还没有产物。立目标、建计划,交付过的每一步都会在这里按阶段排开。", "还没有计划。计划立起来之后,这里画的是它的拓扑:脊柱上的步、叉开的车道、收在哪。": "还没有计划。计划立起来之后,这里画的是它的拓扑:脊柱上的步、叉开的车道、收在哪。", "还没有记忆。": "还没有记忆。", "还没有证据:先登记判据,再验证": "还没有证据:先登记判据,再验证", "这一步已作废(缘由:": "这一步已作废(缘由:", "这一步没有声明产物。": "这一步没有声明产物。", "这个会话还没开始:面板的数据来自会话日志,发第一句话之后,这里会显示工作区的技能目录与记忆。": "这个会话还没开始:面板的数据来自会话日志,发第一句话之后,这里会显示工作区的技能目录与记忆。", "这个会话还没开始:面板的数据来自会话日志,发第一句话之后,这里会显示已确认的事实与正在流转的命题。": "这个会话还没开始:面板的数据来自会话日志,发第一句话之后,这里会显示已确认的事实与正在流转的命题。", "这个会话还没有 ClearAI 的状态。": "这个会话还没有 ClearAI 的状态。", "这个工作区盘上已经有 ": "这个工作区盘上已经有 ", "这个工作区里还没有技能或记忆:用一次 `SaveSkill` 或 `WriteMemory`,或把技能放进 `clear/skills/`。": "这个工作区里还没有技能或记忆:用一次 `SaveSkill` 或 `WriteMemory`,或把技能放进 `clear/skills/`。", "这是系统对自己说的话(我们自己按的暂停 / 人清掉的窗口),不是运行档": "这是系统对自己说的话(我们自己按的暂停 / 人清掉的窗口),不是运行档", "进度": "进度", "采纳": "采纳", "采纳 ": "采纳 ", "采纳:改写 frontmatter,模型从此加载得到它": "采纳:改写 frontmatter,模型从此加载得到它", "采纳了某条世界线": "采纳了某条世界线", "采纳此世界线": "采纳此世界线", "问题:": "问题:", "阶段": "阶段", "阶段 ": "阶段 ", "阶段交界": "阶段交界", "需要你 ": "需要你 ", "面板动作失败:": "面板动作失败:", "项目章程": "项目章程", "预设自带": "预设自带", "验证": "验证", "验证中": "验证中", "这次采纳没有合并:": "这次采纳没有合并:", "(决定已登记,产物由一次普通交付落位)": "(决定已登记,产物由一次普通交付落位)", "分支或工作副本已不在": "分支或工作副本已不在"}
|
|
83
|
+
const LOCALE_EN = {" · 交付 ": " · delivered ", " · 人 ": " · human ", " · 在工作区外,面板不读正文": " · outside the workspace; the panel will not read it", " · 尺子 ": " · metric ", " · 执行没跑成": " · execution did not complete", " · 支持到 ": " · supported to ", " · 最近 ": " · latest ", " · 证据 ": " · evidence ", " · 车道 ": " · lanes ", " · 随步骤作废而终止": " · ended when its step was voided", " 份)。": " files).", " 份产物,但没有任何计划声明过它们。点一条可以直接看(它们不计入交付)。": " artifacts on disk, but no plan declared them. Click one to open it (they do not count toward delivery).", " 份没列出来(这一栏只列最近改动的 ": " more not listed (this column lists only the ", " 发起": " started", " 处声明": " declared", " 字节": " bytes", " 字节 · ": " bytes · ", " 字节 · 未被任何计划声明": " bytes · declared by no plan", " 字节 · 资源 ": " bytes · resources ", " 推翻:": "refuted by:", " 旁观 ": " observe ", " 条": " entries", " 条)": " entries)", " 次侦察": " scouts", " 次评估者": " evaluators", " 步": " steps", " 步)": " steps)", " 等 ": " and ", " 评估卡": " evaluation card", " 轮": " rounds", " 里打开": "", "(假设达到这一级且无推翻才升格为事实)": " (a hypothesis is promoted to fact only at this level and with no refutation)", "(每次修订留痕,旧值不删)": " (every revision is kept; old values are not deleted)", "(点一下开右栏「世界树」)": " (click to open Worldlines)", "(点一下开右栏「世界树」看拓扑)": " (click to see the topology in Worldlines)", "(留档不删)": " (kept on record, not deleted)", "(缺)": " (missing)", "(要独立评估)才达门槛": " (independent evaluation required) to reach the threshold", "),所以这个分叉没有归宿了:**随步骤作废而终止**——它既不是被人裁掉(没人做过这个决定),也不是被算术排掉(没有尺子排过它)。要收掉工作副本(保留 ref),对它调 AbandonFork(会弹人工确认)。": "), so this fork has no destination left: **it ended when its step was voided** — it was neither decided by a person (nobody made that call) nor ranked out by arithmetic (no metric ever ranked it). To drop the working copy (keeping the ref), call AbandonFork on it (it asks for human confirmation).", "),等目标验收时升格为事实": "), and is promoted to fact when the goal is accepted", ")——它不是落选:落选意味着它跑完了、被尺子排到了后面;这一条是没跑成,所以也没有读数可以参与算术。": ") — this is not a loss: losing means it ran and was ranked behind; this one did not run, so it has no reading to enter into the arithmetic.", "**执行没跑成**(": "**Execution did not complete** (", "**执行者未归**:这条世界线的执行者派出去之后没有回灌,而分叉已经收口——它再回来也没有归宿了,所以这里既不判它跑成也没跑成,也不再等它。它的产物(如果写过)还在它自己的工作副本里;要判断那条线到底做出了什么,直接看工作副本比看这条状态可靠。": "**Executor never returned**: this worldline's executor was dispatched but never reported back, and the fork has already converged — if it returned now there would be nowhere to land, so it is neither judged done nor judged failed, and it is no longer awaited. Whatever it wrote still sits in its own working copy; to judge what that line actually produced, read the working copy rather than this status.", ":还差 ": ": short by ", ";放弃缘由:": "; reason for abandoning: ", "clearai 面板:右栏页签类型注册失败 ": "clearai panel: failed to register the right-sidebar tab type ", "clearai-loop: 席位跟着会话预设进出": "clearai-loop: seats come and go with the session's preset", "世界树": "Worldlines", "世界线:": "Worldline: ", "为什么放弃?(留痕可考)": "Why abandon it? (the record must be answerable)", "事实": "Facts", "产物": "Deliverables", "人审查后决定撤回": "withdrawn after human review", "人已撤回(记录保留)": "withdrawn by a person (record kept)", "人的裁决:": "Human decision: ", "仅人可引用": "user-invocable only", "会话日志里的原生审批对(不可伪造)": "the native approval pair in the session log (cannot be forged)", "依据": "Basis", "侦 ": "S", "侦察 · ": "Scout · ", "候选": "candidate", "做什么": "What it does", "做法": "Approach", "停摆等人": "stalled, waiting on a person", "其他(": "Other (", "出现推翻证据": "refuting evidence appeared", "出现推翻证据(终态,记录保留)": "refuting evidence appeared (terminal; record kept)", "分支": "Branch", "切回历史的世界树(计划都还在,文档也归档在 clear/goals/plans/)": "Switch back to an earlier worldline set (the plans are all still here, and their documents are archived under clear/goals/plans/)", "判据": "Criterion", "判据:": "Criterion: ", "判据待写": "criterion not written", "升格门槛": "Promotion threshold", "原生预览打不开这条路(它在工作区外?)": "The native preview cannot open this path (is it outside the workspace?)", "原生预览打开它": "Open it in the native preview", "原生预览打开它(计划声明的产物)": "Open it in the native preview (an artifact declared by the plan)", "合并目录、本会话用法、可引用可采纳": "Merged catalogue, usage in this session, invocable and adoptable", "命题": "Propositions", "命题 · ": "Propositions · ", "在": "in", "在 ": "in ", "在世界树里看这一步": "See this step in Worldlines", "在右栏预览": "Preview in the right sidebar", "声明的是目录;准入要求具体文件——目录不算物证": "This declares a directory; admission requires a concrete file — a directory is not physical evidence", "多问我": "Ask me more", "完成度 ": "Progress ", "完成度 —": "Progress —", "审批记录": "approval record", "工作区模板 · clear/skills": "Workspace template · clear/skills", "工作区现状(本会话还没有第一轮对话,这份还没进投影)": "Workspace as it stands (this session has no first turn yet, so this has not reached the projection)", "已交付": "delivered", "已作废": "voided", "已回灌": "reported back", "已推翻": "refuted", "已提出": "proposed", "已撤回": "withdrawn", "已收尾 · 存档可看": "closed · archived and readable", "已改版": "superseded", "已放弃": "abandoned", "已放弃探索:": "Exploration abandoned: ", "已放弃的分叉": "abandoned fork", "已替代": "superseded", "已确认": "confirmed", "已确认事实 · ": "Confirmed facts · ", "已被下一版命题替代(版本留着,不参与当前推理)": "superseded by a later version of the proposition (the version is kept but takes no part in current reasoning)", "已裁决": "decided", "已评估": "evaluated", "已达成": "achieved", "已达门槛(": "threshold reached (", "已达门槛,已升格为事实": "threshold reached; promoted to fact", "已采纳": "adopted", "待开计划": "no plan yet", "待推进": "to advance", "待裁决": "awaiting decision", "我的 · ~/.dsh": "Mine · ~/.dsh", "打开 ": "Open ", "打开「事实」那一格并展开这条命题": "Open the Facts pane and expand this proposition", "打开世界树并选中产出这条事实的验证步": "Open Worldlines and select the verification step that produced this fact", "打开世界树并选中产生这条证据的验证步": "Open Worldlines and select the verification step that produced this evidence", "打开失败:HTTP ": "Open failed: HTTP ", "打开计划文档(原生预览)": "Open the plan document (native preview)", "技能 · 记忆": "Skills · Memory", "把这道裁决摆到原生提问卡上(带你读到的判据与各分支读数)": "Put this decision on the native question card (carrying the criterion you read and each branch's reading)", "探索中": "exploring", "推翻": "refute", "推进中": "in progress", "提交中…": "Submitting…", "提交失败:": "Submit failed: ", "支持": "support", "支持到 ": "supported to ", "收敛": "converge", "收束:": "Closed with: ", "收起": "Collapse", "收起详情": "Collapse details", "放弃了这次探索": "abandoned this exploration", "放弃探索": "Abandon exploration", "放弃缘由(必填)": "Reason for abandoning (required)", "放弃要留痕:先写缘由": "Abandoning leaves a record: write the reason first", "放弃要留痕:点一下写缘由,写了才能提交": "Abandoning leaves a record: click to write the reason; it must be written before submitting", "放弃这条分叉": "Abandon this fork", "放弃这次探索(留档不删,ref 保留)": "Abandon this exploration (kept on record, ref preserved)", "放行": "release", "旁观写这条裁决的评估者子会话(论证过程)": "Observe the evaluator sub-session that wrote this verdict (the reasoning process)", "旁观评估者": "Observe evaluator", "旁观这条世界线的评估者会话(只读)": "Observe this worldline's evaluator session (read-only)", "无法判定": "inconclusive", "是目录(不算物证)": "is a directory (not physical evidence)", "最后改动 ": "Last changed ", "有了第一条证据": "the first piece of evidence arrived", "有人在等你": "someone is waiting on you", "未分类": "Uncategorised", "未声明": "not declared", "未收口(随步骤作废而终止)": "not closed (ended when its step was voided)", "未知": "unknown", "未走:": "Not taken: ", "未采纳": "not adopted", "本会话 模型 ": "This session — model ", "本体声明还没随投影下发,这里用的是规范闭环的镜像;会话跑过一拍后会自动对齐声明。": "The ontology declaration has not reached the projection yet; this shows a mirror of the canonical loop and will align with the declaration after one more step.", "本项目 · .dsh / .agents": "This project · .dsh / .agents", "本项目写的 · clear/skills": "Written by this project · clear/skills", "核心产物(": "Core deliverables (", "步 ": "step ", "没正常结束": "did not finish cleanly", "没送出去:": "Not sent: ", "派出 ": "dispatched ", "派生 · ": "derived · ", "点一条 → 右栏预览": "Click one → preview on the right", "点一行看细节": "Click a row for detail", "点开看这一步的细节": "Click to see this step's detail", "版本": "Version", "状态": "Status", "独立评估者": "independent evaluator", "用原生提问卡决定": "Decide with the native question card", "用原生预览打开章程(它每回合整份注入模型上下文)": "Open the charter in the native preview (it is injected whole into the model's context every turn)", "用提问卡决定": "Decide with a question card", "盘上已有(": "Already on disk (", "盘上没有": "not on disk", "盘上没有这个文件": "this file is not on disk", "目录": "directory", "目录还没到(内核下一次 pre-step 会发)": "The catalogue has not arrived yet (the kernel sends it on the next pre-step)", "目录里已经没有(": "no longer in the catalogue (", "目标": "Goal", "目标挂起": "goal suspended", "看评估者": "view evaluator", "看这一步的证据": "See the evidence for this step", "确认放弃": "Confirm abandon", "确认放弃(留档不删,ref 保留)": "Confirm abandon (kept on record, ref preserved)", "等人或等世界线": "waiting on a person or a worldline", "等你说一句话": "waiting for a word from you", "等独立裁决": "waiting for an independent verdict", "策略暂停": "paused by policy", "策略暂停(理由没记下,已在日志里告警)": "paused by policy (no reason recorded; a warning was logged)", "算术推荐这条": "arithmetic recommends this one", "续跑:多问我——每个阶段收尾就停下,等你给下一阶段(文档里叫「人在场」)。点一下切成「自己拿主意」。": "Continuation: ask me more — it stops at the end of each stage and waits for you to give the next one (called \"attended\" in the docs). Click to switch to \"decide for yourself\".", "续跑:自己拿主意——立约即授权,按轮数自己往下跑,只在不可约的判断上开门(文档里叫「无人值守」)。点一下切成「多问我」。": "Continuation: decide for yourself — committing a plan is the authorisation, and it keeps going for a set number of rounds, opening a gate only for decisions it cannot reduce (called \"unattended\" in the docs). Click to switch to \"ask me more\".", "续跑停着:": "Continuation is stopped: ", "续跑已撤回": "Continuation was withdrawn", "缘由必填": "A reason is required", "缺": "missing", "能用的在上面,正在验的在下面": "What you can use is on top; what is still being verified is below", "自判": "self-judged", "自定义": "custom", "自己拿主意": "Decide for yourself", "被 ": "by ", "被下一版命题改写": "rewritten by a later version of the proposition", "裁决": "Verdict", "裁决:采纳 ": "Verdict: adopt ", "要你": "needs you", "观测": "Observation", "计划": "Plan", "计划 ": "Plan ", "计划受阻,等人处置": "plan blocked, waiting for a person", "计划在建,**等你确认**": "plan is being built, **waiting for your confirmation**", "计划已交付 ": "Plan delivered ", "计划已收尾(": "Plan closed (", "计划待确认": "plan awaiting confirmation", "计划文档": "Plan document", "计划的拓扑与闸门:脊柱、叉开的车道、收在哪、要你拍哪一下": "The plan's topology and gates: the spine, the lanes that branch off, where it converges, and where you have to decide", "让内核跑 ConvergeFork 落实它(合并是内核的活)": "Let the kernel run ConvergeFork to apply it (merging is the kernel's job)", "记忆(": "Memory (", "记忆索引": "Memory index", "证据": "Evidence", "证据 ": "Evidence ", "评 ": "E", "评估": "Evaluation", "评估卡": "evaluation card", "评估者": "Evaluator", "评估者 ": "Evaluator ", "评估者会话 ": "Evaluator session ", "评估者在裁决": "an evaluator is deciding", "说一句话就行 —— ": "Just say a word — ", "读不到交付物:": "Cannot read deliverables: ", "读取中…": "Reading…", "读数": "Reading", "读数(尺子 ": "Reading (metric ", "起过 ": "ran ", "跑着": "running", "车道 · ": "Lane · ", "边界:": "Scope: ", "达门槛且无推翻的命题,会在**目标验收**时由系统升格为事实(写在 clear/knowledge/facts/,模型读的 INDEX.md 同步)。": "A proposition that reaches the threshold with no refutation is promoted to fact by the system when the **goal is accepted** (written under clear/knowledge/facts/, with the INDEX.md the model reads kept in sync).", "运行时注册": "registered at runtime", "还有 ": "and ", "还没交付": "not delivered yet", "还没有命题。人与模型都可以提出:每条要有一句话主张与一句「什么结果会推翻它」;通过验证的会升格到上面的事实货架。": "No propositions yet. Both the person and the model can raise one: each needs a one-line claim and a line saying what result would refute it; those that pass verification are promoted to the fact shelf above.", "还没有目标。目标带一份「怎样算回答了」的判据——判据在结果出现之前写下,由系统强制。": "No goal yet. A goal carries a criterion for what would count as an answer — written before any result appears, and enforced by the system.", "还没有目标。立约并验证之后,达门槛且无推翻的命题会在目标验收时升格为事实。": "No goal yet. After you commit a plan and verify it, propositions that reach the threshold with no refutation are promoted to fact when the goal is accepted.", "还没有计划,所以还没有产物。立目标、建计划,交付过的每一步都会在这里按阶段排开。": "No plan yet, so no deliverables. Set a goal and build a plan; every step you deliver will line up here by stage.", "还没有计划。计划立起来之后,这里画的是它的拓扑:脊柱上的步、叉开的车道、收在哪。": "No plan yet. Once one is committed, this draws its topology: the steps on the spine, the lanes that branch off, and where it converges.", "还没有记忆。": "No memory yet.", "还没有证据:先登记判据,再验证": "No evidence yet: register the criterion first, then verify", "这一步已作废(缘由:": "This step was voided (reason: ", "这一步没有声明产物。": "This step declares no artifacts.", "这个会话还没开始:面板的数据来自会话日志,发第一句话之后,这里会显示工作区的技能目录与记忆。": "This session has not started: the panels read the session log, so after your first message this will show the workspace's skill catalogue and memory.", "这个会话还没开始:面板的数据来自会话日志,发第一句话之后,这里会显示已确认的事实与正在流转的命题。": "This session has not started: the panels read the session log, so after your first message this will show confirmed facts and the propositions in flight.", "这个会话还没有 ClearAI 的状态。": "This session has no ClearAI state yet.", "这个工作区盘上已经有 ": "This workspace already has ", "这个工作区里还没有技能或记忆:用一次 `SaveSkill` 或 `WriteMemory`,或把技能放进 `clear/skills/`。": "This workspace has no skills or memory yet: use `SaveSkill` or `WriteMemory` once, or put a skill under `clear/skills/`.", "这是系统对自己说的话(我们自己按的暂停 / 人清掉的窗口),不是运行档": "This is the system talking to itself (a pause we pressed, or a window a person cleared) — not a run mode", "进度": "Progress", "采纳": "Adopt", "采纳 ": "Adopt ", "采纳:改写 frontmatter,模型从此加载得到它": "Adopt: rewrites the frontmatter so the model can load it from then on", "采纳了某条世界线": "adopted one worldline", "采纳此世界线": "Adopt this worldline", "问题:": "Question: ", "阶段": "Stage", "阶段 ": "Stage ", "阶段交界": "at a stage boundary", "需要你 ": "needs you ", "面板动作失败:": "Panel action failed: ", "项目章程": "Project charter", "预设自带": "shipped with the preset", "验证": "Verification", "验证中": "verifying", "这次采纳没有合并:": "This adoption was not merged: ", "(决定已登记,产物由一次普通交付落位)": " (the decision is on record; a normal delivery places the artifacts)", "分支或工作副本已不在": "its branch or working copy is gone"}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 翻译函数:**由原生 locale 座位绑定**(`ctx.locale.bind`),不是我们自建的一套 i18n。
|
|
87
|
+
* 座位不在(老宿主 / 测试桩)时退化成恒等函数 —— 面板照常显示中文,不会炸。
|
|
88
|
+
*/
|
|
89
|
+
let localeBound = (key) => key
|
|
90
|
+
const t = (key) => localeBound(key)
|
|
91
|
+
/** 语言变了要让组件重画:订阅者集合 + 一个极小的外部 store。 */
|
|
92
|
+
const localeListeners = new Set()
|
|
93
|
+
/** 语言变了需要**重新注册**的那些座位(标签在注册时求值)。 */
|
|
94
|
+
const localeResyncs = new Set()
|
|
95
|
+
function useLocaleTick() {
|
|
96
|
+
const [, bump] = React.useState(0)
|
|
97
|
+
React.useEffect(() => {
|
|
98
|
+
const listener = () => bump((value) => value + 1)
|
|
99
|
+
localeListeners.add(listener)
|
|
100
|
+
return () => {
|
|
101
|
+
localeListeners.delete(listener)
|
|
102
|
+
}
|
|
103
|
+
}, [])
|
|
104
|
+
}
|
|
105
|
+
/** 把注册出去的面板包一层:语言一变就重画(不重载页面)。 */
|
|
106
|
+
function withLocale(Component) {
|
|
107
|
+
return function Localized(props) {
|
|
108
|
+
useLocaleTick()
|
|
109
|
+
return h(Component, props)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 依赖的客户端服务,**必须全部声明在这里**。
|
|
115
|
+
*
|
|
116
|
+
* 这不是洁癖,是 2026-09-11 实测换来的:客户端模块在 `slots` 一出现就被激活
|
|
117
|
+
* (`slots` 是平台 seed,启动时就有),而 `sessions` / `sidebarRightTabs` 是**后来**
|
|
118
|
+
* 才由各自的插件 `provide` 的。只声明 `slots` 的话,我们的 `apply()` 会在它们之前跑,
|
|
119
|
+
* 读不到会话服务 → 早退 → **一个插座都不注册**,而且不报错:右栏只剩宿主自带的「文件」,
|
|
120
|
+
* 中栏没有「产物」。宿主那侧一切正常(模块图里有我们、bundle 正常送达),
|
|
121
|
+
* 所以这个 bug 只在浏览器里看得见——单测里的假 ctx 恰好把两个服务都给了,于是全绿。
|
|
122
|
+
*
|
|
123
|
+
* 声明之后 Cordis 会把插件**挂起**到这些服务就位再激活(原生插件同样是这么写的,
|
|
124
|
+
* 例如 `dsh-client-ui-sidebar-files` 声明 slots + sidebarRightTabs + remote + locale)。
|
|
125
|
+
*/
|
|
126
|
+
const inject = ['slots', 'sessions', 'sidebarRightTabs', 'sidebarRight']
|
|
127
|
+
|
|
128
|
+
const PHASE = lazyTable(() => ({
|
|
129
|
+
drafting: t('判据待写'),
|
|
130
|
+
planning: t('待开计划'),
|
|
131
|
+
executing: t('推进中'),
|
|
132
|
+
waiting: t('等人或等世界线'),
|
|
133
|
+
stage_boundary: t('阶段交界'),
|
|
134
|
+
auditing: t('评估者在裁决'),
|
|
135
|
+
stalled: t('停摆等人'),
|
|
136
|
+
suspended: t('目标挂起'),
|
|
137
|
+
achieved: t('已达成'),
|
|
138
|
+
abandoned: t('已放弃'),
|
|
139
|
+
superseded: t('已改版'),
|
|
140
|
+
}))
|
|
141
|
+
const HYPOTHESIS = lazyTable(() => ({
|
|
142
|
+
proposed: t('已提出'),
|
|
143
|
+
alive: t('验证中'),
|
|
144
|
+
confirmed: t('已确认'),
|
|
145
|
+
/** 规范词是「已替代」(`docs/verification-loop.md` §5 与原始 loopModel.ts 同词);「已改版」是旧写法。 */
|
|
146
|
+
refuted: t('已推翻'),
|
|
147
|
+
superseded: t('已替代'),
|
|
148
|
+
retracted: t('已撤回'),
|
|
149
|
+
}))
|
|
150
|
+
const STEP = lazyTable(() => ({ open: t('待推进'), advanced: t('已交付'), void: t('已作废') }))
|
|
151
|
+
const VERDICT = lazyTable(() => ({ support: t('支持'), refute: t('推翻'), inconclusive: t('无法判定') }))
|
|
152
|
+
const EVALUATOR = lazyTable(() => ({ self: t('自判'), independent: t('独立评估者') }))
|
|
153
|
+
const BRANCH_STATUS = lazyTable(() => ({ exploring: t('探索中'), evaluated: t('已评估'), adopted: t('已采纳'), pruned: t('未采纳') }))
|
|
154
|
+
/** 人在面板上做过的裁决(树详情里要如实回放:谁、以什么理由)。 */
|
|
155
|
+
const FORK_ACTION = lazyTable(() => ({ adopt_branch: t('采纳了某条世界线'), abandon_fork: t('放弃了这次探索') }))
|
|
156
|
+
/** 侦察的三种结局(与 fold 的派生同源):跑着 / 正常回灌 / 没正常结束。 */
|
|
157
|
+
const SCOUT = lazyTable(() => ({ running: t('跑着'), settled: t('已回灌'), failed: t('没正常结束') }))
|
|
158
|
+
const FORK_PHASE = lazyTable(() => ({ exploring: t('探索中'), deciding: t('待裁决'), settled: t('已裁决'), abandoned: t('已放弃') }))
|
|
159
|
+
const STRONG = { refuted: true, stalled: true, refute: true }
|
|
160
|
+
|
|
161
|
+
const dash = (value) => (value === null || value === undefined || value === '' ? '—' : String(value))
|
|
162
|
+
const gloss = (table, value) => (value === null || value === undefined ? '—' : `${table[value] ?? value}`)
|
|
163
|
+
const when = (at) => {
|
|
164
|
+
if (typeof at !== 'number' || at <= 0) return '—'
|
|
165
|
+
const d = new Date(at)
|
|
166
|
+
const pad = (n) => String(n).padStart(2, '0')
|
|
167
|
+
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* 面板的 CSS:按钮与标签照抄**原生那一套**(实测取自宿主自己的包,不是我们编的):
|
|
172
|
+
* · 28px 药丸(设置页的「Add model」):`.5px solid var(--dsw-alias-border-l3)` + `border-radius:14px`
|
|
173
|
+
* + `bg:0 0` + `label-primary` + `font-size:12px;line-height:18px;padding:0 10px`
|
|
174
|
+
* · 11px 小药丸(技能卡上的「inspect」):`.5px solid var(--dsw-alias-border-l4)`
|
|
175
|
+
* + `border-radius:999px` + `bg-base` + `label-secondary` + `padding:2px 8px`
|
|
176
|
+
* · 悬停一律 `var(--dsw-alias-interactive-bg-hover)`(原生包里的同名令牌)
|
|
177
|
+
* 颜色**只用主题令牌**,不写死色值——深色/浅色跟着宿主走。
|
|
178
|
+
*/
|
|
179
|
+
const CSS = `
|
|
180
|
+
.clearai-btn{box-sizing:border-box;display:inline-flex;align-items:center;gap:4px;height:26px;padding:0 10px;border:.5px solid var(--dsw-alias-border-l3);border-radius:999px;background:0 0;color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;line-height:18px;cursor:pointer;white-space:nowrap}
|
|
181
|
+
.clearai-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}
|
|
182
|
+
.clearai-btn:disabled{color:var(--dsw-alias-label-secondary);opacity:.5;cursor:default}
|
|
183
|
+
.clearai-btn[data-tone="warn"]{border-color:var(--dsw-alias-state-warn-primary)}
|
|
184
|
+
/* 工具行里的控制(§18.1):照原生那一行的形状——无边框、次级文字色、悬浮才出底。
|
|
185
|
+
刻意不做成我们自己那种药丸:它坐在原生「完全权限」旁边,长得像原生才不突兀。 */
|
|
186
|
+
.clearai-toolctl{box-sizing:border-box;display:inline-flex;align-items:center;gap:2px;padding:2px 6px;border:0;border-radius:4px;background:transparent;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px;cursor:pointer}
|
|
187
|
+
.clearai-toolctl:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
|
|
188
|
+
.clearai-chip{box-sizing:border-box;display:inline-flex;align-items:center;gap:4px;padding:1px 8px;border:.5px solid var(--dsw-alias-border-l4);border-radius:999px;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-secondary);font-size:11px;line-height:16px;cursor:pointer}
|
|
189
|
+
.clearai-chip:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}
|
|
190
|
+
.clearai-tag{display:inline-block;padding:0 6px;border:.5px solid var(--dsw-alias-border-l3);border-radius:999px;color:var(--dsw-alias-label-secondary);font-size:11px;line-height:16px;white-space:nowrap}
|
|
191
|
+
.clearai-tag[data-strong="1"]{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-primary)}
|
|
192
|
+
.clearai-link{color:var(--dsw-alias-label-primary);text-decoration:underline;text-underline-offset:2px;cursor:pointer}
|
|
193
|
+
.clearai-link:hover{color:var(--dsw-alias-brand-primary)}
|
|
194
|
+
/* 世界树的**唯一**活性惯用法(照抄 ClearAI PlanTree 的 globals.css):
|
|
195
|
+
节点外的柔光环一呼一吸 = 这东西此刻在动;步骤与世界线共用它——两种闪法并存的话,
|
|
196
|
+
读者得学两次「什么在动」。透明度区间刻意压在 0.22–0.07:光环是底噪级的存在感,
|
|
197
|
+
不该盖过节点自己的语义色。 */
|
|
198
|
+
@keyframes clearai-breathe{0%,100%{opacity:.22}50%{opacity:.07}}
|
|
199
|
+
.clearai-breathe{animation:clearai-breathe 1.6s ease-in-out infinite}
|
|
200
|
+
/* Evaluator 正在审这一步:**虚线**描边环呼吸。与上面是两件事——填充柔光=它在跑,
|
|
201
|
+
虚线描边=有外部观察者在看它。区分走**形状**不走颜色:颜色通道只表达结局。 */
|
|
202
|
+
@keyframes clearai-breathe-stroke{0%,100%{opacity:.85}50%{opacity:.35}}
|
|
203
|
+
.clearai-breathe-stroke{animation:clearai-breathe-stroke 1.6s ease-in-out infinite}
|
|
204
|
+
@media (prefers-reduced-motion:reduce){.clearai-breathe,.clearai-breathe-stroke{animation:none}}
|
|
205
|
+
/* 树的行(可选中):悬停与选中都是背景,不改变字形——字形只被状态占用。 */
|
|
206
|
+
.clearai-treerow{display:flex;align-items:center;gap:6px;padding:0 6px;cursor:pointer;border-radius:4px;transition:background .12s}
|
|
207
|
+
.clearai-treerow:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
208
|
+
.clearai-treerow[data-sel="1"]{background:var(--dsw-alias-interactive-bg-active, var(--dsw-alias-interactive-bg-hover))}
|
|
209
|
+
`
|
|
210
|
+
/** 把上面那段 CSS 挂进页面(带 data-plugin 标记,宿主按包名记账,卸载时收掉)。 */
|
|
211
|
+
function installStyles() {
|
|
212
|
+
if (typeof document === 'undefined') return () => {}
|
|
213
|
+
const existing = document.querySelector('style[data-plugin="clearai-dsh"]')
|
|
214
|
+
if (existing !== null) return () => {}
|
|
215
|
+
const tag = document.createElement('style')
|
|
216
|
+
tag.dataset.plugin = 'clearai-dsh'
|
|
217
|
+
tag.textContent = CSS
|
|
218
|
+
document.head.append(tag)
|
|
219
|
+
return () => {
|
|
220
|
+
tag.remove()
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ── 样式:颜色一律用主题令牌(--dsw-alias-*),不写死色值 ─────────────────
|
|
225
|
+
const S = {
|
|
226
|
+
/** 空态里的标记行:quiet,只陈述"这一格是谁的",不跟正文抢。 */
|
|
227
|
+
emptyMark: { display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10, color: 'var(--dsw-alias-label-tertiary)' },
|
|
228
|
+
emptyName: { fontSize: 13, fontWeight: 600, letterSpacing: '.02em' },
|
|
229
|
+
/** 小节标题前的小标:与标题同基线,quiet。 */
|
|
230
|
+
headMark: { display: 'inline-flex', verticalAlign: '-2px', marginRight: 6 },
|
|
231
|
+
/** 人门区:等人的事永远最先说,所以它排在最上面,用一条竖线标出来。 */
|
|
232
|
+
gate: { display: 'flex', flexDirection: 'column', gap: 6, padding: '8px 10px', margin: '0 0 8px', borderLeft: '3px solid var(--dsw-alias-state-warn-primary)', background: 'var(--dsw-alias-bg-layer-1)', borderRadius: 6 },
|
|
233
|
+
gateRow: { display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'baseline' },
|
|
234
|
+
row: { display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'baseline', padding: '3px 0' },
|
|
235
|
+
pre: { whiteSpace: 'pre-wrap', wordBreak: 'break-word', font: 'inherit', fontSize: 12, maxHeight: 320, overflow: 'auto', background: 'var(--dsw-alias-bg-layer-2)', padding: 8, borderRadius: 6, margin: '4px 0 0', color: 'var(--dsw-alias-label-primary)' },
|
|
236
|
+
wrap: { height: '100%', overflowY: 'auto', padding: '14px 16px 24px', fontSize: 12.5, lineHeight: 1.55, color: 'var(--dsw-alias-label-primary)' },
|
|
237
|
+
bar: { display: 'flex', alignItems: 'baseline', gap: 10, flexWrap: 'wrap', paddingBottom: 10, borderBottom: '.5px solid var(--dsw-alias-border-l1)', marginBottom: 14 },
|
|
238
|
+
title: { fontSize: 13, fontWeight: 600, letterSpacing: 0.3 },
|
|
239
|
+
dim: { color: 'var(--dsw-alias-label-secondary)' },
|
|
240
|
+
faint: { color: 'var(--dsw-alias-label-secondary)', opacity: 0.7 },
|
|
241
|
+
mono: { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize: 11.5 },
|
|
242
|
+
section: { marginBottom: 16 },
|
|
243
|
+
head: { fontSize: 11, fontWeight: 600, letterSpacing: 1.2, textTransform: 'uppercase', color: 'var(--dsw-alias-label-secondary)', marginBottom: 6 },
|
|
244
|
+
row: { padding: '5px 0', borderTop: '.5px solid var(--dsw-alias-border-l1)' },
|
|
245
|
+
rowFirst: { padding: '5px 0' },
|
|
246
|
+
tag: { display: 'inline-block', padding: '0 6px', border: '.5px solid var(--dsw-alias-border-l3)', borderRadius: 999, color: 'var(--dsw-alias-label-secondary)', fontSize: 11, lineHeight: '16px', marginRight: 6, whiteSpace: 'nowrap' },
|
|
247
|
+
tagStrong: { display: 'inline-block', padding: '0 6px', border: '.5px solid var(--dsw-alias-label-primary)', borderRadius: 999, color: 'var(--dsw-alias-label-primary)', fontSize: 11, lineHeight: '16px', marginRight: 6, fontWeight: 600, whiteSpace: 'nowrap' },
|
|
248
|
+
inline: { display: 'flex', gap: 8, alignItems: 'baseline', flexWrap: 'wrap' },
|
|
249
|
+
kv: { display: 'grid', gridTemplateColumns: 'minmax(64px, max-content) 1fr', gap: '2px 10px', marginTop: 4 },
|
|
250
|
+
empty: { padding: '28px 4px', color: 'var(--dsw-alias-label-secondary)', fontSize: 12.5, lineHeight: 1.7 },
|
|
251
|
+
table: { width: '100%', borderCollapse: 'collapse', marginTop: 4 },
|
|
252
|
+
th: { textAlign: 'left', fontWeight: 600, fontSize: 11, color: 'var(--dsw-alias-label-secondary)', padding: '3px 6px 3px 0', borderBottom: '.5px solid var(--dsw-alias-border-l1)' },
|
|
253
|
+
td: { verticalAlign: 'top', padding: '4px 6px 4px 0', borderTop: '.5px solid var(--dsw-alias-border-l1)' },
|
|
254
|
+
strip: { display: 'flex', gap: 10, alignItems: 'baseline', fontSize: 11.5, color: 'var(--dsw-alias-label-secondary)', padding: '2px 2px 4px', flexWrap: 'wrap' },
|
|
255
|
+
branch: { marginTop: 4, paddingLeft: 10, borderLeft: '2px solid var(--dsw-alias-border-l2)' },
|
|
256
|
+
branchFirst: { marginTop: 6, paddingLeft: 10, borderLeft: '2px solid var(--dsw-alias-border-l2)' },
|
|
257
|
+
progressTrack: { display: 'inline-block', width: 90, height: 4, background: 'var(--dsw-alias-border-l1)', borderRadius: 2, verticalAlign: 'middle', marginLeft: 4 },
|
|
258
|
+
progressBar: { display: 'block', height: 4, background: 'var(--dsw-alias-brand-primary)', borderRadius: 2 },
|
|
259
|
+
/**
|
|
260
|
+
* §24「事实」那一格:一个知识货架(上架=已确认事实,下架=命题)。
|
|
261
|
+
* 默认一行一条命题,只有主张 + 当前处境 + 等级判者;点开才展开来路与证据。
|
|
262
|
+
*/
|
|
263
|
+
group: { display: 'flex', gap: 6, alignItems: 'baseline', fontSize: 11, fontWeight: 600, letterSpacing: 1, color: 'var(--dsw-alias-label-secondary)', margin: '10px 0 2px' },
|
|
264
|
+
groupN: { fontWeight: 400, color: 'var(--dsw-alias-label-secondary)', opacity: 0.7 },
|
|
265
|
+
factRow: { padding: '6px 0', borderTop: '.5px solid var(--dsw-alias-border-l1)' },
|
|
266
|
+
factClaim: { color: 'var(--dsw-alias-label-primary)' },
|
|
267
|
+
factMeta: { fontSize: 11.5, color: 'var(--dsw-alias-label-secondary)', opacity: 0.85, marginTop: 1 },
|
|
268
|
+
propRow: { padding: '6px 0', borderTop: '.5px solid var(--dsw-alias-border-l1)' },
|
|
269
|
+
propOpen: { padding: '6px 0 2px', borderTop: '.5px solid var(--dsw-alias-border-l1)', background: 'var(--dsw-alias-bg-layer-1)', borderRadius: 6 },
|
|
270
|
+
propHead: { display: 'grid', gridTemplateColumns: '1fr minmax(120px, auto) minmax(120px, auto)', gap: 10, alignItems: 'baseline', cursor: 'pointer' },
|
|
271
|
+
propClaim: { color: 'var(--dsw-alias-label-primary)' },
|
|
272
|
+
stale: { color: 'var(--dsw-alias-label-secondary)', opacity: 0.6 },
|
|
273
|
+
propWhere: { fontSize: 11.5, color: 'var(--dsw-alias-label-secondary)', opacity: 0.85, textAlign: 'left' },
|
|
274
|
+
propJudge: { fontSize: 11.5, color: 'var(--dsw-alias-label-secondary)', textAlign: 'right', whiteSpace: 'nowrap' },
|
|
275
|
+
propBody: { marginTop: 8, paddingLeft: 10, borderLeft: '2px solid var(--dsw-alias-border-l2)', display: 'flex', flexDirection: 'column', gap: 4 },
|
|
276
|
+
/** 流转图(§25):竖排的状态机,走过的边实线、未走的虚线灰,当前态高亮。 */
|
|
277
|
+
flow: { display: 'flex', flexDirection: 'column', gap: 0, margin: '2px 0 6px' },
|
|
278
|
+
flowStep: { display: 'flex', flexDirection: 'column' },
|
|
279
|
+
flowNode: { alignSelf: 'flex-start', display: 'inline-flex', gap: 6, alignItems: 'baseline', padding: '1px 10px', border: '.5px solid var(--dsw-alias-border-l2)', borderRadius: 999, fontSize: 11.5 },
|
|
280
|
+
flowNow: { borderColor: 'var(--dsw-alias-brand-primary)', color: 'var(--dsw-alias-brand-primary)', fontWeight: 600 },
|
|
281
|
+
flowReached: { color: 'var(--dsw-alias-label-primary)', borderColor: 'var(--dsw-alias-label-secondary)' },
|
|
282
|
+
flowOff: { color: 'var(--dsw-alias-label-secondary)', opacity: 0.45, borderStyle: 'dashed' },
|
|
283
|
+
flowEdgeOn: { fontSize: 11, color: 'var(--dsw-alias-label-secondary)', padding: '1px 0 1px 8px' },
|
|
284
|
+
flowEdgeOff: { fontSize: 11, color: 'var(--dsw-alias-label-secondary)', opacity: 0.35, padding: '1px 0 1px 8px' },
|
|
285
|
+
flowTag: { fontSize: 10, opacity: 0.75 },
|
|
286
|
+
pathLine: { display: 'flex', gap: 6, alignItems: 'baseline', flexWrap: 'wrap', fontSize: 11.5 },
|
|
287
|
+
pathKey: { color: 'var(--dsw-alias-label-secondary)', opacity: 0.7, minWidth: 34 },
|
|
288
|
+
pathVal: { color: 'var(--dsw-alias-label-secondary)' },
|
|
289
|
+
pathArrow: { color: 'var(--dsw-alias-label-secondary)', opacity: 0.7 },
|
|
290
|
+
pathNode: { color: 'var(--dsw-alias-label-primary)', fontWeight: 500 },
|
|
291
|
+
evList: { display: 'flex', flexDirection: 'column', gap: 2, marginTop: 2 },
|
|
292
|
+
evRow: { display: 'flex', gap: 8, alignItems: 'baseline', flexWrap: 'wrap', fontSize: 11.5 },
|
|
293
|
+
evHead: { color: 'var(--dsw-alias-label-primary)', fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize: 11, whiteSpace: 'nowrap' },
|
|
294
|
+
evBasis: { color: 'var(--dsw-alias-label-secondary)', opacity: 0.85, flex: '1 1 auto', minWidth: 0 },
|
|
295
|
+
fileLink: { color: 'var(--dsw-alias-brand-primary)', cursor: 'pointer', whiteSpace: 'nowrap' },
|
|
296
|
+
pathFoot: { fontSize: 11, color: 'var(--dsw-alias-label-secondary)', opacity: 0.7, marginTop: 2 },
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** 原生那一档的药丸按钮(28px 圆角版),`tone` 只影响边框色。 */
|
|
300
|
+
function Button(props) {
|
|
301
|
+
return h(
|
|
302
|
+
'button',
|
|
303
|
+
{ type: 'button', className: 'clearai-btn', 'data-tone': props.tone, disabled: props.disabled === true, title: props.title, onClick: props.onClick },
|
|
304
|
+
props.children,
|
|
305
|
+
)
|
|
306
|
+
}
|
|
307
|
+
/** 更小的一档(11px),用在密集列表里。 */
|
|
308
|
+
function Chip(props) {
|
|
309
|
+
return h('button', { type: 'button', className: 'clearai-chip', disabled: props.disabled === true, title: props.title, onClick: props.onClick }, props.children)
|
|
310
|
+
}
|
|
311
|
+
/** 可点的文本(替代裸下划线 span)。 */
|
|
312
|
+
function Link(props) {
|
|
313
|
+
return h('span', { className: 'clearai-link', title: props.title, onClick: props.onClick }, props.children)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* 读一个面板自己的 HTTP 响应。
|
|
318
|
+
*
|
|
319
|
+
* 为什么不能直接 `response.json()`:那条路上失败时返回的是**纯文本**
|
|
320
|
+
* (`connection` 层查不到 exact fetch route 就回 `404 "not found"`),
|
|
321
|
+
* 直接 parse 会抛出 `Unexpected token 'o', "not found" is not valid JSON`
|
|
322
|
+
* ——人看到的是解析器的抱怨,不是发生了什么(2026-09-11 实测)。
|
|
323
|
+
* 这里统一成 `{ ok, status, payload, error }`,`error` 里带上状态码与原文。
|
|
324
|
+
*/
|
|
325
|
+
const readResponse = async (response) => {
|
|
326
|
+
const text = await response.text()
|
|
327
|
+
let payload = null
|
|
328
|
+
try {
|
|
329
|
+
payload = text === '' ? null : JSON.parse(text)
|
|
330
|
+
} catch {
|
|
331
|
+
payload = null
|
|
332
|
+
}
|
|
333
|
+
if (payload !== null && payload.ok === true) return { ok: true, status: response.status, payload, error: null }
|
|
334
|
+
const reason = payload !== null && typeof payload.error === 'string' ? payload.error : `${response.status} ${text.slice(0, 80)}`.trim()
|
|
335
|
+
return { ok: false, status: response.status, payload, error: reason }
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function Progress(props) {
|
|
339
|
+
if (typeof props.value !== 'number') return h('span', { style: S.faint }, t('完成度 —'))
|
|
340
|
+
const pct = Math.max(0, Math.min(1, props.value))
|
|
341
|
+
return h(
|
|
342
|
+
'span',
|
|
343
|
+
null,
|
|
344
|
+
t('完成度 '),
|
|
345
|
+
`${Math.round(pct * 100)}%`,
|
|
346
|
+
h('span', { style: S.progressTrack }, h('span', { style: { ...S.progressBar, width: `${Math.round(pct * 100)}%` } })),
|
|
347
|
+
)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function Tag(props) {
|
|
351
|
+
return h('span', { style: props.strong === true ? S.tagStrong : S.tag }, props.children)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* 小节。`mark: true` 时在标题前放我们的标记 —— **只有事实货架要它**:
|
|
356
|
+
* 那一格是这套系统真正的产物(已确认、可引用),标一下是"这一格由 ClearAI 维护"的陈述。
|
|
357
|
+
* 别的标题不标:标滥了就只是装饰(§11:同类同一规矩,而不是到处贴)。
|
|
358
|
+
*/
|
|
359
|
+
function Section(props) {
|
|
360
|
+
return h(
|
|
361
|
+
'div',
|
|
362
|
+
{ style: S.section },
|
|
363
|
+
h(
|
|
364
|
+
'div',
|
|
365
|
+
{ style: S.head },
|
|
366
|
+
props.mark === true ? h('span', { style: S.headMark }, h(ClearAIMark, { size: 13 })) : null,
|
|
367
|
+
props.title,
|
|
368
|
+
),
|
|
369
|
+
props.children,
|
|
370
|
+
)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* 空态。**每一格空态都带我们的标记**——同类空态同一规矩(§11),不是逐处装饰。
|
|
375
|
+
*
|
|
376
|
+
* 为什么标在这里而不是页签图标那一排:一排里两个一样的标是**重复**(试过,退了);
|
|
377
|
+
* 而空态一次只看得见一个,标记在这里是"这一格是我们的"这一句陈述,克制且不抢。
|
|
378
|
+
*/
|
|
379
|
+
function Empty(props) {
|
|
380
|
+
return h(
|
|
381
|
+
'div',
|
|
382
|
+
{ style: S.empty },
|
|
383
|
+
h(
|
|
384
|
+
'div',
|
|
385
|
+
{ style: S.emptyMark },
|
|
386
|
+
h(ClearAIMark, { size: 22 }),
|
|
387
|
+
h('span', { style: S.emptyName }, 'ClearAI'),
|
|
388
|
+
),
|
|
389
|
+
props.children,
|
|
390
|
+
)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* **目标那一行**(§23):世界树页眉用。只放事实(主张 · 判据 · 阶段与完成度),
|
|
395
|
+
* 详情(结算单、推进次数)在各处自己的面上——页眉不该变成第二份面板。
|
|
396
|
+
*/
|
|
397
|
+
function GoalLine(props) {
|
|
398
|
+
const goal = props.goal
|
|
399
|
+
if (goal === null || goal === undefined) return null
|
|
400
|
+
return h(
|
|
401
|
+
'div',
|
|
402
|
+
{ style: { ...S.rowFirst, ...S.inline } },
|
|
403
|
+
h('span', null, dash(goal.claim)),
|
|
404
|
+
// 阶段与完成度归**状态条**(常驻可见)⇒ 这里不重复(§30:两处说同一件事 = 冗余)
|
|
405
|
+
// 判据常常是五条清单(真数据里 ~400 字 ✗)⇒ 页眉只放一句,全文进 tooltip(§30)
|
|
406
|
+
h('span', { style: S.faint, title: String(goal.doneCriteria ?? '') }, `${t('判据:')}${brief(goal.doneCriteria, 56)}`),
|
|
407
|
+
)
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function GoalDomain(props) {
|
|
411
|
+
const goal = props.goal
|
|
412
|
+
if (goal === null || goal === undefined) {
|
|
413
|
+
return h(Empty, null, t('还没有目标。目标带一份「怎样算回答了」的判据——判据在结果出现之前写下,由系统强制。'))
|
|
414
|
+
}
|
|
415
|
+
return h(
|
|
416
|
+
Section,
|
|
417
|
+
{ title: t('目标') },
|
|
418
|
+
h('div', { style: S.rowFirst }, h('div', null, dash(goal.claim))),
|
|
419
|
+
h(
|
|
420
|
+
'div',
|
|
421
|
+
{ style: S.kv },
|
|
422
|
+
h('span', { style: S.dim }, t('判据')),
|
|
423
|
+
h('span', null, dash(goal.doneCriteria)),
|
|
424
|
+
h('span', { style: S.dim }, t('阶段')),
|
|
425
|
+
h('span', null, h(Tag, { strong: STRONG[goal.phase] === true }, gloss(PHASE, goal.phase)), h('span', { style: S.faint }, `${t('派生 · ')}${dash(goal.phase)}`)),
|
|
426
|
+
h('span', { style: S.dim }, t('进度')),
|
|
427
|
+
h('span', null, h(Progress, { value: goal.progress })),
|
|
428
|
+
h('span', { style: S.dim }, t('升格门槛')),
|
|
429
|
+
h('span', null, `${dash(goal.promoteAtLevel)}${t('(假设达到这一级且无推翻才升格为事实)')}`),
|
|
430
|
+
goal.revision > 1 ? h('span', { style: S.dim }, t('版本')) : null,
|
|
431
|
+
goal.revision > 1 ? h('span', null, `rev${goal.revision}${t('(每次修订留痕,旧值不删)')}`) : null,
|
|
432
|
+
),
|
|
433
|
+
)
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* 对象的**人话名字**(§23)。与本体声明里的对象名一一对应——交叉校验测试里
|
|
438
|
+
* 有一条「声明里每个对象都必须在这里有名字」,少一个就红(界面不许漏对象)。
|
|
439
|
+
*/
|
|
440
|
+
const LOOP_LABEL = lazyTable(() => ({
|
|
441
|
+
goal: t('目标'),
|
|
442
|
+
hypothesis: t('命题'),
|
|
443
|
+
plan: t('计划'),
|
|
444
|
+
step: t('验证'),
|
|
445
|
+
observation: t('观测'),
|
|
446
|
+
evaluation: t('评估'),
|
|
447
|
+
evidence: t('证据'),
|
|
448
|
+
fact: t('事实'),
|
|
449
|
+
release: t('放行'),
|
|
450
|
+
}))
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* 命题的分组 = **本体声明的五个状态**,顺序即认识论的顺序。
|
|
454
|
+
*
|
|
455
|
+
* 用词有权威出处:[`docs/verification-loop.md`](../docs/verification-loop.md) §5 的状态表
|
|
456
|
+
* (那份文档明说「其他文档、提示词与代码注释提到这些概念时,以这里的叫法为准」)。
|
|
457
|
+
* 界面因此不另造一套产品词——「已确认」的命题不在这一列:它已经**升格为事实**,在事实货架上。
|
|
458
|
+
*/
|
|
459
|
+
const PROPOSITION_GROUPS = lazyTable(() => ([
|
|
460
|
+
{ status: 'proposed', label: t('已提出') },
|
|
461
|
+
{ status: 'alive', label: t('验证中') },
|
|
462
|
+
{ status: 'refuted', label: t('已推翻') },
|
|
463
|
+
{ status: 'superseded', label: t('已替代') },
|
|
464
|
+
{ status: 'retracted', label: t('已撤回') },
|
|
465
|
+
]))
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* 一行够用的摘要。**真数据逼出来的**:评估者的依据常常是几百字的一整段
|
|
469
|
+
* (逐条对照判据 + 隔离标注),原样内联会把那一行撑到没法看。
|
|
470
|
+
* 摘要进列表,全文进 tooltip——「可查」不等于「必须占屏」。
|
|
471
|
+
*/
|
|
472
|
+
function brief(value, max = 64) {
|
|
473
|
+
const text = String(value ?? '').replace(/\s+/g, ' ').trim()
|
|
474
|
+
if (text === '') return '—'
|
|
475
|
+
return text.length <= max ? text : `${text.slice(0, max)}…`
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const LEVEL_RANK = { L0: 0, L1: 1, L2: 2, L3: 3, L4: 4 }
|
|
479
|
+
const levelRank = (value) => LEVEL_RANK[value] ?? -1
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* 一条命题的证据。链是**证据 → 步骤 → 命题**(`evidence.stepId` → `step.tests.hypothesis`),
|
|
483
|
+
* 与原始设计 `evidenceForHypothesis` 同一条链:证据挂在步骤上,不直接挂在命题上。
|
|
484
|
+
*/
|
|
485
|
+
function evidenceOf(data, hypothesisId) {
|
|
486
|
+
/**
|
|
487
|
+
* 走**跨计划**的步骤索引(§25):命题的验证步常常留在**已收尾的旧计划**里
|
|
488
|
+
* (真数据:两条计划,`tests` 全在旧的那条上)⇒ 只查活动计划会全断,
|
|
489
|
+
* 面板上五个命题的证据与判者全是「—」。索引由折法派生,不新增存储。
|
|
490
|
+
*/
|
|
491
|
+
const index = data?.stepIndex ?? {}
|
|
492
|
+
const steps = new Set(
|
|
493
|
+
Object.entries(index)
|
|
494
|
+
.filter(([, meta]) => meta?.tests?.hypothesis === hypothesisId)
|
|
495
|
+
.map(([stepId]) => stepId),
|
|
496
|
+
)
|
|
497
|
+
return (data?.evidence ?? []).filter((item) => steps.has(item.stepId)).slice().sort((a, b) => (a.at ?? 0) - (b.at ?? 0))
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* 一条证据的**出处**(§27b)。分两层:
|
|
502
|
+
*
|
|
503
|
+
* ① **记账时定下的 `origins` 优先** —— 四类入口(产物 / 评估卡 / 评估者子会话 / 人放行)
|
|
504
|
+
* 是内核在写证据那一刻解析好的事实,界面只渲染;
|
|
505
|
+
* ② 旧日志没有 `origins` 时走**只读回退**:材料 id → 材料表里的路径,换不出来的**丢掉** ——
|
|
506
|
+
* 绝不把裸 id 渲染成"能点"的样子(真数据实测:整排出处因此点不开)。
|
|
507
|
+
*/
|
|
508
|
+
const ORIGIN_LABEL = lazyTable(() => ({ 'audit-card': t('评估卡'), 'evaluator-session': t('看评估者'), 'approval-record': t('审批记录') }))
|
|
509
|
+
/**
|
|
510
|
+
* 出处的**可点标签**:产物用**文件名**,不用「产物」三个字 ——
|
|
511
|
+
* 一条证据常常挂着两三个产物,都叫「产物」就没人知道该点谁(用户实测反馈)。
|
|
512
|
+
* 同名不同目录时才补路径片段(仍然是"一眼能认出点的是哪个")。
|
|
513
|
+
*/
|
|
514
|
+
function fileLabel(path, others) {
|
|
515
|
+
const text = String(path ?? '')
|
|
516
|
+
if (text === '') return t('产物')
|
|
517
|
+
const name = text.split('/').filter(Boolean).pop() ?? text
|
|
518
|
+
const sameName = (others ?? []).filter((other) => String(other ?? '').split('/').filter(Boolean).pop() === name)
|
|
519
|
+
if (sameName.length <= 1) return name
|
|
520
|
+
// 有重名:带上最后一层目录(products/report.md → products/report.md)
|
|
521
|
+
const parts = text.split('/').filter(Boolean)
|
|
522
|
+
return parts.length >= 2 ? parts.slice(-2).join('/') : text
|
|
523
|
+
}
|
|
524
|
+
function originsOf(data, item) {
|
|
525
|
+
if (Array.isArray(item.origins) && item.origins.length > 0) {
|
|
526
|
+
const paths = item.origins.filter((origin) => origin.kind === 'artifact').map((origin) => origin.path)
|
|
527
|
+
return item.origins.map((origin) => ({
|
|
528
|
+
kind: origin.kind,
|
|
529
|
+
label: origin.kind === 'artifact' ? fileLabel(origin.path, paths) : (ORIGIN_LABEL[origin.kind] ?? origin.kind),
|
|
530
|
+
path: typeof origin.path === 'string' && origin.path !== '' ? origin.path : null,
|
|
531
|
+
session: typeof origin.session === 'string' && origin.session !== '' ? origin.session : null,
|
|
532
|
+
call: typeof origin.call === 'string' && origin.call !== '' ? origin.call : null,
|
|
533
|
+
}))
|
|
534
|
+
}
|
|
535
|
+
const materials = new Map((data?.materials ?? []).map((row) => [row.id, row.ref]))
|
|
536
|
+
const asPath = (ref) => {
|
|
537
|
+
const text = String(ref ?? '').trim()
|
|
538
|
+
if (text === '') return null
|
|
539
|
+
if (materials.has(text)) return String(materials.get(text))
|
|
540
|
+
return /[/.]/.test(text) ? text : null
|
|
541
|
+
}
|
|
542
|
+
const out = []
|
|
543
|
+
if (item.anchor === 'auditor') {
|
|
544
|
+
/**
|
|
545
|
+
* 评估卡按**步骤**归属;世界线证据的卡按 `分支` 归属(形如 `k-…:b-…`),
|
|
546
|
+
* 与证据的 `stepId` 不是同一个 id —— 所以两种都要认(实测踩过:只认 stepId 时,
|
|
547
|
+
* 世界线的五条独立证据一条都指不到卡)。
|
|
548
|
+
*/
|
|
549
|
+
const audits = (data?.audits ?? []).filter((row) => row.evaluator === 'independent')
|
|
550
|
+
const audit =
|
|
551
|
+
audits.find((row) => row.stepId === item.stepId) ??
|
|
552
|
+
(item.branch === null || item.branch === undefined ? undefined : audits.find((row) => String(row.stepId ?? '').endsWith(`:${item.branch}`)))
|
|
553
|
+
if (audit?.cardPath !== null && audit?.cardPath !== undefined) out.push({ kind: 'audit-card', label: t('评估卡'), path: String(audit.cardPath) })
|
|
554
|
+
if (audit?.evaluatorSession !== null && audit?.evaluatorSession !== undefined) out.push({ kind: 'evaluator-session', label: t('看评估者'), session: String(audit.evaluatorSession) })
|
|
555
|
+
}
|
|
556
|
+
const fallbackPaths = (item.refs ?? []).map((ref) => asPath(ref)).filter((path) => path !== null)
|
|
557
|
+
for (const ref of item.refs ?? []) {
|
|
558
|
+
const path = asPath(ref)
|
|
559
|
+
if (path !== null) out.push({ kind: 'artifact', label: fileLabel(path, fallbackPaths), path })
|
|
560
|
+
}
|
|
561
|
+
const released = (data?.releases ?? []).find((row) => row.step === item.stepId)
|
|
562
|
+
if (released !== undefined) out.push({ kind: 'approval-record', label: t('审批记录'), path: null, call: released.call ?? null })
|
|
563
|
+
return out
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** 一条证据一句话:`e-1 · L2 · 支持 · 自判 · 依据… · 出处`。 */
|
|
567
|
+
function evidenceLine(data, item) {
|
|
568
|
+
return h(
|
|
569
|
+
'div',
|
|
570
|
+
{ key: item.id, style: S.evRow },
|
|
571
|
+
h('span', { style: S.evHead }, `${item.id} · ${dash(item.level)} · ${gloss(VERDICT, item.verdict)} · ${gloss(EVALUATOR, item.evaluator)}`),
|
|
572
|
+
h('span', { style: S.evBasis, title: String(item.basis ?? '') }, brief(item.basis, 160)),
|
|
573
|
+
...originsOf(data, item).map((origin, index) => {
|
|
574
|
+
/**
|
|
575
|
+
* 四类入口各自的去处(§27b):
|
|
576
|
+
* 产物 / 评估卡 → 右侧**原生预览**打开那个文件;
|
|
577
|
+
* 看评估者 → 旁观**评估者子会话**(论证过程在里面,harness 原生能力);
|
|
578
|
+
* 审批记录 → 没有文件(原生审批对在会话日志里),如实说明它凭什么不可伪造。
|
|
579
|
+
*/
|
|
580
|
+
const open =
|
|
581
|
+
origin.kind === 'evaluator-session'
|
|
582
|
+
? origin.session === null || data.openSpectator === undefined
|
|
583
|
+
? undefined
|
|
584
|
+
: () => data.openSpectator(origin.session)
|
|
585
|
+
: origin.path === null || data.openPreview === undefined
|
|
586
|
+
? undefined
|
|
587
|
+
: () => data.openPreview(origin.path)
|
|
588
|
+
const title =
|
|
589
|
+
origin.kind === 'evaluator-session'
|
|
590
|
+
? t('旁观写这条裁决的评估者子会话(论证过程)')
|
|
591
|
+
: origin.kind === 'approval-record'
|
|
592
|
+
? `${t('会话日志里的原生审批对(不可伪造)')}${origin.call === null ? '' : ` · ${origin.call}`}`
|
|
593
|
+
: (origin.path ?? '')
|
|
594
|
+
return h(
|
|
595
|
+
'span',
|
|
596
|
+
{
|
|
597
|
+
key: `${item.id}-${origin.label}-${index}`,
|
|
598
|
+
style: open === undefined ? S.faint : S.fileLink,
|
|
599
|
+
onClick:
|
|
600
|
+
open === undefined
|
|
601
|
+
? undefined
|
|
602
|
+
: (event) => {
|
|
603
|
+
event?.stopPropagation?.()
|
|
604
|
+
open()
|
|
605
|
+
},
|
|
606
|
+
title,
|
|
607
|
+
},
|
|
608
|
+
origin.label,
|
|
609
|
+
)
|
|
610
|
+
}),
|
|
611
|
+
)
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** 当前处境一句话:这是这一格存在的理由——用户要知道「现在能不能当真」。 */
|
|
615
|
+
function whereOf(data, row) {
|
|
616
|
+
const evidence = evidenceOf(data, row.id)
|
|
617
|
+
const threshold = data?.goal?.promoteAtLevel ?? 'L3'
|
|
618
|
+
const refute = evidence.find((item) => item.verdict === 'refute')
|
|
619
|
+
if (row.status === 'refuted') return refute === undefined ? t('出现推翻证据(终态,记录保留)') : `${t('被 ')}${refute.id}${t(' 推翻:')}${brief(refute.basis, 56)}`
|
|
620
|
+
if (row.status === 'superseded') return t('已被下一版命题替代(版本留着,不参与当前推理)')
|
|
621
|
+
if (row.status === 'confirmed') return t('已达门槛,已升格为事实')
|
|
622
|
+
if (row.status === 'retracted') return t('人已撤回(记录保留)')
|
|
623
|
+
if (row.supportedLevel === null || row.supportedLevel === undefined) return t('还没有证据:先登记判据,再验证')
|
|
624
|
+
if (levelRank(row.supportedLevel) >= levelRank(threshold)) return `${t('已达门槛(')}${row.supportedLevel}${t('),等目标验收时升格为事实')}`
|
|
625
|
+
return `${t('支持到 ')}${row.supportedLevel}${t(':还差 ')}${threshold}${t('(要独立评估)才达门槛')}`
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* 一条**事实**的证据落在哪一步(纯函数:可单测)。
|
|
630
|
+
*
|
|
631
|
+
* 为什么需要:会话结案后命题已升格为事实、离开命题货架 ⇒ 命题 drill 里那条
|
|
632
|
+
* 「在世界树里看这一步」就够不着了 ✗。事实这一层同样要能跳回它那一步。
|
|
633
|
+
*/
|
|
634
|
+
function stepOfFact(data, fact) {
|
|
635
|
+
for (const id of fact?.evidenceIds ?? []) {
|
|
636
|
+
const item = (data?.evidence ?? []).find((entry) => entry.id === id)
|
|
637
|
+
const stepId = item?.stepId
|
|
638
|
+
if (typeof stepId === 'string' && stepId !== '') return stepId
|
|
639
|
+
}
|
|
640
|
+
return null
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/** 一条事实的证据落在哪份计划里:多份计划各有自己的世界树,跳过去时要切到那一份。 */
|
|
644
|
+
function rowPlanOf(data, fact) {
|
|
645
|
+
for (const id of fact?.evidenceIds ?? []) {
|
|
646
|
+
const item = (data?.evidence ?? []).find((entry) => entry.id === id)
|
|
647
|
+
if (typeof item?.planId === 'string' && item.planId !== '') return item.planId
|
|
648
|
+
}
|
|
649
|
+
return null
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/** 右侧那一列:最有力的一条证据给出的**等级 · 判者**——这就是「多可信」。 */
|
|
653
|
+
function judgeOf(data, row) {
|
|
654
|
+
const evidence = evidenceOf(data, row.id)
|
|
655
|
+
const best = evidence.slice().sort((a, b) => levelRank(b.level) - levelRank(a.level))[0]
|
|
656
|
+
return best === undefined ? '—' : `${dash(best.level)} · ${gloss(EVALUATOR, best.evaluator)}`
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/** 实际**走过**的转移(不画未走的岔路:那是过程知识,不是「我现在能信什么」)。 */
|
|
660
|
+
function transitionsOf(data, row) {
|
|
661
|
+
const evidence = evidenceOf(data, row.id)
|
|
662
|
+
const out = []
|
|
663
|
+
/**
|
|
664
|
+
* `to` 一律用**声明里的原始状态键**(`alive` / `refuted` …),不做翻译——
|
|
665
|
+
* 翻译只在渲染时做。混用两套键是实测踩过的坑:流转图按原始键查「这一跳走没走过」,
|
|
666
|
+
* 而这里返回的是中文字面 ⇒ 命不中 ⇒ 走过的边全被画成"没走"。
|
|
667
|
+
*/
|
|
668
|
+
if (evidence.length > 0) {
|
|
669
|
+
const first = evidence[0]
|
|
670
|
+
out.push({ to: 'alive', on: t('有了第一条证据'), by: `${first.id} · ${dash(first.level)} ${gloss(VERDICT, first.verdict)}`, independent: first.evaluator === 'independent' })
|
|
671
|
+
}
|
|
672
|
+
if (row.status === 'refuted') {
|
|
673
|
+
const refute = evidence.find((item) => item.verdict === 'refute')
|
|
674
|
+
out.push({
|
|
675
|
+
to: 'refuted',
|
|
676
|
+
on: t('出现推翻证据'),
|
|
677
|
+
by: refute === undefined ? '—' : `${refute.id} · ${gloss(EVALUATOR, refute.evaluator)}`,
|
|
678
|
+
independent: refute !== undefined && refute.evaluator === 'independent',
|
|
679
|
+
})
|
|
680
|
+
}
|
|
681
|
+
if (row.status === 'superseded') out.push({ to: 'superseded', on: t('被下一版命题改写'), by: row.version > 1 ? `v${row.version}` : '—', independent: false })
|
|
682
|
+
if (row.status === 'retracted') out.push({ to: 'retracted', on: t('人审查后决定撤回'), by: '—', independent: false })
|
|
683
|
+
return out
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* 流转图的**固定布局**(产品面,不是通用图布局器)。
|
|
688
|
+
*
|
|
689
|
+
* 主干一行(左→右):已提出 → 验证中 → 已确认;终态分支挂在下面一行。
|
|
690
|
+
* 这样无论声明有没有下发、有几条边,**横向主干都在** —— 不会退化成竖排。
|
|
691
|
+
* 声明下发后,边的 `on` / `actor` 从声明取(未走的边据此写清"这件事叫什么、谁发起");
|
|
692
|
+
* 声明还没下来的旧会话,用规范闭环的安全镜像,并在标题里如实标注。
|
|
693
|
+
*/
|
|
694
|
+
const FLOW_LAYOUT = {
|
|
695
|
+
nodeW: 112,
|
|
696
|
+
nodeH: 30,
|
|
697
|
+
width: 600,
|
|
698
|
+
height: 152,
|
|
699
|
+
trunkY: 20,
|
|
700
|
+
branchY: 100,
|
|
701
|
+
place: {
|
|
702
|
+
proposed: { x: 16, y: 20 },
|
|
703
|
+
alive: { x: 166, y: 20 },
|
|
704
|
+
confirmed: { x: 316, y: 20 },
|
|
705
|
+
refuted: { x: 166, y: 100 },
|
|
706
|
+
superseded: { x: 316, y: 100 },
|
|
707
|
+
retracted: { x: 466, y: 100 },
|
|
708
|
+
},
|
|
709
|
+
/** 声明缺失时的规范镜像:只用来画图,标题会说明它不是实时声明。 */
|
|
710
|
+
fallbackEdges: [
|
|
711
|
+
['proposed', 'alive', 'system', 'evidence_appended'],
|
|
712
|
+
['alive', 'confirmed', 'system', 'promotion_threshold'],
|
|
713
|
+
['alive', 'refuted', 'system', 'refuting_evidence'],
|
|
714
|
+
['proposed', 'superseded', 'model', 'hypothesis_revised'],
|
|
715
|
+
['confirmed', 'retracted', 'human', 'retraction_request'],
|
|
716
|
+
],
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* **流转图**(§27 重做):横向主干 + 分支的 SVG 状态机。
|
|
721
|
+
*
|
|
722
|
+
* 三条着色规矩(与世界树同一套视觉语言):
|
|
723
|
+
* · **走过的边**:实线 + 颜色(独立裁决用品牌强调色、自判用前景色),旁边写清触发与凭据;
|
|
724
|
+
* · **当前态**:品牌色描边 + 呼吸光环,并在节点上写「当前」;
|
|
725
|
+
* · **没走的边**:虚线 + 低饱和,写明「这件事 · 谁发起」。
|
|
726
|
+
*/
|
|
727
|
+
function StateFlow(props) {
|
|
728
|
+
const { data, row } = props
|
|
729
|
+
const spec = (data?.ontology?.objects ?? []).find((object) => object.name === 'hypothesis') ?? null
|
|
730
|
+
const declared = Array.isArray(spec?.edges) && spec.edges.length > 0 ? spec.edges : null
|
|
731
|
+
const edges = declared ?? FLOW_LAYOUT.fallbackEdges
|
|
732
|
+
const path = transitionsOf(data, row)
|
|
733
|
+
const taken = new Map(path.map((step) => [step.to, step]))
|
|
734
|
+
const current = row.status
|
|
735
|
+
const L = FLOW_LAYOUT
|
|
736
|
+
const items = []
|
|
737
|
+
for (const [from, to, actor, on] of edges) {
|
|
738
|
+
const a = L.place[from]
|
|
739
|
+
const b = L.place[to]
|
|
740
|
+
if (a === undefined || b === undefined || from === to) continue
|
|
741
|
+
const step = taken.get(to)
|
|
742
|
+
const done = step !== undefined
|
|
743
|
+
const color = done ? (step.independent === true ? 'var(--dsw-alias-brand-primary)' : 'var(--dsw-alias-label-primary)') : 'var(--dsw-alias-border-l2)'
|
|
744
|
+
const horizontal = a.y === b.y && b.x > a.x
|
|
745
|
+
const x1 = horizontal ? a.x + L.nodeW : a.x + L.nodeW / 2
|
|
746
|
+
const y1 = horizontal ? a.y + L.nodeH / 2 : a.y + L.nodeH
|
|
747
|
+
const x2 = horizontal ? b.x : b.x + L.nodeW / 2
|
|
748
|
+
const y2 = horizontal ? b.y + L.nodeH / 2 : b.y
|
|
749
|
+
const midY = (y1 + y2) / 2
|
|
750
|
+
items.push(
|
|
751
|
+
h(
|
|
752
|
+
'path',
|
|
753
|
+
{
|
|
754
|
+
key: `edge-${from}-${to}`,
|
|
755
|
+
d: horizontal ? `M ${x1} ${y1} H ${x2}` : `M ${x1} ${y1} V ${midY} H ${x2} V ${y2}`,
|
|
756
|
+
fill: 'none',
|
|
757
|
+
stroke: color,
|
|
758
|
+
strokeWidth: done ? 2 : 1,
|
|
759
|
+
strokeDasharray: done ? undefined : '4 3',
|
|
760
|
+
},
|
|
761
|
+
h(
|
|
762
|
+
'title',
|
|
763
|
+
null,
|
|
764
|
+
done ? `${step.on} · ${step.by}` : `${t('未走:')}${on ?? to} · ${actor ?? '?'}${t(' 发起')}`,
|
|
765
|
+
),
|
|
766
|
+
),
|
|
767
|
+
)
|
|
768
|
+
const label = done ? `${step.by.split(' · ')[0]} · ${step.by.split(' · ')[1] ?? ''}`.trim() : ''
|
|
769
|
+
if (label !== '') {
|
|
770
|
+
items.push(
|
|
771
|
+
h(
|
|
772
|
+
'text',
|
|
773
|
+
{
|
|
774
|
+
key: `label-${from}-${to}`,
|
|
775
|
+
x: horizontal ? (x1 + x2) / 2 : x2 + 5,
|
|
776
|
+
y: horizontal ? y1 - 7 : midY - 4,
|
|
777
|
+
textAnchor: horizontal ? 'middle' : 'start',
|
|
778
|
+
fontSize: 9.5,
|
|
779
|
+
fill: color,
|
|
780
|
+
},
|
|
781
|
+
label,
|
|
782
|
+
),
|
|
783
|
+
)
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
for (const [state, position] of Object.entries(L.place)) {
|
|
787
|
+
const reached = state === 'proposed' || taken.has(state)
|
|
788
|
+
const isNow = state === current
|
|
789
|
+
items.push(
|
|
790
|
+
h('rect', {
|
|
791
|
+
key: `node-${state}`,
|
|
792
|
+
x: position.x,
|
|
793
|
+
y: position.y,
|
|
794
|
+
width: L.nodeW,
|
|
795
|
+
height: L.nodeH,
|
|
796
|
+
rx: L.nodeH / 2,
|
|
797
|
+
fill: isNow ? 'var(--dsw-alias-bg-layer-2)' : 'transparent',
|
|
798
|
+
stroke: isNow ? 'var(--dsw-alias-brand-primary)' : reached ? 'var(--dsw-alias-label-primary)' : 'var(--dsw-alias-border-l2)',
|
|
799
|
+
strokeWidth: isNow ? 2 : 1,
|
|
800
|
+
strokeDasharray: reached || isNow ? undefined : '4 3',
|
|
801
|
+
className: isNow ? 'clearai-breathe-stroke' : undefined,
|
|
802
|
+
}),
|
|
803
|
+
)
|
|
804
|
+
items.push(
|
|
805
|
+
h(
|
|
806
|
+
'text',
|
|
807
|
+
{
|
|
808
|
+
key: `text-${state}`,
|
|
809
|
+
x: position.x + L.nodeW / 2,
|
|
810
|
+
y: position.y + L.nodeH / 2 + 4,
|
|
811
|
+
textAnchor: 'middle',
|
|
812
|
+
fontSize: 11,
|
|
813
|
+
fill: isNow ? 'var(--dsw-alias-brand-primary)' : reached ? 'var(--dsw-alias-label-primary)' : 'var(--dsw-alias-label-secondary)',
|
|
814
|
+
},
|
|
815
|
+
`${gloss(HYPOTHESIS, state)}${isNow ? ' · 当前' : ''}`,
|
|
816
|
+
),
|
|
817
|
+
)
|
|
818
|
+
}
|
|
819
|
+
/**
|
|
820
|
+
* 图下那几行**来路**:图上只标走过那一跳的证据 id(窄间距里读得清),
|
|
821
|
+
* 完整的「这件事叫什么 + 谁做的」写在这里 —— 图管结构,文字管凭据,各占各的地方。
|
|
822
|
+
*/
|
|
823
|
+
const walked = path.filter((step) => step.to !== 'retracted' || current === 'retracted')
|
|
824
|
+
return h(
|
|
825
|
+
'div',
|
|
826
|
+
{ style: { margin: '4px 0 8px', maxWidth: '100%' } },
|
|
827
|
+
h(
|
|
828
|
+
'div',
|
|
829
|
+
{ style: { overflowX: 'auto' } },
|
|
830
|
+
h(
|
|
831
|
+
'svg',
|
|
832
|
+
{ width: L.width, height: L.height, viewBox: `0 0 ${L.width} ${L.height}`, style: { display: 'block', minWidth: L.width } },
|
|
833
|
+
...items,
|
|
834
|
+
),
|
|
835
|
+
),
|
|
836
|
+
walked.length === 0
|
|
837
|
+
? null
|
|
838
|
+
: h(
|
|
839
|
+
'div',
|
|
840
|
+
{ style: { display: 'flex', flexDirection: 'column', gap: 1, marginTop: 2 } },
|
|
841
|
+
...walked.map((step) =>
|
|
842
|
+
h(
|
|
843
|
+
'div',
|
|
844
|
+
{ key: `walked-${step.to}`, style: { fontSize: 11, color: 'var(--dsw-alias-label-secondary)' } },
|
|
845
|
+
`${step.on} → ${gloss(HYPOTHESIS, step.to)} · ${step.by}`,
|
|
846
|
+
),
|
|
847
|
+
),
|
|
848
|
+
),
|
|
849
|
+
declared === null
|
|
850
|
+
? h('div', { style: { ...S.faint, fontSize: 11, marginTop: 2 } }, t('本体声明还没随投影下发,这里用的是规范闭环的镜像;会话跑过一拍后会自动对齐声明。'))
|
|
851
|
+
: null,
|
|
852
|
+
)
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* **命题行**:一行一条命题,默认只有主张 + 当前处境 + 等级判者。
|
|
857
|
+
* 点开才出现「凭什么」——流转图 + 每条证据的出处。
|
|
858
|
+
*/
|
|
859
|
+
function PropositionRow(props) {
|
|
860
|
+
const { data, row, open, onToggle } = props
|
|
861
|
+
const evidence = evidenceOf(data, row.id)
|
|
862
|
+
const path = transitionsOf(data, row)
|
|
863
|
+
return h(
|
|
864
|
+
'div',
|
|
865
|
+
{ style: open === true ? S.propOpen : S.propRow },
|
|
866
|
+
h(
|
|
867
|
+
'div',
|
|
868
|
+
{ style: S.propHead, onClick: onToggle },
|
|
869
|
+
h('span', { style: { ...S.propClaim, ...(row.status === 'refuted' || row.status === 'superseded' ? S.stale : {}) } }, dash(row.claim)),
|
|
870
|
+
h('span', { style: S.propWhere }, whereOf(data, row)),
|
|
871
|
+
h('span', { style: S.propJudge }, `${gloss(HYPOTHESIS, row.status)} · ${judgeOf(data, row)}`),
|
|
872
|
+
),
|
|
873
|
+
open === true
|
|
874
|
+
? h(
|
|
875
|
+
'div',
|
|
876
|
+
{ style: S.propBody },
|
|
877
|
+
h(StateFlow, { data, row }),
|
|
878
|
+
evidence.length === 0 ? null : h('div', { style: S.evList }, ...evidence.map((item) => evidenceLine(data, item))),
|
|
879
|
+
/**
|
|
880
|
+
* 图上那一跳的完整凭据(触发 + 判者 + 两个可点入口)。
|
|
881
|
+
* 这里**不再**摆本体文件路径:那是文档坐标,不是面板上的动作 ——
|
|
882
|
+
* 用户实测反馈「这句话不用了」(§28)。
|
|
883
|
+
*/
|
|
884
|
+
evidence.length === 0
|
|
885
|
+
? null
|
|
886
|
+
: h(
|
|
887
|
+
'div',
|
|
888
|
+
{ style: S.pathFoot },
|
|
889
|
+
h(
|
|
890
|
+
Link,
|
|
891
|
+
{
|
|
892
|
+
onClick: () => {
|
|
893
|
+
const last = evidence[evidence.length - 1]
|
|
894
|
+
treeFocus.set({ plan: last.planId ?? null, step: last.stepId, branch: last.branch ?? null })
|
|
895
|
+
if (data.openRail !== undefined) data.openRail('clearai-worldtree')
|
|
896
|
+
},
|
|
897
|
+
title: t('打开世界树并选中产生这条证据的验证步'),
|
|
898
|
+
},
|
|
899
|
+
t('在世界树里看这一步'),
|
|
900
|
+
),
|
|
901
|
+
),
|
|
902
|
+
)
|
|
903
|
+
: null,
|
|
904
|
+
)
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/** **命题货架**:按本体状态分组;空组不出现。已确认的不在这里(它已升格为事实)。 */
|
|
908
|
+
function PropositionShelf(props) {
|
|
909
|
+
/**
|
|
910
|
+
* 打开器**自己接**(不靠外层注入):谁渲染这个货架,点证据/点事实都能开原件。
|
|
911
|
+
* 实测踩过:只有最外层 `Facts` 注入 `openPreview` 时,单独渲染货架(测试缝、
|
|
912
|
+
* 以后的复用)会让整排出处看着能点、点了没反应。
|
|
913
|
+
*/
|
|
914
|
+
/**
|
|
915
|
+
* `props.X ?? 已有值`:外层 `Facts` 只传 `{data}`,而打开器在 `data` 里 ——
|
|
916
|
+
* 直接写 `openRail: props.openRail` 会用 `undefined` 把它盖掉 ⇒ 链接永不渲染 ✗(实测踩过)。
|
|
917
|
+
*/
|
|
918
|
+
const base = props.data ?? (typeof props.useProjection === 'function' ? props.useProjection('clearai') : undefined) ?? {}
|
|
919
|
+
const data = { ...base, openPreview: props.openPreview ?? base.openPreview, openRail: props.openRail ?? base.openRail, openSpectator: props.openSpectator ?? base.openSpectator }
|
|
920
|
+
const rows = (data.goal?.hypotheses ?? []).filter((row) => row.status !== 'confirmed')
|
|
921
|
+
const groups = PROPOSITION_GROUPS.map((group) => ({ ...group, rows: rows.filter((row) => row.status === group.status) })).filter((group) => group.rows.length > 0)
|
|
922
|
+
return h(
|
|
923
|
+
Section,
|
|
924
|
+
{ title: `${t('命题 · ')}${rows.length}` },
|
|
925
|
+
rows.length === 0
|
|
926
|
+
? h('div', { style: S.faint }, t('还没有命题。人与模型都可以提出:每条要有一句话主张与一句「什么结果会推翻它」;通过验证的会升格到上面的事实货架。'))
|
|
927
|
+
: h(
|
|
928
|
+
'div',
|
|
929
|
+
null,
|
|
930
|
+
...groups.map((group) =>
|
|
931
|
+
h(
|
|
932
|
+
'div',
|
|
933
|
+
{ key: group.status },
|
|
934
|
+
h('div', { style: S.group }, group.label, h('span', { style: S.groupN }, String(group.rows.length))),
|
|
935
|
+
...group.rows.map((row) => h(PropositionRow, { key: row.id, data, row, open: props.open === row.id, onToggle: () => props.onToggle(row.id) })),
|
|
936
|
+
),
|
|
937
|
+
),
|
|
938
|
+
),
|
|
939
|
+
)
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
/** **事实货架**:已确认、可作已知的那一层。每条都带边界——没有边界的事实没人敢用。 */
|
|
943
|
+
function FactShelf(props) {
|
|
944
|
+
const base = props.data ?? (typeof props.useProjection === 'function' ? props.useProjection('clearai') : undefined) ?? {}
|
|
945
|
+
const data = { ...base, openPreview: props.openPreview ?? base.openPreview, openRail: props.openRail ?? base.openRail, openSpectator: props.openSpectator ?? base.openSpectator }
|
|
946
|
+
const facts = data.facts ?? []
|
|
947
|
+
return h(
|
|
948
|
+
Section,
|
|
949
|
+
{ title: `${t('已确认事实 · ')}${facts.length}`, mark: true },
|
|
950
|
+
facts.length === 0
|
|
951
|
+
? h(
|
|
952
|
+
'div',
|
|
953
|
+
{ style: S.faint },
|
|
954
|
+
data.goal === null || data.goal === undefined
|
|
955
|
+
? t('还没有目标。立约并验证之后,达门槛且无推翻的命题会在目标验收时升格为事实。')
|
|
956
|
+
: t('达门槛且无推翻的命题,会在**目标验收**时由系统升格为事实(写在 clear/knowledge/facts/,模型读的 INDEX.md 同步)。'),
|
|
957
|
+
)
|
|
958
|
+
: h(
|
|
959
|
+
'div',
|
|
960
|
+
null,
|
|
961
|
+
...facts.map((row) =>
|
|
962
|
+
h(
|
|
963
|
+
'div',
|
|
964
|
+
{
|
|
965
|
+
key: row.id,
|
|
966
|
+
style: S.factRow,
|
|
967
|
+
/** 这一格**就是**事实库:点这条事实开它的原件(右侧原生预览),不再另挂一个「打开事实库」。 */
|
|
968
|
+
onClick: row.path === null || row.path === undefined || data.openPreview === undefined ? undefined : () => data.openPreview(row.path),
|
|
969
|
+
title: row.path === null || row.path === undefined ? '' : `${t('打开 ')}${row.path}`,
|
|
970
|
+
},
|
|
971
|
+
h('div', { style: S.factClaim }, dash(row.text)),
|
|
972
|
+
h(
|
|
973
|
+
'div',
|
|
974
|
+
{ style: S.factMeta, title: row.scope === null || row.scope === undefined ? '' : String(row.scope) },
|
|
975
|
+
/**
|
|
976
|
+
* 边界和证据的「依据」是同一类字段 ⇒ **同一条规矩**:列表放摘要、全文进 tooltip。
|
|
977
|
+
* 真数据里边界常是 200~300 字,三条事实就把这一格撑到 1393 字 ✗(§30)。
|
|
978
|
+
*/
|
|
979
|
+
`${t('边界:')}${row.scope === null || row.scope === undefined || String(row.scope).trim() === '' ? '(未写——引用前请谨慎)' : brief(row.scope, 80)}${t(' · 支持到 ')}${dash(row.level)}${t(' · 证据 ')}${(row.evidenceIds ?? []).length}${t(' 条')}`,
|
|
980
|
+
/**
|
|
981
|
+
* 原件的标签用**文件名**(「打开原件」四平八稳,但一排下来同样认不出是谁)。
|
|
982
|
+
*/
|
|
983
|
+
/**
|
|
984
|
+
* 整行已经可点开原件 ⇒ 不再挂一个同样动作的「事实存档」✗(§30:同一动作两条路)。
|
|
985
|
+
* 原件路径在整行的 tooltip 里(`打开 <path>`)。
|
|
986
|
+
*/
|
|
987
|
+
/**
|
|
988
|
+
* 跳去它那一步(§27e)。**不把 `openRail` 当成渲染前提** ——
|
|
989
|
+
* 上一版因此整条链接都不出现(用户实测:那一行什么都没有)。
|
|
990
|
+
* 拿不到 `openRail` 也照常渲染:点了至少把聚焦设上、并试一次原生切视图。
|
|
991
|
+
*/
|
|
992
|
+
stepOfFact(data, row) === null
|
|
993
|
+
? null
|
|
994
|
+
: h(
|
|
995
|
+
Link,
|
|
996
|
+
{
|
|
997
|
+
onClick: (event) => {
|
|
998
|
+
event?.stopPropagation?.()
|
|
999
|
+
treeFocus.set({ plan: rowPlanOf(data, row), step: stepOfFact(data, row), branch: null })
|
|
1000
|
+
if (data.openRail !== undefined) data.openRail('clearai-worldtree')
|
|
1001
|
+
},
|
|
1002
|
+
title: t('打开世界树并选中产出这条事实的验证步'),
|
|
1003
|
+
},
|
|
1004
|
+
t('在世界树里看这一步'),
|
|
1005
|
+
),
|
|
1006
|
+
),
|
|
1007
|
+
),
|
|
1008
|
+
),
|
|
1009
|
+
),
|
|
1010
|
+
)
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/**
|
|
1014
|
+
* **「事实」页签**(§24):一个**知识货架**,不是机制说明页。
|
|
1015
|
+
*
|
|
1016
|
+
* 上架 = 已确认事实(可用作下一轮已知,按支持等级分级、每条带边界);
|
|
1017
|
+
* 下架 = 命题(人与模型提出的候选知识,按本体状态分组流转)。
|
|
1018
|
+
* 默认只回答一个问题:「我现在能信什么」;点开一条命题才回答「凭什么」。
|
|
1019
|
+
*/
|
|
1020
|
+
function Facts(props) {
|
|
1021
|
+
// 取数与 Panel/Deliverables 同一姿势:宿主半的会话投影单元 `clearai` 由插座作为标准 prop 交下来。
|
|
1022
|
+
const projected = typeof props.useProjection === 'function' ? props.useProjection('clearai') : undefined
|
|
1023
|
+
const [manual, setManual] = React.useState(null)
|
|
1024
|
+
const incoming = useFocus(factsFocus)
|
|
1025
|
+
if (projected === undefined || projected === null) {
|
|
1026
|
+
return h('div', { style: S.wrap }, h('div', { style: S.bar }, h('span', { style: S.title }, t('事实'))), h(Empty, null, t('这个会话还没开始:面板的数据来自会话日志,发第一句话之后,这里会显示已确认的事实与正在流转的命题。')))
|
|
1027
|
+
}
|
|
1028
|
+
const data = { ...projected, openPreview: props.openPreview, openRail: props.openRail, openSpectator: props.openSpectator }
|
|
1029
|
+
const facts = data.facts ?? []
|
|
1030
|
+
const propositions = (data.goal?.hypotheses ?? []).filter((row) => row.status !== 'confirmed')
|
|
1031
|
+
/** 外面点进来的聚焦优先;手动点行仍然有效(聚焦为 null 时用它)。 */
|
|
1032
|
+
const focused = incoming === null || incoming === undefined ? null : propositionForStep(data, incoming.step)
|
|
1033
|
+
const open = focused ?? manual
|
|
1034
|
+
return h(
|
|
1035
|
+
'div',
|
|
1036
|
+
{ style: S.wrap },
|
|
1037
|
+
h(
|
|
1038
|
+
'div',
|
|
1039
|
+
{ style: S.bar },
|
|
1040
|
+
h('span', { style: S.title }, t('事实')),
|
|
1041
|
+
/**
|
|
1042
|
+
* 页眉只说这一格是干什么的:计数在下面两段的标题里(§30:同屏四个数字两两重复 ✗),
|
|
1043
|
+
* 「模型读的是同一张表」是实现保证、不是用户信息 ✗。
|
|
1044
|
+
*/
|
|
1045
|
+
h('span', { style: S.faint }, t('能用的在上面,正在验的在下面')),
|
|
1046
|
+
),
|
|
1047
|
+
h(FactShelf, { data }),
|
|
1048
|
+
h(PropositionShelf, {
|
|
1049
|
+
data,
|
|
1050
|
+
open,
|
|
1051
|
+
onToggle: (id) => {
|
|
1052
|
+
// 手动点行:先清掉外面的聚焦(否则它一直压着手动选择,点了没反应 ✗)
|
|
1053
|
+
factsFocus.set(null)
|
|
1054
|
+
setManual(open === id ? null : id)
|
|
1055
|
+
},
|
|
1056
|
+
}),
|
|
1057
|
+
)
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
/**
|
|
1061
|
+
* 「点开看」的目标:每个会话一份的小状态。
|
|
1062
|
+
*
|
|
1063
|
+
* 它只是**界面状态**(点了哪一条),不是事实——事实仍然只有投影与读面两个来源。
|
|
1064
|
+
* 预览页签**第一次用到时才注册**:宁可少一张页签,也不放一张空的在那里。
|
|
1065
|
+
*/
|
|
1066
|
+
/**
|
|
1067
|
+
* 打开 DSH **原生**的文档预览。
|
|
1068
|
+
*
|
|
1069
|
+
* 第一性原理:预览是「把一条路径渲染成人能看的东西」——而宿主已经有这件东西了,而且比我们全:
|
|
1070
|
+
* `dsh-client-ui-sidebar-documentpreview` 注册了 **6 个实现**(text / markdown / html / image /
|
|
1071
|
+
* pdf / code),自己按地址认领标签、自己按后缀挑实现、自己分页读盘。我们原来那套
|
|
1072
|
+
* `<pre>` + 自己写读面,等于**把同一件事做第二遍,而且只做出了其中一种**(把 md 显示成源码、
|
|
1073
|
+
* 根本打不开图)。
|
|
1074
|
+
*
|
|
1075
|
+
* 奥卡姆:不新造渲染器,也不新造读面——只按原生约定的地址把文件**递给**它:
|
|
1076
|
+
* `dsh-resource://file/session/<sessionId>/<逐段编码的路径>`
|
|
1077
|
+
* (地址语法与编码规则照抄 `workspace-path/file-address`:`/#?` 之前、按 `/` 分段、逐段解码。)
|
|
1078
|
+
* 路径是绝对路径时,分段里会自然出现一个空段,拼回来仍是绝对路径——原生就是这么分的。
|
|
1079
|
+
*
|
|
1080
|
+
* **第二参是会话 id,不是路径**(2026-09-11 实测踩过):读面按它查工作区根
|
|
1081
|
+
* (`workspaceFileScope`),拿错了就原样报
|
|
1082
|
+
* `lookup provider "workspaceFileScope" did not resolve the requested identity`。
|
|
1083
|
+
* 谁调用谁负责给对 —— 见 `apply` 里的 `openPreviewFor`,按**座位自己的** props 定型。
|
|
1084
|
+
*/
|
|
1085
|
+
/** 时间戳 → 「2026-09-11 00:30」。与宿主半卡片里的写法一致:同一份事实在两处长得不一样,人就会怀疑其中一处。 */
|
|
1086
|
+
const stampOf = (value) => (typeof value === 'number' && Number.isFinite(value) ? new Date(value).toISOString().slice(0, 16).replace('T', ' ') : t('未知'))
|
|
1087
|
+
|
|
1088
|
+
/** 逐段编码:照抄原生的 `encodeSegment`——`:` 保持字面(Windows 盘符),其余交给 encodeURIComponent。 */
|
|
1089
|
+
const encodeAddressSegment = (segment) => encodeURIComponent(segment).replace(/%3A/gi, ':')
|
|
1090
|
+
const fileAddress = (sessionId, path) => {
|
|
1091
|
+
// 归一化也照抄原生(`sessionFileAddress`):反斜杠→`/`,去掉开头的 `./`。
|
|
1092
|
+
const normalized = String(path).replace(/\\/g, '/').replace(/^(?:\.\/)+/, '')
|
|
1093
|
+
const segments = normalized.split('/').map(encodeAddressSegment)
|
|
1094
|
+
return `dsh-resource://file/session/${encodeAddressSegment(sessionId)}/${segments.join('/')}`
|
|
1095
|
+
}
|
|
1096
|
+
const openNativePreview = (sidebarRight, sessionId, path) => {
|
|
1097
|
+
if (typeof sessionId !== 'string' || sessionId === '' || typeof path !== 'string' || path === '') return false
|
|
1098
|
+
const address = fileAddress(sessionId, path)
|
|
1099
|
+
try {
|
|
1100
|
+
// 原生那两个入口:当前会话用 `openResource`,指定会话用 `openResourceIn`
|
|
1101
|
+
// (原生 chat 与 files 都是前者)。
|
|
1102
|
+
if (sidebarRight !== undefined && typeof sidebarRight.openResource === 'function') {
|
|
1103
|
+
sidebarRight.openResource(address)
|
|
1104
|
+
return true
|
|
1105
|
+
}
|
|
1106
|
+
if (sidebarRight !== undefined && typeof sidebarRight.openResourceIn === 'function') {
|
|
1107
|
+
sidebarRight.openResourceIn(sessionId, address)
|
|
1108
|
+
return true
|
|
1109
|
+
}
|
|
1110
|
+
} catch {
|
|
1111
|
+
return false
|
|
1112
|
+
}
|
|
1113
|
+
return false
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/**
|
|
1117
|
+
* 产物视图(中栏,UI 定案 §7:中栏第一眼该看「我拿到了什么」)。
|
|
1118
|
+
*
|
|
1119
|
+
* 数据走宿主读面(`/api/clearai/deliverables`):交付物的**存在性是文件系统事实**,
|
|
1120
|
+
* 浏览器读不了盘,所以由宿主在请求时现场 stat(顺带给出字节与修改时间)。
|
|
1121
|
+
* 投影一变(推进了一步、多了一条证据)就重新取一次——面板不许比状态慢一拍。
|
|
1122
|
+
*
|
|
1123
|
+
* 这条读面回**两半**,这一栏也画两半:
|
|
1124
|
+
* · `stages` = **声明**(计划里写了要交什么、谁验的、盘上在不在);
|
|
1125
|
+
* · `outputs` = **实际**(products/ 底下真有、但没有任何计划声明过的)。
|
|
1126
|
+
* 为什么两半都要(2026-09-11 用户实测):在一个已经有产物的老项目里开新会话,原来这一栏
|
|
1127
|
+
* 只有「阶段 0 · 交付 0/0」—— 可人此刻问的是「我拿到了什么」,盘上那份 46 KB 的 HTML
|
|
1128
|
+
* 明明就在。声明与实际是两件事,少画一半就等于对着半张表回答。
|
|
1129
|
+
*/
|
|
1130
|
+
function Deliverables(props) {
|
|
1131
|
+
const data = typeof props.useProjection === 'function' ? props.useProjection('clearai') : undefined
|
|
1132
|
+
const sessionId = data === null || data === undefined ? null : data.sessionId
|
|
1133
|
+
const [stages, setStages] = React.useState(null)
|
|
1134
|
+
const [onDisk, setOnDisk] = React.useState([])
|
|
1135
|
+
const [error, setError] = React.useState(null)
|
|
1136
|
+
const stamp = data === null || data === undefined ? '' : `${data.plan?.id ?? ''}:${data.plan?.advancedCount ?? 0}:${data.plan?.totalCount ?? 0}:${(data.evidence || []).length}:${(data.facts || []).length}`
|
|
1137
|
+
React.useEffect(() => {
|
|
1138
|
+
if (typeof sessionId !== 'string' || sessionId === '') return
|
|
1139
|
+
let alive = true
|
|
1140
|
+
fetch(`/api/clearai/deliverables?sessionId=${encodeURIComponent(sessionId)}`)
|
|
1141
|
+
.then(readResponse)
|
|
1142
|
+
.then((result) => {
|
|
1143
|
+
if (!alive) return
|
|
1144
|
+
if (result.ok === true) {
|
|
1145
|
+
setStages(result.payload.stages ?? [])
|
|
1146
|
+
setOnDisk(result.payload.outputs ?? [])
|
|
1147
|
+
} else setError(result.error)
|
|
1148
|
+
})
|
|
1149
|
+
.catch((thrown) => {
|
|
1150
|
+
if (alive) setError(String(thrown?.message ?? thrown))
|
|
1151
|
+
})
|
|
1152
|
+
return () => {
|
|
1153
|
+
alive = false
|
|
1154
|
+
}
|
|
1155
|
+
}, [sessionId, stamp])
|
|
1156
|
+
|
|
1157
|
+
const openStage = data === null || data === undefined ? null : data.plan
|
|
1158
|
+
// 点一条产物 → **原生预览**(md 渲染成 md、png 直接出图、html 直接看、pdf 用原生 pdf.js)
|
|
1159
|
+
const open = (path) => props.openPreview?.(path)
|
|
1160
|
+
if (sessionId === null) {
|
|
1161
|
+
return h('div', { style: S.wrap }, h('div', { style: S.bar }, h('span', { style: S.title }, t('产物'))), h(Empty, null, t('这个会话还没有 ClearAI 的状态。')))
|
|
1162
|
+
}
|
|
1163
|
+
const advanced = (stages ?? []).flatMap((stage) => stage.steps.filter((step) => step.status === 'advanced' && step.artifacts.length > 0))
|
|
1164
|
+
const delivered = advanced.reduce((sum, step) => sum + step.artifacts.filter((artifact) => artifact.exists).length, 0)
|
|
1165
|
+
const declared = (stages ?? []).flatMap((stage) => stage.steps).reduce((sum, step) => sum + step.artifacts.length, 0)
|
|
1166
|
+
/**
|
|
1167
|
+
* 「核心产物」:**盘上真的在**的输出成果(products/),跨阶段汇总在最上面。
|
|
1168
|
+
* 中间过程(lab/)不进这一栏——这正是 ClearAI 分类的用途:别让 100 个中间文件
|
|
1169
|
+
* 淹没 3 份交付物。
|
|
1170
|
+
*/
|
|
1171
|
+
/**
|
|
1172
|
+
* 「核心产物」= **一份文件一行**(§29)。
|
|
1173
|
+
*
|
|
1174
|
+
* 原先摊平的是「步骤 × 声明产物」这对组合 ⇒ 一个文件被几个步骤声明就出现几次 ✗
|
|
1175
|
+
* (真数据:`qinrun-dipforming-3d.html` 被 build/verify-3d/rebuild/integrate 四步都声明了,
|
|
1176
|
+
* 于是同一份 1153646 字节的文件列了四行,计数也跟着膨胀 ✗)。
|
|
1177
|
+
* 「后面几步也声明了它」是**有用的信息**(那几步在验它、整合它),所以去重时把它收进 `steps`,
|
|
1178
|
+
* 而不是丢掉。
|
|
1179
|
+
*/
|
|
1180
|
+
const byPath = new Map()
|
|
1181
|
+
for (const stage of stages ?? []) {
|
|
1182
|
+
for (const step of stage.steps) {
|
|
1183
|
+
for (const artifact of step.artifacts) {
|
|
1184
|
+
if (artifact.exists !== true) continue
|
|
1185
|
+
if (artifact.area?.key !== 'output' && artifact.area?.key !== 'other') continue
|
|
1186
|
+
const seen = byPath.get(artifact.path)
|
|
1187
|
+
if (seen === undefined) byPath.set(artifact.path, { ...artifact, stage: stage.plan, steps: [step.id] })
|
|
1188
|
+
else if (!seen.steps.includes(step.id)) seen.steps.push(step.id)
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
const coreOutputs = [...byPath.values()]
|
|
1193
|
+
/** 盘上已有的:不铺满一屏(它只是「第一眼」),但如实说还有多少没列。 */
|
|
1194
|
+
const ON_DISK_SHOWN = 25
|
|
1195
|
+
const shownOnDisk = onDisk.slice(0, ON_DISK_SHOWN)
|
|
1196
|
+
const hiddenOnDisk = onDisk.length - shownOnDisk.length
|
|
1197
|
+
const clickable = coreOutputs.length + onDisk.length
|
|
1198
|
+
return h(
|
|
1199
|
+
'div',
|
|
1200
|
+
{ style: S.wrap },
|
|
1201
|
+
h(
|
|
1202
|
+
'div',
|
|
1203
|
+
{ style: S.bar },
|
|
1204
|
+
h('span', { style: S.title }, t('产物')),
|
|
1205
|
+
/**
|
|
1206
|
+
* 两个计数的**单位不同**,所以分开说:「处」= 步骤声明(一份文件被四步声明算四处),
|
|
1207
|
+
* 「份」= 去重后的文件。混着写会让人以为盘上有四份。
|
|
1208
|
+
*/
|
|
1209
|
+
h('span', { style: S.faint }, `${t('阶段 ')}${(stages ?? []).length}${t(' · 交付 ')}${delivered}/${declared}${t(' 处声明')}${onDisk.length === 0 ? '' : ` · 盘上 ${onDisk.length} 份`}`),
|
|
1210
|
+
clickable === 0 ? null : h('span', { style: { ...S.faint, marginLeft: 'auto' } }, t('点一条 → 右栏预览')),
|
|
1211
|
+
),
|
|
1212
|
+
error !== null ? h('div', { style: S.faint }, `${t('读不到交付物:')}${error}`) : null,
|
|
1213
|
+
coreOutputs.length === 0
|
|
1214
|
+
? null
|
|
1215
|
+
: h(
|
|
1216
|
+
'div',
|
|
1217
|
+
{ style: S.section },
|
|
1218
|
+
h('div', { style: S.head }, `${t('核心产物(')}${coreOutputs.length})`),
|
|
1219
|
+
coreOutputs.map((artifact) =>
|
|
1220
|
+
h(
|
|
1221
|
+
'div',
|
|
1222
|
+
{ key: artifact.path, style: S.inline },
|
|
1223
|
+
h('span', { style: S.tagStrong }, artifact.area?.label ?? t('未分类')),
|
|
1224
|
+
h(Link, { onClick: () => open(artifact.path) }, h('span', { style: S.mono }, artifact.path)),
|
|
1225
|
+
h(
|
|
1226
|
+
'span',
|
|
1227
|
+
{ style: S.faint, title: artifact.steps.join(' · ') },
|
|
1228
|
+
`${artifact.bytes}${t(' 字节 · ')}${artifact.steps.length === 1 ? `步 ${artifact.steps[0]}` : `步 ${artifact.steps[0]} 等 ${artifact.steps.length} 步`}`,
|
|
1229
|
+
),
|
|
1230
|
+
),
|
|
1231
|
+
),
|
|
1232
|
+
),
|
|
1233
|
+
/**
|
|
1234
|
+
* 「盘上已有」= 实际那一半。它**不进**交付计数(计数只认计划声明),
|
|
1235
|
+
* 所以每条都带上这句交代,免得人以为计划多交了几份。
|
|
1236
|
+
*/
|
|
1237
|
+
onDisk.length === 0
|
|
1238
|
+
? null
|
|
1239
|
+
: h(
|
|
1240
|
+
'div',
|
|
1241
|
+
{ style: S.section },
|
|
1242
|
+
h('div', { style: S.head }, `${t('盘上已有(')}${onDisk.length})`),
|
|
1243
|
+
...shownOnDisk.map((item) =>
|
|
1244
|
+
h(
|
|
1245
|
+
'div',
|
|
1246
|
+
{ key: item.path, style: S.inline },
|
|
1247
|
+
h('span', { style: S.tag }, item.area?.label ?? t('未分类')),
|
|
1248
|
+
h(Link, { onClick: () => open(item.path) }, h('span', { style: S.mono }, item.path)),
|
|
1249
|
+
h('span', { style: S.faint }, `${item.bytes}${t(' 字节 · 未被任何计划声明')}`),
|
|
1250
|
+
),
|
|
1251
|
+
),
|
|
1252
|
+
hiddenOnDisk <= 0 ? null : h('div', { style: S.faint }, `${t('还有 ')}${hiddenOnDisk}${t(' 份没列出来(这一栏只列最近改动的 ')}${ON_DISK_SHOWN}${t(' 份)。')}`),
|
|
1253
|
+
),
|
|
1254
|
+
stages === null
|
|
1255
|
+
? h('div', { style: S.faint }, t('读取中…'))
|
|
1256
|
+
: stages.length === 0
|
|
1257
|
+
? h(
|
|
1258
|
+
Empty,
|
|
1259
|
+
null,
|
|
1260
|
+
onDisk.length === 0
|
|
1261
|
+
? t('还没有计划,所以还没有产物。立目标、建计划,交付过的每一步都会在这里按阶段排开。')
|
|
1262
|
+
: `${t('这个工作区盘上已经有 ')}${onDisk.length}${t(' 份产物,但没有任何计划声明过它们。点一条可以直接看(它们不计入交付)。')}`,
|
|
1263
|
+
)
|
|
1264
|
+
: stages.map((stage) =>
|
|
1265
|
+
h(
|
|
1266
|
+
'div',
|
|
1267
|
+
{ key: stage.plan, style: S.section },
|
|
1268
|
+
h(
|
|
1269
|
+
'div',
|
|
1270
|
+
{ style: S.head },
|
|
1271
|
+
// 「待确认」改成「授权记号未落账」:前者暗示有一道要人按的门,而门已经砍了——
|
|
1272
|
+
// 记号会在第一次交付时按事实补写(行为即授权)。
|
|
1273
|
+
`${openStage !== null && openStage.id === stage.plan ? '当前阶段 · ' : ''}${stage.plan} · ${dash(stage.status)}${stage.confirmedAt === null ? ' · 授权记号未落账(交付即落账)' : ''}`,
|
|
1274
|
+
),
|
|
1275
|
+
stage.brief === '' ? null : h('div', { style: S.faint }, stage.brief),
|
|
1276
|
+
stage.summary === null || stage.summary === undefined ? null : h('div', { style: S.faint }, `${t('收束:')}${stage.summary}`),
|
|
1277
|
+
stage.steps.map((step) =>
|
|
1278
|
+
h(
|
|
1279
|
+
'div',
|
|
1280
|
+
{ key: step.id, style: S.row },
|
|
1281
|
+
h(
|
|
1282
|
+
'div',
|
|
1283
|
+
{ style: S.inline },
|
|
1284
|
+
h('span', { style: step.status === 'advanced' ? S.tagStrong : S.tag }, gloss(STEP, step.status)),
|
|
1285
|
+
h('span', { style: S.mono }, `${step.ordinal}. ${step.id}`),
|
|
1286
|
+
step.level === null ? null : h('span', { style: S.faint }, step.level),
|
|
1287
|
+
h('span', null, step.do),
|
|
1288
|
+
),
|
|
1289
|
+
h('div', { style: S.faint }, `${t('判据:')}${step.doneCriteria}${step.voidReason === null || step.voidReason === undefined ? '' : `(作废:${step.voidReason})`}`),
|
|
1290
|
+
step.artifacts.length === 0
|
|
1291
|
+
? h('div', { style: S.faint }, t('这一步没有声明产物。'))
|
|
1292
|
+
: h(
|
|
1293
|
+
'div',
|
|
1294
|
+
null,
|
|
1295
|
+
step.artifacts.map((artifact) =>
|
|
1296
|
+
h(
|
|
1297
|
+
'div',
|
|
1298
|
+
{ key: artifact.path, style: S.inline },
|
|
1299
|
+
h('span', { style: artifact.exists ? S.tagStrong : S.tag }, artifact.exists ? t('在') : artifact.directory === true ? t('目录') : t('缺')),
|
|
1300
|
+
h('span', { style: S.faint }, artifact.area?.label ?? t('未分类')),
|
|
1301
|
+
/**
|
|
1302
|
+
* 判据为「缺」的那条**不是链接**:点进去只会撞一个不存在的路径
|
|
1303
|
+
* (原生预览会照实报错)。机制上不可点,胜过在文案里劝人别点。
|
|
1304
|
+
* 它带来的信息仍然齐全:路径、分类、「盘上没有」。
|
|
1305
|
+
*/
|
|
1306
|
+
artifact.exists
|
|
1307
|
+
? h('span', { className: 'clearai-link', onClick: () => open(artifact.path), title: t('在右栏预览') }, artifact.path)
|
|
1308
|
+
: h(
|
|
1309
|
+
'span',
|
|
1310
|
+
{ style: S.mono, title: artifact.directory === true ? t('声明的是目录;准入要求具体文件——目录不算物证') : t('盘上没有这个文件') },
|
|
1311
|
+
artifact.path,
|
|
1312
|
+
),
|
|
1313
|
+
artifact.exists
|
|
1314
|
+
? h('span', { style: S.faint }, `${artifact.bytes}${t(' 字节')}`)
|
|
1315
|
+
: h('span', { style: S.faint }, artifact.directory === true ? t('是目录(不算物证)') : t('盘上没有')),
|
|
1316
|
+
),
|
|
1317
|
+
),
|
|
1318
|
+
),
|
|
1319
|
+
),
|
|
1320
|
+
),
|
|
1321
|
+
),
|
|
1322
|
+
),
|
|
1323
|
+
)
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
/**
|
|
1327
|
+
* 世界树(右栏):计划即世界树——脊柱上一串步,到 fork 就地裂成并行车道,
|
|
1328
|
+
* 收敛画成菱形,再回到同一条脊柱(git-graph 式)。
|
|
1329
|
+
*
|
|
1330
|
+
* 几何与**四条正交通道**照抄 ClearAI `PlanTree.tsx`(`ROW_H=30 / LANE_W=15 / NODE_R=6`):
|
|
1331
|
+
* 行高固定 → 拓扑常驻不变;车道不移位 → 叉开与收敛看得清。四条通道各答一个问题、可任意叠加:
|
|
1332
|
+
*
|
|
1333
|
+
* ① **填充** 已落定终局了吗 实心 = 是(于是「实心到哪儿就是做到哪儿」,进度成了一眼可读的形)
|
|
1334
|
+
* ② **颜色** 什么结局 绿=过/采纳 灰=待办/落选 蓝=在跑 琥珀=等你
|
|
1335
|
+
* ③ **光环** 此刻在动吗 呼吸 = 是(步骤与世界线共用同一惯用法)
|
|
1336
|
+
* ④ **分段** 被闸门裁断过几次 N>1 才分段,封顶 3 段,驳回的那几段画红
|
|
1337
|
+
*
|
|
1338
|
+
* 2026-09-11 用户指出这一处没移植好(分岔/推进/闪烁/选择/裁减/变灰都丢了)。补回来的:
|
|
1339
|
+
* 空心圈与实心核、呼吸光环与虚线审计环、轮次分段弧、落选/作废的**变灰划掉**、
|
|
1340
|
+
* 虚线轨道(落选那条)、点行选中 → 树下详情(含给人的两个动作:采纳这条世界线 / 放弃探索)。
|
|
1341
|
+
*
|
|
1342
|
+
* 第一性原理:一棵树好不好读,取决于**每一条通道只答一个问题**。把「在动」「落定」
|
|
1343
|
+
* 「结局」「几轮」挤进同一个实心点,读者就得靠记住上一次的颜色差——那不是可视化,是暗号。
|
|
1344
|
+
*
|
|
1345
|
+
* 奥卡姆:不画的东西也写清楚为什么——越位内环(我们的投影里没有「越位」这条事实)、
|
|
1346
|
+
* run 级状态色(同上)、任务选择器与结算单(进展页签已有),都不引入。
|
|
1347
|
+
*/
|
|
1348
|
+
const TREE = {
|
|
1349
|
+
rowHeight: 30,
|
|
1350
|
+
laneWidth: 15,
|
|
1351
|
+
padX: 10,
|
|
1352
|
+
radius: 6,
|
|
1353
|
+
arcWidth: 1.5,
|
|
1354
|
+
arcGap: 2,
|
|
1355
|
+
maxArcs: 3,
|
|
1356
|
+
halo: 9,
|
|
1357
|
+
}
|
|
1358
|
+
const TREE_COLOR = {
|
|
1359
|
+
done: 'rgb(var(--dsw-color-success, 16 185 129))',
|
|
1360
|
+
running: '#7dd3fc',
|
|
1361
|
+
pending: '#cbd5e1',
|
|
1362
|
+
adopted: 'rgb(var(--dsw-color-success, 16 185 129))',
|
|
1363
|
+
recommend: 'rgb(var(--dsw-color-warning, 245 158 11))',
|
|
1364
|
+
evaluated: '#64748b',
|
|
1365
|
+
exploring: '#94a3b8',
|
|
1366
|
+
pruned: '#cbd5e1',
|
|
1367
|
+
/** 执行没跑成:低饱和红(照抄原设计的 branchFailed)。它不是落选,所以不能与灰混用。 */
|
|
1368
|
+
failed: '#c98f8f',
|
|
1369
|
+
/** 落选/作废那条轨道画虚线(3 3),与实线轨道分开。 */
|
|
1370
|
+
rail: 'hsla(220, 9%, 46%, 0.35)',
|
|
1371
|
+
}
|
|
1372
|
+
/** 一步/一条车道**被闸门裁断过几次**(`block/counted` 折出来的驳回次数 + 通过的那一次)。 */
|
|
1373
|
+
function treeLoops(blocks, planId, stepId, advanced) {
|
|
1374
|
+
const counted = Number(blocks?.[planId]?.[stepId] ?? 0)
|
|
1375
|
+
const fails = Number.isFinite(counted) && counted > 0 ? counted : 0
|
|
1376
|
+
return { fails, rounds: fails + (advanced ? 1 : 0) }
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
/**
|
|
1380
|
+
* 把「计划 + 世界线」摊成带车道的行:脊柱 + fork 车道 + 收敛。纯函数,便于断言拓扑。
|
|
1381
|
+
*
|
|
1382
|
+
* 放弃过的分叉**画一行**(不铺车道):它是留档的事实,不是一条还在走的路;
|
|
1383
|
+
* 原设计里那棵树也把它留着——「什么都不删」在图上就是「还在,但灰了」。
|
|
1384
|
+
*/
|
|
1385
|
+
/**
|
|
1386
|
+
* **对外聚焦通道**(§27d):别的面点一个步骤/世界线,世界树要能**选中那一行**。
|
|
1387
|
+
*
|
|
1388
|
+
* 为什么需要它:树原先只有**内部**选中状态(点行看详情),外面够不着 ⇒
|
|
1389
|
+
* 「在世界树里看这一步」只能把树打开,不定位 ✗ —— 这是"点击跳转闭环"里缺的那一环。
|
|
1390
|
+
*
|
|
1391
|
+
* 只做两件最小的事:
|
|
1392
|
+
* · 一个**纯函数** `rowIndexOf(rows, target)`:行身份 = `step.id`(+ 有分支时 `branch.id`),
|
|
1393
|
+
* 返回行下标或 null(可单测 ✓);
|
|
1394
|
+
* · 一个**订阅式小 store**:聚焦是**界面状态**,不是事实 —— 不写任何变更、不进账本 ✓。
|
|
1395
|
+
*/
|
|
1396
|
+
function rowIndexOf(rows, target) {
|
|
1397
|
+
if (target === null || target === undefined || typeof target.step !== 'string' || target.step === '') return null
|
|
1398
|
+
const sameStep = rows.map((row, index) => ({ row, index })).filter((item) => item.row?.step?.id === target.step)
|
|
1399
|
+
if (sameStep.length === 0) return null
|
|
1400
|
+
if (typeof target.branch === 'string' && target.branch !== '') {
|
|
1401
|
+
const branch = sameStep.find((item) => item.row?.branch?.id === target.branch)
|
|
1402
|
+
return branch === undefined ? null : branch.index
|
|
1403
|
+
}
|
|
1404
|
+
// 没指定分支:优先"代表这一步"的那一行(step / fork / abandoned),否则退到第一行
|
|
1405
|
+
const head = sameStep.find((item) => item.row?.kind === 'step' || item.row?.kind === 'fork' || item.row?.kind === 'abandoned')
|
|
1406
|
+
return (head ?? sameStep[0]).index
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
/**
|
|
1410
|
+
* 聚焦通道的**统一形状**:一个值 + 一组订阅者。
|
|
1411
|
+
* 它是**界面状态**(点了哪一行),不是事实 —— 不写变更、不进账本。
|
|
1412
|
+
*/
|
|
1413
|
+
function makeFocusStore() {
|
|
1414
|
+
const store = {
|
|
1415
|
+
value: null,
|
|
1416
|
+
listeners: new Set(),
|
|
1417
|
+
set(target) {
|
|
1418
|
+
store.value = target
|
|
1419
|
+
for (const listener of store.listeners) listener(target)
|
|
1420
|
+
},
|
|
1421
|
+
subscribe(listener) {
|
|
1422
|
+
store.listeners.add(listener)
|
|
1423
|
+
return () => store.listeners.delete(listener)
|
|
1424
|
+
},
|
|
1425
|
+
}
|
|
1426
|
+
return store
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
/** 聚焦变了就重渲染的读法(两个面各一份)。 */
|
|
1430
|
+
function useFocus(store) {
|
|
1431
|
+
const [value, setValue] = React.useState(store.value)
|
|
1432
|
+
React.useEffect(() => store.subscribe(setValue), [])
|
|
1433
|
+
return value
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
const treeFocus = makeFocusStore()
|
|
1437
|
+
/**
|
|
1438
|
+
* **反向聚焦**(§27e):从世界树的某一步跳回「事实」那一格,并**展开对应的命题**。
|
|
1439
|
+
* 与 treeFocus 同一个形状、同一条纪律(界面状态,不进账本)。
|
|
1440
|
+
*/
|
|
1441
|
+
const factsFocus = makeFocusStore()
|
|
1442
|
+
|
|
1443
|
+
/**
|
|
1444
|
+
* 哪条命题的证据来自这一步(纯函数:可单测)。
|
|
1445
|
+
*
|
|
1446
|
+
* 唯一的关系就是**步骤上登记的 `tests.hypothesis`** —— 不猜:这一步没登记命题
|
|
1447
|
+
* (自判的 L0 步骤常常没有)就返回 `null`,界面因此**不乱展开**任何一条命题 ✓。
|
|
1448
|
+
* (第一版这里写过一个"退一步看证据挂谁"的回退,而它又去查同一个索引 ⇒ 循环 ✗,已删。)
|
|
1449
|
+
*/
|
|
1450
|
+
function propositionForStep(data, stepId) {
|
|
1451
|
+
if (typeof stepId !== 'string' || stepId === '') return null
|
|
1452
|
+
const hypothesis = (data?.stepIndex ?? {})[stepId]?.tests?.hypothesis
|
|
1453
|
+
return typeof hypothesis === 'string' && hypothesis !== '' ? hypothesis : null
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
function treeRows(plan, forks) {
|
|
1457
|
+
const rows = []
|
|
1458
|
+
for (const step of plan?.steps ?? []) {
|
|
1459
|
+
const fork = (forks ?? []).find((item) => item.stepId === step.id)
|
|
1460
|
+
if (fork === undefined) {
|
|
1461
|
+
rows.push({ kind: 'step', lane: 0, step })
|
|
1462
|
+
continue
|
|
1463
|
+
}
|
|
1464
|
+
// 放弃过的分叉画成**一行灰掉的留档**(不是消失:什么都不删在图上就是「还在,但灰了」)。
|
|
1465
|
+
// 判据是 fold 派生的 `phase === 'abandoned'`(它同时带 abandonReason)。
|
|
1466
|
+
if (fork.phase === 'abandoned') {
|
|
1467
|
+
rows.push({ kind: 'abandoned', lane: 0, step, fork })
|
|
1468
|
+
continue
|
|
1469
|
+
}
|
|
1470
|
+
rows.push({ kind: 'fork', lane: 0, step, fork })
|
|
1471
|
+
// 车道从 **1** 起(照抄原型):第 0 道是脊柱——第一条车道要是压在脊柱上,
|
|
1472
|
+
// 扇出去的四条线会缩成一条往下掉的蛇。
|
|
1473
|
+
fork.branches.forEach((branch, index) => {
|
|
1474
|
+
rows.push({ kind: 'branch', lane: 1 + index, step, fork, branch })
|
|
1475
|
+
})
|
|
1476
|
+
rows.push({ kind: 'converge', lane: 0, step, fork })
|
|
1477
|
+
}
|
|
1478
|
+
return rows
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
/**
|
|
1482
|
+
* 行与行之间的**连接**(轨道):不是「相邻两行连一条」,而是显式的扇出/扇入。
|
|
1483
|
+
*
|
|
1484
|
+
* 2026-09-11 用户看图指出来的问题:上一版按相邻行连线,于是「脊柱裂成四条并行车道」
|
|
1485
|
+
* 被画成了一条往右下掉的**蛇**——分叉点只连到第一条车道,第二条连第三条……
|
|
1486
|
+
* 那不是分叉,是排队。原设计用的是显式连接表(fork → 每条车道、每条车道 → 收敛菱形),
|
|
1487
|
+
* 所以四条车道是从同一个点扇出去的。这里照它重写。
|
|
1488
|
+
*/
|
|
1489
|
+
function treeConns(rows) {
|
|
1490
|
+
const conns = []
|
|
1491
|
+
let prevMain = null
|
|
1492
|
+
let forkRow = null
|
|
1493
|
+
let branchRows = []
|
|
1494
|
+
rows.forEach((row, index) => {
|
|
1495
|
+
if (row.kind === 'step' || row.kind === 'abandoned' || row.kind === 'fork') {
|
|
1496
|
+
// 脊柱(含放弃的留档行)接上一段主线
|
|
1497
|
+
if (prevMain !== null) conns.push({ from: prevMain, fromLane: 0, to: index, toLane: 0 })
|
|
1498
|
+
prevMain = index
|
|
1499
|
+
if (row.kind === 'fork') {
|
|
1500
|
+
forkRow = index
|
|
1501
|
+
branchRows = []
|
|
1502
|
+
}
|
|
1503
|
+
return
|
|
1504
|
+
}
|
|
1505
|
+
if (row.kind === 'branch') {
|
|
1506
|
+
// 扇出:从**分叉点**连到这一条车道(不是从上一条车道连过来)
|
|
1507
|
+
if (forkRow !== null) conns.push({ from: forkRow, fromLane: 0, to: index, toLane: row.lane })
|
|
1508
|
+
branchRows.push({ idx: index, lane: row.lane })
|
|
1509
|
+
return
|
|
1510
|
+
}
|
|
1511
|
+
// 收敛:每条车道各收一条线进来,然后主线继续
|
|
1512
|
+
for (const branch of branchRows) conns.push({ from: branch.idx, fromLane: branch.lane, to: index, toLane: 0 })
|
|
1513
|
+
branchRows = []
|
|
1514
|
+
forkRow = null
|
|
1515
|
+
prevMain = index
|
|
1516
|
+
})
|
|
1517
|
+
return conns
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
/**
|
|
1521
|
+
* 轨道该在节点**边缘**收住,不进节点内部(照抄原型):圆形让 NODE_R + 描边,
|
|
1522
|
+
* 收敛菱形按对角让 NODE_R·√2(它是旋转 45° 的方,竖直方向的顶点比半径远)。
|
|
1523
|
+
* 两端切线都是竖直的,所以直接在 y 上让开即可,不必求切向。
|
|
1524
|
+
*/
|
|
1525
|
+
const railClear = (row) => (row?.kind === 'converge' ? TREE.radius * Math.SQRT2 + 1 : TREE.radius + 1.5)
|
|
1526
|
+
|
|
1527
|
+
/**
|
|
1528
|
+
* 一条连接画成什么路径:同车道 = 直线;跨车道 = 三次贝塞尔(控制点与端点同 x,
|
|
1529
|
+
* 所以两端仍是竖直的,与节点的净空对得上)。
|
|
1530
|
+
*/
|
|
1531
|
+
function railPath(conn, rows) {
|
|
1532
|
+
const x1 = TREE.padX + conn.fromLane * TREE.laneWidth
|
|
1533
|
+
const x2 = TREE.padX + conn.toLane * TREE.laneWidth
|
|
1534
|
+
const y1 = conn.from * TREE.rowHeight + TREE.rowHeight / 2 + 4 + railClear(rows[conn.from])
|
|
1535
|
+
const y2 = conn.to * TREE.rowHeight + TREE.rowHeight / 2 + 4 - railClear(rows[conn.to])
|
|
1536
|
+
if (y2 <= y1) return '' // 两行挨太近,净空吃掉了整段:不画残线
|
|
1537
|
+
if (x1 === x2) return `M${x1} ${y1} L${x2} ${y2}`
|
|
1538
|
+
const my = (y1 + y2) / 2
|
|
1539
|
+
return `M${x1} ${y1} C${x1} ${my}, ${x2} ${my}, ${x2} ${y2}`
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
/**
|
|
1543
|
+
* 四条通道的**唯一来源**:给一行,算出它该怎么画。
|
|
1544
|
+
*
|
|
1545
|
+
* 为什么收成一个纯函数:原设计的教训(它自己的注释里写着)——分段节点曾经走独立的
|
|
1546
|
+
* early-return 分支,于是那个分支悄悄漏掉了活性光环,「一个正在跑的步骤只要磨过轮次
|
|
1547
|
+
* 就再也不显示它在动」。通道一多,漏项就难发现;所以**一条代码路径**算完再叠加。
|
|
1548
|
+
*/
|
|
1549
|
+
function treeMarks(row, context) {
|
|
1550
|
+
const blocks = context?.blocks ?? null
|
|
1551
|
+
const audits = context?.audits ?? []
|
|
1552
|
+
const planId = context?.planId ?? null
|
|
1553
|
+
const stepId = row.step?.id ?? null
|
|
1554
|
+
const advanced = row.kind === 'branch' ? row.branch.status === 'adopted' : row.step?.status === 'advanced'
|
|
1555
|
+
const loops = treeLoops(blocks, planId, stepId, advanced)
|
|
1556
|
+
// ① 填充:落定终局了吗(与原设计同一条既有谓词:adopted|pruned 已是终局,advanced|void 同理)
|
|
1557
|
+
const settled =
|
|
1558
|
+
row.kind === 'branch'
|
|
1559
|
+
? row.branch.status === 'adopted' || row.branch.status === 'pruned'
|
|
1560
|
+
: row.kind === 'converge'
|
|
1561
|
+
? row.fork.phase === 'settled'
|
|
1562
|
+
: row.step.status === 'advanced' || row.step.status === 'void'
|
|
1563
|
+
// ③ 光环:此刻在动吗——两种事实,各自一条规则,都不猜
|
|
1564
|
+
// · 虚线环 = 有 Evaluator 正在审这一步(audits 里 verdict 还没回来)
|
|
1565
|
+
// · 实心呼吸 = 这条世界线还在探索(原设计的 run 级状态我们这侧没有,所以只画我们真有的)
|
|
1566
|
+
const auditing = row.kind !== 'branch' && audits.some((audit) => audit.stepId === stepId && (audit.verdict === null || audit.verdict === undefined))
|
|
1567
|
+
/**
|
|
1568
|
+
* 光环 = 「此刻在动吗」。两个**必须**排除的假话(2026-09-11 长测抓到的):
|
|
1569
|
+
* · 步骤已经作废/交付了,它上面那条车道不可能还在跑(分叉没跟着收口时会留下这种孤儿);
|
|
1570
|
+
* · 分叉自己已经放弃了(它下面没有「在动」的东西)。
|
|
1571
|
+
* 状态机那一侧该不该让分叉跟着步骤一起收口,是另一件事(记在 recon 里待定);
|
|
1572
|
+
* 但**界面不许说假话**这一条不依赖那个决定。
|
|
1573
|
+
*/
|
|
1574
|
+
const ownerAlive = row.step?.status !== 'void' && row.step?.status !== 'advanced'
|
|
1575
|
+
// 失败的世界线不再「在动」:它死了,不是还在跑(2026-09-11 补的派生状态)。
|
|
1576
|
+
const dead = row.kind === 'branch' && row.branch.failed === true
|
|
1577
|
+
/**
|
|
1578
|
+
* 「执行者未归」也不在动:分叉已经收口(收敛或放弃),派出去的执行者**再回来也没有归宿**。
|
|
1579
|
+
* 它既不是失败(没人知道它跑成没跑成),也不是在跑(「结论会自动回灌」那句承诺实现不了)——
|
|
1580
|
+
* 所以它是第三个静止态:不判成败、不呼吸(2026-09-11 长测抓到的第二句假话)。
|
|
1581
|
+
*/
|
|
1582
|
+
const unreturned = row.kind === 'branch' && row.branch.unreturned === true
|
|
1583
|
+
const live = row.kind === 'branch' && row.branch.status === 'exploring' && ownerAlive && !dead && !unreturned
|
|
1584
|
+
// ② 颜色:什么结局
|
|
1585
|
+
const color =
|
|
1586
|
+
row.kind === 'branch'
|
|
1587
|
+
? row.branch.failed === true
|
|
1588
|
+
? TREE_COLOR.failed
|
|
1589
|
+
: row.branch.status === 'adopted'
|
|
1590
|
+
? TREE_COLOR.adopted
|
|
1591
|
+
: row.branch.status === 'pruned'
|
|
1592
|
+
? TREE_COLOR.pruned
|
|
1593
|
+
: row.branch.status === 'evaluated'
|
|
1594
|
+
? TREE_COLOR.evaluated
|
|
1595
|
+
: row.fork.recommended === row.branch.id && row.fork.phase !== 'settled'
|
|
1596
|
+
? TREE_COLOR.recommend
|
|
1597
|
+
: TREE_COLOR.exploring
|
|
1598
|
+
: row.kind === 'converge'
|
|
1599
|
+
? row.fork.phase === 'settled'
|
|
1600
|
+
? TREE_COLOR.adopted
|
|
1601
|
+
: row.fork.phase === 'deciding'
|
|
1602
|
+
? TREE_COLOR.recommend
|
|
1603
|
+
: TREE_COLOR.exploring
|
|
1604
|
+
: row.kind === 'abandoned'
|
|
1605
|
+
? TREE_COLOR.pruned
|
|
1606
|
+
: row.step.status === 'advanced'
|
|
1607
|
+
? TREE_COLOR.done
|
|
1608
|
+
: row.step.status === 'void'
|
|
1609
|
+
? TREE_COLOR.pruned
|
|
1610
|
+
: TREE_COLOR.pending
|
|
1611
|
+
// 变灰划掉:作废的步、落选的车道、放弃的分叉(单增:留档不删)
|
|
1612
|
+
const greyed =
|
|
1613
|
+
(row.kind === 'step' && row.step.status === 'void') ||
|
|
1614
|
+
row.kind === 'abandoned' ||
|
|
1615
|
+
(row.kind === 'branch' && (row.branch.status === 'pruned' || row.branch.failed === true || row.branch.orphaned === true))
|
|
1616
|
+
const done = row.kind === 'step' && row.step.status === 'advanced'
|
|
1617
|
+
return { settled, color, live, auditing, greyed, done, loops, ...loops }
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
/**
|
|
1621
|
+
* 圆环上第 `i`/`total` 段弧的 dash 参数(照抄原设计):用 dasharray 而不是手写
|
|
1622
|
+
* `<path d="M… A…">`——同一个圆复制 N 份、各自偏移一格,每份只显出一段,
|
|
1623
|
+
* 于是每段可独立着色,而且**不必算三角函数**。
|
|
1624
|
+
*/
|
|
1625
|
+
function arcDash(index, total, r = TREE.radius) {
|
|
1626
|
+
const circumference = 2 * Math.PI * r
|
|
1627
|
+
const slot = circumference / Math.max(1, total)
|
|
1628
|
+
const gap = total <= 1 ? 0 : Math.min(TREE.arcGap, slot * 0.5)
|
|
1629
|
+
return { strokeDasharray: `${slot - gap} ${circumference - slot + gap}`, strokeDashoffset: -(index * slot) }
|
|
1630
|
+
}
|
|
1631
|
+
/** 要画的弧:只取闸门轮次,封顶 3 段(超出的轮数由行右的 `∞N` 徽标承载)。 */
|
|
1632
|
+
function treeArcs(marks) {
|
|
1633
|
+
// 无事发生(一次就过)= 不画弧:节点保持一个干净的圆点。
|
|
1634
|
+
if (marks.fails === 0 && marks.rounds <= 1) return []
|
|
1635
|
+
const shown = Math.min(TREE.maxArcs, marks.rounds)
|
|
1636
|
+
/**
|
|
1637
|
+
* 弧要画**最近**的几轮(越靠近现在越有解释力),而驳回总是发生在前几轮——
|
|
1638
|
+
* 所以「这一弧是不是驳回」要按**绝对轮次**算:`start + index < fails`。
|
|
1639
|
+
* 按相对位置算会把「驳回 3 次后终于过了」画成三个红弧,而那一步其实已经通过了。
|
|
1640
|
+
*/
|
|
1641
|
+
const start = marks.rounds - shown
|
|
1642
|
+
return Array.from({ length: shown }, (_, index) => ({ key: `arc-${index}`, index, fail: start + index < marks.fails }))
|
|
1643
|
+
}
|
|
1644
|
+
/** 世界树页签。选中一行 → 树下详情(结构在上、细节在下,树本身不动)。 */
|
|
1645
|
+
function WorldTree(props) {
|
|
1646
|
+
const sessions = props.useSessions
|
|
1647
|
+
const sessionId = props.sessionId
|
|
1648
|
+
const data =
|
|
1649
|
+
typeof sessions === 'function' && typeof sessionId === 'string'
|
|
1650
|
+
? sessions((snapshot) => snapshot?.byId?.[sessionId]?.projectionValues?.clearai)
|
|
1651
|
+
: undefined
|
|
1652
|
+
const [manual, setManual] = React.useState(null)
|
|
1653
|
+
const [picked, setPicked] = React.useState(null)
|
|
1654
|
+
const focus = useFocus(treeFocus)
|
|
1655
|
+
/**
|
|
1656
|
+
* **一个对话会有多个世界树**(§32):活动的那份默认显示,已收尾的可以切回去看
|
|
1657
|
+
* (它们没被删:文档归档在 `clear/goals/plans/<id>.md`)。
|
|
1658
|
+
* 优先级:外面点进来的聚焦所指的那份 > 手动切的 > 活动的。
|
|
1659
|
+
*/
|
|
1660
|
+
const plans = data === null || data === undefined || !Array.isArray(data.plans) ? [] : data.plans
|
|
1661
|
+
const wanted = (focus !== null && focus !== undefined && typeof focus.plan === 'string' ? focus.plan : null) ?? picked
|
|
1662
|
+
const chosen = wanted === null ? null : plans.find((item) => item.id === wanted) ?? null
|
|
1663
|
+
const plan = chosen ?? (data === null || data === undefined ? null : data.plan)
|
|
1664
|
+
if (plan === null || plan === undefined) {
|
|
1665
|
+
return h('div', { style: S.wrap }, h('div', { style: S.bar }, h('span', { style: S.title }, t('世界树'))), h(Empty, null, t('还没有计划。计划立起来之后,这里画的是它的拓扑:脊柱上的步、叉开的车道、收在哪。')))
|
|
1666
|
+
}
|
|
1667
|
+
const rows = treeRows(plan, data.forks)
|
|
1668
|
+
/** 页眉那一句:计划的首个非空行,过长再截(整篇在 tooltip 与「计划文档」里)。 */
|
|
1669
|
+
const briefLine = String(plan.brief ?? '').split('\n').map((line) => line.trim()).find((line) => line !== '' && !line.startsWith('#')) ?? ''
|
|
1670
|
+
const briefText = String(plan.brief ?? '').trim()
|
|
1671
|
+
const titleText = briefLine === '' ? plan.id : `${brief(briefLine, 44)} · ${plan.id}`
|
|
1672
|
+
/** 外面点进来的聚焦优先;手动点行仍然有效(聚焦为 null 时用它)。 */
|
|
1673
|
+
const focused = rowIndexOf(rows, focus)
|
|
1674
|
+
const selected = focused === null ? manual : focused
|
|
1675
|
+
const context = { blocks: data.blocks ?? null, audits: data.audits ?? [], planId: plan.id }
|
|
1676
|
+
const laneCount = rows.reduce((max, row) => Math.max(max, row.lane), 0) + 1
|
|
1677
|
+
const gutter = TREE.padX * 2 + laneCount * TREE.laneWidth
|
|
1678
|
+
const x = (lane) => TREE.padX + lane * TREE.laneWidth
|
|
1679
|
+
const y = (index) => index * TREE.rowHeight + TREE.rowHeight / 2 + 4
|
|
1680
|
+
const marksOf = rows.map((row) => treeMarks(row, context))
|
|
1681
|
+
// 轨道:显式连接表(扇出/扇入)。落选那条画虚线(3 3)用灰——「这条路走过了,但没走通」。
|
|
1682
|
+
const svg = treeConns(rows).map((conn, index) => {
|
|
1683
|
+
const to = rows[conn.to]
|
|
1684
|
+
const dashed = marksOf[conn.to].greyed
|
|
1685
|
+
return h('path', { key: `rail-${index}`, d: railPath(conn, rows), fill: 'none', stroke: dashed ? TREE_COLOR.pruned : TREE_COLOR.rail, strokeWidth: 1, strokeDasharray: dashed && to.kind === 'branch' ? '3 3' : undefined })
|
|
1686
|
+
})
|
|
1687
|
+
rows.forEach((row, index) => {
|
|
1688
|
+
const marks = marksOf[index]
|
|
1689
|
+
const cx = x(row.lane)
|
|
1690
|
+
const cy = y(index)
|
|
1691
|
+
const arcs = treeArcs(marks)
|
|
1692
|
+
const nodes = []
|
|
1693
|
+
// ③ 光环(先画,在节点下面)
|
|
1694
|
+
if (marks.auditing) {
|
|
1695
|
+
nodes.push(h('circle', { key: 'halo', cx, cy, r: TREE.halo, fill: 'none', stroke: TREE_COLOR.running, strokeWidth: 1.4, strokeDasharray: '2 2.5', className: 'clearai-breathe-stroke' }))
|
|
1696
|
+
} else if (marks.live) {
|
|
1697
|
+
nodes.push(h('circle', { key: 'halo', cx, cy, r: TREE.halo, fill: marks.color, className: 'clearai-breathe' }))
|
|
1698
|
+
}
|
|
1699
|
+
if (row.kind === 'converge') {
|
|
1700
|
+
nodes.push(
|
|
1701
|
+
h('rect', {
|
|
1702
|
+
key: 'node',
|
|
1703
|
+
x: cx - TREE.radius,
|
|
1704
|
+
y: cy - TREE.radius,
|
|
1705
|
+
width: TREE.radius * 2,
|
|
1706
|
+
height: TREE.radius * 2,
|
|
1707
|
+
rx: 1,
|
|
1708
|
+
transform: `rotate(45 ${cx} ${cy})`,
|
|
1709
|
+
fill: marks.settled ? marks.color : 'transparent',
|
|
1710
|
+
stroke: marks.color,
|
|
1711
|
+
strokeWidth: 1.4,
|
|
1712
|
+
}),
|
|
1713
|
+
)
|
|
1714
|
+
} else {
|
|
1715
|
+
// ① 填充:落定的画实心核(有分段时核再小一圈,不与弧粘连)
|
|
1716
|
+
if (marks.settled) {
|
|
1717
|
+
nodes.push(h('circle', { key: 'core', cx, cy, r: arcs.length > 0 ? TREE.radius - TREE.arcWidth - 1 : TREE.radius, fill: marks.color }))
|
|
1718
|
+
}
|
|
1719
|
+
// ④ 分段:每段一次闸门裁断,驳回的画红
|
|
1720
|
+
if (arcs.length > 0) {
|
|
1721
|
+
for (const arc of arcs) {
|
|
1722
|
+
nodes.push(
|
|
1723
|
+
h('circle', {
|
|
1724
|
+
key: arc.key,
|
|
1725
|
+
cx,
|
|
1726
|
+
cy,
|
|
1727
|
+
r: TREE.radius,
|
|
1728
|
+
fill: 'none',
|
|
1729
|
+
stroke: arc.fail ? '#c98f8f' : marks.color,
|
|
1730
|
+
strokeWidth: TREE.arcWidth,
|
|
1731
|
+
transform: `rotate(-90 ${cx} ${cy})`,
|
|
1732
|
+
...arcDash(arc.index, arcs.length),
|
|
1733
|
+
}),
|
|
1734
|
+
)
|
|
1735
|
+
}
|
|
1736
|
+
} else if (!marks.settled) {
|
|
1737
|
+
// 还没落定又没故事:一个干净的空心圈(实心到哪儿就是做到哪儿)
|
|
1738
|
+
nodes.push(h('circle', { key: 'node', cx, cy, r: TREE.radius, fill: 'transparent', stroke: marks.color, strokeWidth: 1.4 }))
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
svg.push(h('g', { key: `node-${index}` }, nodes))
|
|
1742
|
+
})
|
|
1743
|
+
const counts = (row) => {
|
|
1744
|
+
if (row.kind === 'branch' || row.kind === 'converge') return null
|
|
1745
|
+
const marks = treeMarks(row, context)
|
|
1746
|
+
const stepId = row.step.id
|
|
1747
|
+
const scouts = (data.scouts ?? []).filter((item) => item.stepId === stepId).length
|
|
1748
|
+
const judges = (data.audits ?? []).filter((item) => item.stepId === stepId).length
|
|
1749
|
+
return { marks, scouts, judges }
|
|
1750
|
+
}
|
|
1751
|
+
/**
|
|
1752
|
+
* 行文只放**名字**,状态交给颜色/形状/标签说。但「失败」与「随步骤作废而终止」是两种
|
|
1753
|
+
* 容易被误读成「还在跑」的状态,所以它们各带一个后缀(2026-09-11 定的语义:
|
|
1754
|
+
* 失败不是落选,孤儿不是被裁)。
|
|
1755
|
+
*/
|
|
1756
|
+
const rowSuffix = (row) =>
|
|
1757
|
+
row.kind === 'branch' && row.branch.failed === true
|
|
1758
|
+
? t(' · 执行没跑成')
|
|
1759
|
+
: row.kind === 'branch' && row.branch.orphaned === true
|
|
1760
|
+
? t(' · 随步骤作废而终止')
|
|
1761
|
+
: ''
|
|
1762
|
+
const rowLabel = (row) =>
|
|
1763
|
+
row.kind === 'branch'
|
|
1764
|
+
? `${row.branch.label || row.branch.id}${rowSuffix(row)}`
|
|
1765
|
+
: row.kind === 'converge'
|
|
1766
|
+
? row.fork.phase === 'settled'
|
|
1767
|
+
? t('已采纳')
|
|
1768
|
+
: row.fork.phase === 'orphaned'
|
|
1769
|
+
? t('未收口(随步骤作废而终止)')
|
|
1770
|
+
: row.fork.phase === 'deciding'
|
|
1771
|
+
? t('待裁决')
|
|
1772
|
+
: t('收敛')
|
|
1773
|
+
: row.kind === 'abandoned'
|
|
1774
|
+
? `${t('已放弃探索:')}${brief(row.fork.question, 24)}`
|
|
1775
|
+
: /**
|
|
1776
|
+
* 行里只放**认得出这一步的那几个字**:`do` 常常是整句话(真数据里 ~100 字 ✗),
|
|
1777
|
+
* 全文在点开的详情里(§30)。
|
|
1778
|
+
*/
|
|
1779
|
+
`${row.step.ordinal}. ${brief(row.step.do, 26)}`
|
|
1780
|
+
return h(
|
|
1781
|
+
'div',
|
|
1782
|
+
{ style: S.wrap },
|
|
1783
|
+
h(
|
|
1784
|
+
'div',
|
|
1785
|
+
{ style: S.bar },
|
|
1786
|
+
h('span', { style: S.title }, t('世界树')),
|
|
1787
|
+
/**
|
|
1788
|
+
* 页眉只放**一句话**:`brief` 常常是整篇 markdown 计划(真数据里这一行渲染出 1794 字 ✗),
|
|
1789
|
+
* 全文进 tooltip,要读整篇点「打开计划」(原生预览 `clear/goals/plans/<id>.md`)——
|
|
1790
|
+
* 默认少而准,细节靠点开(§30)。
|
|
1791
|
+
*/
|
|
1792
|
+
h('span', { style: S.faint, title: briefText }, titleText),
|
|
1793
|
+
/**
|
|
1794
|
+
* **历史世界树的入口**(§32):一个对话会有多个计划。
|
|
1795
|
+
* 只有一份时不出现 ✓;多份时用**一个原生下拉**(一行,不铺一排标签 —— 真数据里有 8 份 ✗)。
|
|
1796
|
+
* 默认停在最近那份;切到旧的会在下面标出「已收尾 · 存档可看」。
|
|
1797
|
+
*/
|
|
1798
|
+
plans.length <= 1
|
|
1799
|
+
? null
|
|
1800
|
+
: h(
|
|
1801
|
+
'select',
|
|
1802
|
+
{
|
|
1803
|
+
value: plan?.id ?? '',
|
|
1804
|
+
onChange: (event) => {
|
|
1805
|
+
setPicked(event.target.value)
|
|
1806
|
+
setManual(null)
|
|
1807
|
+
},
|
|
1808
|
+
title: t('切回历史的世界树(计划都还在,文档也归档在 clear/goals/plans/)'),
|
|
1809
|
+
style: { font: 'inherit', fontSize: 11.5, color: 'var(--dsw-alias-label-secondary)', background: 'transparent', border: '.5px solid var(--dsw-alias-border-l2)', borderRadius: 6, padding: '1px 4px' },
|
|
1810
|
+
},
|
|
1811
|
+
...plans.map((item, index) =>
|
|
1812
|
+
h('option', { key: item.id, value: item.id }, `${t('计划 ')}${index + 1}/${plans.length} · ${item.status === 'active' ? '推进中' : '已收尾'}`),
|
|
1813
|
+
),
|
|
1814
|
+
),
|
|
1815
|
+
chosen === null
|
|
1816
|
+
? null
|
|
1817
|
+
: h('span', { style: { ...S.faint, opacity: 0.8 } }, t('已收尾 · 存档可看')),
|
|
1818
|
+
h(
|
|
1819
|
+
'span',
|
|
1820
|
+
{ style: S.faint },
|
|
1821
|
+
`${t('步 ')}${rows.filter((row) => row.kind === 'step' || row.kind === 'fork').length}${t(' · 车道 ')}${laneCount}`,
|
|
1822
|
+
),
|
|
1823
|
+
props.openPreview === undefined
|
|
1824
|
+
? null
|
|
1825
|
+
: h(Link, { onClick: () => props.openPreview(`clear/goals/plans/${plan.id}.md`), title: t('打开计划文档(原生预览)') }, t('计划文档')),
|
|
1826
|
+
h('span', { style: { ...S.faint, marginLeft: 'auto' } }, t('点一行看细节')),
|
|
1827
|
+
),
|
|
1828
|
+
/**
|
|
1829
|
+
* 这一格是**计划的一切**(§23):目标是脊柱的起点、拓扑是它的形状、
|
|
1830
|
+
* 要你拍板的那一下(人门)就发生在某条车道上——所以三样都在这张图上,
|
|
1831
|
+
* 而不是散在三个页签里(「进展」那一格因此撤了)。
|
|
1832
|
+
*/
|
|
1833
|
+
h(GoalLine, { goal: data.goal }),
|
|
1834
|
+
h(Inbox, { data }),
|
|
1835
|
+
h(
|
|
1836
|
+
'div',
|
|
1837
|
+
{ style: { display: 'flex', gap: 8, alignItems: 'flex-start' } },
|
|
1838
|
+
h('svg', { width: gutter, height: rows.length * TREE.rowHeight + 8, style: { flex: '0 0 auto' } }, svg),
|
|
1839
|
+
h(
|
|
1840
|
+
'div',
|
|
1841
|
+
{ style: { flex: 1, minWidth: 0 } },
|
|
1842
|
+
rows.map((row, index) => {
|
|
1843
|
+
const marks = marksOf[index]
|
|
1844
|
+
const stat = counts(row)
|
|
1845
|
+
const isSel = selected === index
|
|
1846
|
+
return h(
|
|
1847
|
+
'div',
|
|
1848
|
+
{
|
|
1849
|
+
key: `t-${index}`,
|
|
1850
|
+
className: 'clearai-treerow',
|
|
1851
|
+
'data-sel': isSel ? '1' : '0',
|
|
1852
|
+
style: { height: TREE.rowHeight, overflow: 'hidden' },
|
|
1853
|
+
onClick: () => {
|
|
1854
|
+
// 手动点行:先清掉外面的聚焦(否则它一直压着手动选择,点了没反应 ✗)
|
|
1855
|
+
treeFocus.set(null)
|
|
1856
|
+
setManual(isSel ? null : index)
|
|
1857
|
+
},
|
|
1858
|
+
title: t('点开看这一步的细节'),
|
|
1859
|
+
},
|
|
1860
|
+
h(
|
|
1861
|
+
'span',
|
|
1862
|
+
{
|
|
1863
|
+
style: {
|
|
1864
|
+
flex: 1,
|
|
1865
|
+
minWidth: 0,
|
|
1866
|
+
overflow: 'hidden',
|
|
1867
|
+
textOverflow: 'ellipsis',
|
|
1868
|
+
whiteSpace: 'nowrap',
|
|
1869
|
+
fontWeight: row.kind === 'converge' || row.kind === 'fork' ? 600 : 400,
|
|
1870
|
+
...(marks.greyed
|
|
1871
|
+
? { color: 'var(--dsw-alias-label-secondary)', opacity: 0.55, textDecoration: 'line-through' }
|
|
1872
|
+
: marks.done
|
|
1873
|
+
? { opacity: 0.6, textDecoration: 'line-through' }
|
|
1874
|
+
: {}),
|
|
1875
|
+
},
|
|
1876
|
+
},
|
|
1877
|
+
rowLabel(row),
|
|
1878
|
+
),
|
|
1879
|
+
row.kind === 'branch' && row.fork.recommended === row.branch.id && row.fork.phase !== 'settled' ? h('span', { style: { color: TREE_COLOR.recommend, flex: '0 0 auto' }, title: t('算术推荐这条') }, '★') : null,
|
|
1880
|
+
row.kind === 'branch' && row.branch.reading !== null && row.branch.reading !== undefined
|
|
1881
|
+
? h('span', { style: { ...S.mono, color: marks.color, flex: '0 0 auto' }, title: `${t('读数(尺子 ')}${row.fork.decideBy?.metric ?? '—'})` }, String(row.branch.reading))
|
|
1882
|
+
: null,
|
|
1883
|
+
stat === null ? null : h(
|
|
1884
|
+
'span',
|
|
1885
|
+
{ style: { flex: '0 0 auto', display: 'flex', gap: 6, ...S.faint, marginLeft: 'auto' } },
|
|
1886
|
+
stat.marks.loops.rounds > 0
|
|
1887
|
+
? h('span', { title: `${stat.marks.loops.rounds}${t(' 轮')}${stat.marks.loops.fails > 0 ? ` · 其中 ${stat.marks.loops.fails} 次被驳回` : ''}` }, `∞${stat.marks.loops.rounds}`)
|
|
1888
|
+
: null,
|
|
1889
|
+
stat.scouts > 0 ? h('span', { title: `${t('派出 ')}${stat.scouts}${t(' 次侦察')}` }, `${t('侦 ')}${stat.scouts}`) : null,
|
|
1890
|
+
stat.judges > 0 ? h('span', { title: `${t('起过 ')}${stat.judges}${t(' 次评估者')}` }, `${t('评 ')}${stat.judges}`) : null,
|
|
1891
|
+
),
|
|
1892
|
+
)
|
|
1893
|
+
}),
|
|
1894
|
+
),
|
|
1895
|
+
),
|
|
1896
|
+
selected === null ? null : h(TreeDetail, { row: rows[selected], data, openPreview: props.openPreview, openSpectator: props.openSpectator, openFacts: props.openFacts, onClose: () => setManual(null) }),
|
|
1897
|
+
)
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
/**
|
|
1901
|
+
* 树下的详情:选中行的**事实**都在这里(树上只有形状,文字细节在这里)。
|
|
1902
|
+
*
|
|
1903
|
+
* 分叉待裁决时,这里也是**给人的那两个动作**的落点(与原设计一致:
|
|
1904
|
+
* 「采纳、改判、放弃探索的动作都在 PlanTree 里」)。放弃必须带缘由——
|
|
1905
|
+
* 留痕可考,不是摆设(原设计用对话框强制必填,这里用同一个纪律:没缘由按钮不生效)。
|
|
1906
|
+
*/
|
|
1907
|
+
function TreeDetail(props) {
|
|
1908
|
+
const row = props.row
|
|
1909
|
+
const data = props.data ?? {}
|
|
1910
|
+
const [note, setNote] = React.useState('')
|
|
1911
|
+
const [busy, setBusy] = React.useState(null)
|
|
1912
|
+
const [error, setError] = React.useState(null)
|
|
1913
|
+
const send = (action, extra) => {
|
|
1914
|
+
setBusy(action)
|
|
1915
|
+
setError(null)
|
|
1916
|
+
fetch('/api/clearai/gate', {
|
|
1917
|
+
method: 'POST',
|
|
1918
|
+
headers: { 'content-type': 'application/json' },
|
|
1919
|
+
body: JSON.stringify({ sessionId: data.sessionId, action, plan: data.plan?.id ?? null, step: row.step?.id ?? null, note: note.trim() === '' ? null : note.trim(), ...extra }),
|
|
1920
|
+
})
|
|
1921
|
+
.then(readResponse)
|
|
1922
|
+
.then((result) => {
|
|
1923
|
+
if (result.ok !== true) setError(result.error)
|
|
1924
|
+
})
|
|
1925
|
+
.catch((thrown) => setError(String(thrown?.message ?? thrown)))
|
|
1926
|
+
.finally(() => setBusy(null))
|
|
1927
|
+
}
|
|
1928
|
+
const step = row.step
|
|
1929
|
+
const fork = row.fork ?? null
|
|
1930
|
+
const evidence = (data.evidence ?? []).filter((item) => item.stepId === step?.id)
|
|
1931
|
+
const audits = (data.audits ?? []).filter((item) => item.stepId === step?.id)
|
|
1932
|
+
const scouts = (data.scouts ?? []).filter((item) => item.stepId === step?.id)
|
|
1933
|
+
const branch = row.kind === 'branch' ? row.branch : null
|
|
1934
|
+
const deciding = fork !== null && fork.phase === 'deciding' && fork.abandonReason === null
|
|
1935
|
+
return h(
|
|
1936
|
+
'div',
|
|
1937
|
+
{ style: { marginTop: 10, borderTop: '.5px solid var(--dsw-alias-border-l1)', paddingTop: 8 } },
|
|
1938
|
+
h(
|
|
1939
|
+
'div',
|
|
1940
|
+
{ style: S.inline },
|
|
1941
|
+
h('span', { style: S.head }, row.kind === 'branch' ? `${t('车道 · ')}${branch.label || branch.id}` : row.kind === 'converge' ? t('收敛') : row.kind === 'abandoned' ? t('已放弃的分叉') : `${t('步 ')}${step.ordinal} · ${step.id}`),
|
|
1942
|
+
h(Link, { onClick: () => props.onClose?.(), title: t('收起详情') }, h('span', { style: S.faint }, t('收起'))),
|
|
1943
|
+
),
|
|
1944
|
+
row.kind === 'abandoned'
|
|
1945
|
+
? h('div', { style: S.faint }, `${t('问题:')}${fork.question}${t(';放弃缘由:')}${fork.abandonReason ?? '未记'}${t('(留档不删)')}`)
|
|
1946
|
+
: null,
|
|
1947
|
+
fork?.orphaned === true
|
|
1948
|
+
? h(
|
|
1949
|
+
'div',
|
|
1950
|
+
{ style: S.faint },
|
|
1951
|
+
`${t('这一步已作废(缘由:')}${fork.ownerVoidReason ?? '未记'}${t('),所以这个分叉没有归宿了:**随步骤作废而终止**——它既不是被人裁掉(没人做过这个决定),也不是被算术排掉(没有尺子排过它)。要收掉工作副本(保留 ref),对它调 AbandonFork(会弹人工确认)。')}`,
|
|
1952
|
+
)
|
|
1953
|
+
: null,
|
|
1954
|
+
branch?.failed === true
|
|
1955
|
+
? h(
|
|
1956
|
+
'div',
|
|
1957
|
+
{ style: S.faint },
|
|
1958
|
+
`${t('**执行没跑成**(')}${branch.execution?.note ?? 'failed'}${t(')——它不是落选:落选意味着它跑完了、被尺子排到了后面;这一条是没跑成,所以也没有读数可以参与算术。')}${branch.execution?.conclusion === null || branch.execution?.conclusion === undefined || branch.execution.conclusion === '' ? '' : `执行者最后说的话:${String(branch.execution.conclusion).slice(0, 160)}`}`,
|
|
1959
|
+
)
|
|
1960
|
+
: null,
|
|
1961
|
+
branch?.unreturned === true
|
|
1962
|
+
? h(
|
|
1963
|
+
'div',
|
|
1964
|
+
{ style: S.faint },
|
|
1965
|
+
t('**执行者未归**:这条世界线的执行者派出去之后没有回灌,而分叉已经收口——它再回来也没有归宿了,所以这里既不判它跑成也没跑成,也不再等它。它的产物(如果写过)还在它自己的工作副本里;要判断那条线到底做出了什么,直接看工作副本比看这条状态可靠。'),
|
|
1966
|
+
)
|
|
1967
|
+
: null,
|
|
1968
|
+
branch !== null
|
|
1969
|
+
? h(
|
|
1970
|
+
'div',
|
|
1971
|
+
{ style: S.kv },
|
|
1972
|
+
h('span', { style: S.faint }, t('做法')), h('span', null, branch.approach ?? '—'),
|
|
1973
|
+
h('span', { style: S.faint }, t('判据')), h('span', null, branch.doneCriteria ?? '—'),
|
|
1974
|
+
h('span', { style: S.faint }, t('读数')), h('span', { style: S.mono }, branch.reading === null || branch.reading === undefined ? '—' : `${branch.reading}${branch.validity === 'usable' ? '' : '(不可用)'}`),
|
|
1975
|
+
h('span', { style: S.faint }, t('状态')), h('span', null, `${gloss(BRANCH_STATUS, branch.status)}${branch.verdict === null || branch.verdict === undefined ? '' : ` · 结论 ${branch.verdict}`}`),
|
|
1976
|
+
branch.basis === null || branch.basis === undefined ? null : h('span', { style: S.faint }, t('依据')),
|
|
1977
|
+
branch.basis === null || branch.basis === undefined ? null : h('span', null, branch.basis),
|
|
1978
|
+
branch.gitBranch === null || branch.gitBranch === undefined ? null : h('span', { style: S.faint }, t('分支')),
|
|
1979
|
+
branch.gitBranch === null || branch.gitBranch === undefined ? null : h('span', { style: S.mono }, `${branch.gitBranch}${branch.worktreeRemoved === true ? '(工作副本已清理,ref 保留)' : ''}`),
|
|
1980
|
+
branch.evaluatorSession === null || branch.evaluatorSession === undefined ? null : h('span', { style: S.faint }, t('评估者')),
|
|
1981
|
+
branch.evaluatorSession === null || branch.evaluatorSession === undefined
|
|
1982
|
+
? null
|
|
1983
|
+
: h(Link, { onClick: () => props.openSpectator?.(branch.evaluatorSession), title: t('旁观这条世界线的评估者会话(只读)') }, h('span', { style: S.mono }, branch.evaluatorSession)),
|
|
1984
|
+
)
|
|
1985
|
+
: null,
|
|
1986
|
+
step === null || step === undefined
|
|
1987
|
+
? null
|
|
1988
|
+
: h(
|
|
1989
|
+
'div',
|
|
1990
|
+
{ style: S.kv },
|
|
1991
|
+
h('span', { style: S.faint }, t('做什么')), h('span', null, step.do),
|
|
1992
|
+
h('span', { style: S.faint }, t('判据')), h('span', null, `${step.doneCriteria}${step.voidReason === null || step.voidReason === undefined ? '' : `(作废:${step.voidReason})`}`),
|
|
1993
|
+
h('span', { style: S.faint }, t('状态')), h('span', null, `${gloss(STEP, step.status)}${step.level === null || step.level === undefined ? '' : ` · ${step.level}`}`),
|
|
1994
|
+
h(
|
|
1995
|
+
'span',
|
|
1996
|
+
{ style: S.faint },
|
|
1997
|
+
t('产物'),
|
|
1998
|
+
),
|
|
1999
|
+
h(
|
|
2000
|
+
'span',
|
|
2001
|
+
null,
|
|
2002
|
+
...(step.artifacts ?? []).length === 0
|
|
2003
|
+
? [t('未声明')]
|
|
2004
|
+
: (step.artifacts ?? []).map((artifact, index) => {
|
|
2005
|
+
/**
|
|
2006
|
+
* 两种形状都吃:声明里是**字符串**,宿主路由 stat 过的是**对象**。
|
|
2007
|
+
* 而且 `exists` 有三态:true / false(查过、不在) / null(**没查过**)——
|
|
2008
|
+
* 「没查过」不许说成「缺」(真数据里树详情因此把每个产物都写成 `undefined(缺)` ✗)。
|
|
2009
|
+
*/
|
|
2010
|
+
const path = typeof artifact === 'string' ? artifact : artifact?.path
|
|
2011
|
+
const exists = typeof artifact === 'string' ? null : (artifact?.exists ?? null)
|
|
2012
|
+
if (typeof path !== 'string' || path === '') return null
|
|
2013
|
+
return h(
|
|
2014
|
+
'span',
|
|
2015
|
+
{ key: `${path}-${index}`, style: { marginRight: 8 } },
|
|
2016
|
+
exists === false
|
|
2017
|
+
? h('span', { style: { ...S.mono, opacity: 0.6 }, title: t('盘上没有这个文件') }, `${path}${t('(缺)')}`)
|
|
2018
|
+
: h(Link, { onClick: () => props.openPreview?.(path), title: exists === true ? t('原生预览打开它') : t('原生预览打开它(计划声明的产物)') }, h('span', { style: S.mono }, path)),
|
|
2019
|
+
)
|
|
2020
|
+
}),
|
|
2021
|
+
),
|
|
2022
|
+
/**
|
|
2023
|
+
* **反向跳**(§27e):从树里的这一步跳到「事实」那一格,并展开对应的命题。
|
|
2024
|
+
* 切中栏视图走**原生正门** selectPanel(经可选读法拿到的 layout 服务;守卫:
|
|
2025
|
+
* 没注册的 key 它会抛,而抛了不该把整棵树带下水)。
|
|
2026
|
+
*/
|
|
2027
|
+
props.openFacts === undefined
|
|
2028
|
+
? null
|
|
2029
|
+
: h('span', { style: S.faint }, t('证据')),
|
|
2030
|
+
props.openFacts === undefined
|
|
2031
|
+
? null
|
|
2032
|
+
: h(
|
|
2033
|
+
'span',
|
|
2034
|
+
null,
|
|
2035
|
+
h(
|
|
2036
|
+
Link,
|
|
2037
|
+
{
|
|
2038
|
+
onClick: () => props.openFacts(step.id),
|
|
2039
|
+
title: t('打开「事实」那一格并展开这条命题'),
|
|
2040
|
+
},
|
|
2041
|
+
t('看这一步的证据'),
|
|
2042
|
+
),
|
|
2043
|
+
),
|
|
2044
|
+
),
|
|
2045
|
+
evidence.length === 0 && audits.length === 0 && scouts.length === 0
|
|
2046
|
+
? null
|
|
2047
|
+
: h(
|
|
2048
|
+
'div',
|
|
2049
|
+
{ style: { marginTop: 6 } },
|
|
2050
|
+
evidence.map((item) => h('div', { key: item.id, style: S.faint }, `${t('证据 ')}${item.id} · ${item.verdict ?? '—'}${item.level === null || item.level === undefined ? '' : ` · ${item.level}`} · ${item.evaluator ?? '—'}`)),
|
|
2051
|
+
audits.map((item) =>
|
|
2052
|
+
h(
|
|
2053
|
+
'div',
|
|
2054
|
+
{ key: item.id, style: S.faint },
|
|
2055
|
+
`${t('评估者 ')}${item.verdict === null || item.verdict === undefined ? '正在裁决' : item.verdict} · ${item.evaluator ?? '—'}`,
|
|
2056
|
+
item.evaluatorSession === null || item.evaluatorSession === undefined ? null : h(Link, { onClick: () => props.openSpectator?.(item.evaluatorSession) }, h('span', { style: S.mono }, `${t(' 旁观 ')}${item.evaluatorSession}`)),
|
|
2057
|
+
item.cardPath === null || item.cardPath === undefined ? null : h(Link, { onClick: () => props.openPreview?.(item.cardPath) }, h('span', { style: S.mono }, t(' 评估卡'))),
|
|
2058
|
+
),
|
|
2059
|
+
),
|
|
2060
|
+
scouts.map((item) => h('div', { key: item.id, style: S.faint }, `${t('侦察 · ')}${gloss(SCOUT, item.status)}${item.conclusion === null || item.conclusion === undefined ? '' : `:${String(item.conclusion).slice(0, 80)}`}`)),
|
|
2061
|
+
),
|
|
2062
|
+
fork === null
|
|
2063
|
+
? null
|
|
2064
|
+
: h(
|
|
2065
|
+
'div',
|
|
2066
|
+
{ style: { marginTop: 6 } },
|
|
2067
|
+
h('div', { style: S.faint }, `${t('世界线:')}${fork.question}${t(' · 尺子 ')}${fork.decideBy?.metric ?? '—'}(${fork.decideBy?.direction === 'min' ? '越小越好' : '越大越好'}) · ${fork.phase === 'settled' ? '已收敛' : fork.phase === 'deciding' ? '待裁决' : '探索中'}`),
|
|
2068
|
+
fork.verdict === null || fork.verdict === undefined
|
|
2069
|
+
? null
|
|
2070
|
+
: h('div', { style: S.faint }, `${t('裁决:采纳 ')}${fork.verdict.winner}${fork.verdict.tie === true ? '(并列)' : ''}${fork.verdict.margin === null || fork.verdict.margin === undefined ? '' : ` · 余量 ${fork.verdict.margin}`}${fork.provisional === true ? ' · 临时采纳,待复核' : ''}`),
|
|
2071
|
+
// 采纳了但没合并:如实说(账上写过分支、后来对象没了,合并就做不到)。
|
|
2072
|
+
fork.mergeSkipped === null || fork.mergeSkipped === undefined
|
|
2073
|
+
? null
|
|
2074
|
+
: h('div', { style: S.faint }, `${t('这次采纳没有合并:')}${fork.mergeSkipped.reason ?? t('分支或工作副本已不在')}${t('(决定已登记,产物由一次普通交付落位)')}`),
|
|
2075
|
+
fork.humanDecision === null || fork.humanDecision === undefined ? null : h('div', { style: S.faint }, `${t('人的裁决:')}${gloss(FORK_ACTION, fork.humanDecision.action)}${fork.humanDecision.note === null || fork.humanDecision.note === undefined ? '' : ` · ${fork.humanDecision.note}`}`),
|
|
2076
|
+
),
|
|
2077
|
+
deciding
|
|
2078
|
+
? h(
|
|
2079
|
+
'div',
|
|
2080
|
+
{ style: { marginTop: 6, display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' } },
|
|
2081
|
+
h(Chip, { disabled: busy !== null, onClick: () => send('adopt_branch', { fork: fork.id, branch: branch === null ? fork.recommended ?? null : branch.id }), title: t('让内核跑 ConvergeFork 落实它(合并是内核的活)') }, busy === 'adopt_branch' ? t('提交中…') : t('采纳此世界线')),
|
|
2082
|
+
h('input', {
|
|
2083
|
+
value: note,
|
|
2084
|
+
placeholder: t('放弃缘由(必填)'),
|
|
2085
|
+
onChange: (event) => setNote(event.target.value),
|
|
2086
|
+
style: { fontSize: 11.5, padding: '2px 6px', borderRadius: 6, border: '.5px solid var(--dsw-alias-border-l3)', background: 'transparent', color: 'inherit', minWidth: 120 },
|
|
2087
|
+
}),
|
|
2088
|
+
h(
|
|
2089
|
+
Chip,
|
|
2090
|
+
{
|
|
2091
|
+
disabled: busy !== null || note.trim() === '',
|
|
2092
|
+
onClick: () => send('abandon_fork', { fork: fork.id }),
|
|
2093
|
+
title: note.trim() === '' ? t('放弃要留痕:先写缘由') : t('放弃这次探索(留档不删,ref 保留)'),
|
|
2094
|
+
},
|
|
2095
|
+
busy === 'abandon_fork' ? t('提交中…') : t('放弃探索'),
|
|
2096
|
+
),
|
|
2097
|
+
error === null ? null : h('span', { style: S.faint }, `${t('提交失败:')}${error}`),
|
|
2098
|
+
)
|
|
2099
|
+
: null,
|
|
2100
|
+
)
|
|
2101
|
+
}
|
|
2102
|
+
/**
|
|
2103
|
+
* 技能目录的**来源分组**(人此刻在问「我现在能用哪些技能」)。
|
|
2104
|
+
*
|
|
2105
|
+
* 分组表是**照抄宿主的来源名**,不是我们自己编的层次;表外的来源如实显示成
|
|
2106
|
+
* `其他(<来源>)`——宁可见到一个陌生的名字,也不把一条技能藏起来。
|
|
2107
|
+
*/
|
|
2108
|
+
const SOURCE_GROUPS = lazyTable(() => ([
|
|
2109
|
+
{ test: (source) => source === 'bundled', label: t('预设自带'), order: 10 },
|
|
2110
|
+
{ test: (source) => source === 'clearai-template', label: t('工作区模板 · clear/skills'), order: 20 },
|
|
2111
|
+
{ test: (source) => source === 'clearai-workspace', label: t('本项目写的 · clear/skills'), order: 30 },
|
|
2112
|
+
{ test: (source) => source === 'project-dsh' || source === 'project-agents', label: t('本项目 · .dsh / .agents'), order: 40 },
|
|
2113
|
+
{ test: (source) => source === 'user-dsh' || source === 'user-agents', label: t('我的 · ~/.dsh'), order: 50 },
|
|
2114
|
+
{ test: (source) => source === 'clearai-memory', label: t('记忆索引'), order: 60 },
|
|
2115
|
+
{ test: (source) => source === 'runtime', label: t('运行时注册'), order: 70 },
|
|
2116
|
+
{ test: (source) => source === 'custom', label: t('自定义'), order: 80 },
|
|
2117
|
+
]))
|
|
2118
|
+
const groupOf = (source) => SOURCE_GROUPS.find((group) => group.test(source)) ?? { label: `${t('其他(')}${source})`, order: 90 }
|
|
2119
|
+
/** 一步的当前结果(指针的落点):用量行要说清「那一步现在怎么样了」。 */
|
|
2120
|
+
const STEP_OUTCOME = lazyTable(() => ({ advanced: t('已交付'), open: t('还没交付'), void: t('已作废') }))
|
|
2121
|
+
|
|
2122
|
+
/**
|
|
2123
|
+
* 技能 · 记忆页签(右栏)。
|
|
2124
|
+
*
|
|
2125
|
+
* 技能清单 = **宿主原生的合并目录**(`projectionValues.clearai.skills.catalog`)——
|
|
2126
|
+
* 内核每个 pre-step 调一次 `ctx.skills.snapshot`,于是面板与模型看的是**同一张表**
|
|
2127
|
+
* (预设自带 / 我的 / 本项目的 / clear/skills 里的候选,合并后按 rank 取赢家)。
|
|
2128
|
+
*
|
|
2129
|
+
* 用法 = **本会话的加载记录**(`skills.usage`),从日志折出来的:模型加载是
|
|
2130
|
+
* `tool/call name='skill'`,人引用是一条含 `/名字` 的用户消息。给的是**指针**
|
|
2131
|
+
* (谁在第几步加载的、那一步现在什么结果),不是跨会话的比率——判据混杂的比率会冒充事实
|
|
2132
|
+
* 归并而不是各写一份。
|
|
2133
|
+
*
|
|
2134
|
+
* 写动作只留**一个**,而且它走既有的原生机制:
|
|
2135
|
+
* · 「采纳」→ 人门动词 `promote_skill` → 内核改写 frontmatter(`status: active`)。
|
|
2136
|
+
* 引用走**原生**的 `/` 技能触发器(输入框里打 `/`,出候选菜单,选一条即注入正文)——
|
|
2137
|
+
* 我们原来在旁边又放了一个「引用」按钮,做的是同一件事,2026-09-11 砍掉(奥卡姆)。
|
|
2138
|
+
*/
|
|
2139
|
+
function BrainTab(props) {
|
|
2140
|
+
const sessionId = props.sessionId
|
|
2141
|
+
const sessions = props.useSessions
|
|
2142
|
+
const data =
|
|
2143
|
+
typeof sessions === 'function' && typeof sessionId === 'string'
|
|
2144
|
+
? sessions((snapshot) => snapshot?.byId?.[sessionId]?.projectionValues?.clearai)
|
|
2145
|
+
: undefined
|
|
2146
|
+
const brain = data === null || data === undefined ? null : data.brain
|
|
2147
|
+
const skills = data === null || data === undefined || data.skills === null || data.skills === undefined ? null : data.skills
|
|
2148
|
+
const catalog = skills === null ? null : skills.catalog
|
|
2149
|
+
const usage = new Map((skills !== null && Array.isArray(skills.usage) ? skills.usage : []).map((item) => [item.name, item]))
|
|
2150
|
+
const [error, setError] = React.useState(null)
|
|
2151
|
+
const [busy, setBusy] = React.useState(false)
|
|
2152
|
+
/**
|
|
2153
|
+
* 投影里还没有目录时,去问**工作区现状**(宿主读面 `GET /api/clearai/brain`)。
|
|
2154
|
+
*
|
|
2155
|
+
* 为什么要有这一条:面板的数据来自会话日志,而内核只在 pre-step 里落事实——
|
|
2156
|
+
* 新建的会话在第一轮对话之前**没有任何日志**,于是页签是空的,可工作区里其实
|
|
2157
|
+
* 已经躺着 18 个模板技能(2026-09-11 用户实测)。这条读面直接问宿主的 skills 服务,
|
|
2158
|
+
* 拿到的就是模型看到的那张合并目录;渲染仍走同一套分组,并如实标注「还没进投影」。
|
|
2159
|
+
*/
|
|
2160
|
+
const [live, setLive] = React.useState(null)
|
|
2161
|
+
const needsLive = catalog === null
|
|
2162
|
+
React.useEffect(() => {
|
|
2163
|
+
if (!needsLive || typeof sessionId !== 'string') return undefined
|
|
2164
|
+
let alive = true
|
|
2165
|
+
fetch(`/api/clearai/brain?sessionId=${encodeURIComponent(sessionId)}`)
|
|
2166
|
+
.then(readResponse)
|
|
2167
|
+
.then((result) => {
|
|
2168
|
+
if (alive && result.ok === true && Array.isArray(result.payload?.entries)) setLive({ entries: result.payload.entries })
|
|
2169
|
+
})
|
|
2170
|
+
.catch(() => {})
|
|
2171
|
+
return () => {
|
|
2172
|
+
alive = false
|
|
2173
|
+
}
|
|
2174
|
+
}, [sessionId, needsLive])
|
|
2175
|
+
const fromLive = catalog === null && live !== null
|
|
2176
|
+
const entries = catalog !== null && Array.isArray(catalog.entries) ? catalog.entries : fromLive ? live.entries : null
|
|
2177
|
+
/** 宿主装没装「用编辑器打开」那条原生路由:装了就多给一个入口,没装就不显示。 */
|
|
2178
|
+
const [apps, setApps] = React.useState(null)
|
|
2179
|
+
|
|
2180
|
+
React.useEffect(() => {
|
|
2181
|
+
let alive = true
|
|
2182
|
+
fetch('/open-in-app/apps')
|
|
2183
|
+
.then((response) => (response.ok ? response.json() : null))
|
|
2184
|
+
.then((payload) => {
|
|
2185
|
+
if (alive) setApps(Array.isArray(payload?.apps) ? payload.apps.filter((id) => typeof id === 'string') : [])
|
|
2186
|
+
})
|
|
2187
|
+
.catch(() => {
|
|
2188
|
+
if (alive) setApps([])
|
|
2189
|
+
})
|
|
2190
|
+
return () => {
|
|
2191
|
+
alive = false
|
|
2192
|
+
}
|
|
2193
|
+
}, [])
|
|
2194
|
+
|
|
2195
|
+
const promote = (name, viaAsk = false) => {
|
|
2196
|
+
setBusy(true)
|
|
2197
|
+
fetch('/api/clearai/gate', {
|
|
2198
|
+
method: 'POST',
|
|
2199
|
+
headers: { 'content-type': 'application/json' },
|
|
2200
|
+
body: JSON.stringify(viaAsk ? { sessionId, action: 'ask', gate: 'promote_skill', skill: name } : { sessionId, action: 'promote_skill', skill: name }),
|
|
2201
|
+
})
|
|
2202
|
+
.then(readResponse)
|
|
2203
|
+
.then((result) => {
|
|
2204
|
+
if (result.ok !== true) setError(result.error)
|
|
2205
|
+
})
|
|
2206
|
+
.catch((thrown) => setError(String(thrown?.message ?? thrown)))
|
|
2207
|
+
.finally(() => setBusy(false))
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
/**
|
|
2211
|
+
* 2026-09-11 删掉了「引用」按钮(人门动词 `invoke_skill`)。
|
|
2212
|
+
*
|
|
2213
|
+
* 原生的 `/` 技能触发器做的正是同一件事,而且更好:`dsh-client-ui-skill` 注册
|
|
2214
|
+
* `trigger: '/'`,`dsh-client-ui-input-trigger` 出候选菜单,选一条就把技能正文
|
|
2215
|
+
* 作为指令注入这一回合——我们的按钮不过是在旁边又写了一遍同一个手势。
|
|
2216
|
+
* 少一个入口 = 少一处会与原生菜单走偏的语义(面板仍保留「采纳」:那是模型做不到的写动作)。
|
|
2217
|
+
*/
|
|
2218
|
+
/** 工作区外的技能(比如 `~/.dsh/skills`):面板读不了,交给宿主的「用编辑器打开」原生路由。 */
|
|
2219
|
+
const openInApp = (dir) => {
|
|
2220
|
+
const app = Array.isArray(apps) ? apps[0] : undefined
|
|
2221
|
+
if (typeof app !== 'string') return
|
|
2222
|
+
fetch('/open-in-app/open', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ app, path: dir }) })
|
|
2223
|
+
.then((response) => {
|
|
2224
|
+
if (!response.ok) setError(`${t('打开失败:HTTP ')}${response.status}`)
|
|
2225
|
+
})
|
|
2226
|
+
.catch((thrown) => setError(String(thrown?.message ?? thrown)))
|
|
2227
|
+
}
|
|
2228
|
+
/**
|
|
2229
|
+
* 点技能名 → **原生**文档预览(markdown 会渲染成 markdown,不是源码)。
|
|
2230
|
+
*
|
|
2231
|
+
* 用内核随投影发下来的 `file`(技能目录下的 SKILL.md):虚拟条目(记忆索引)没有正文文件,
|
|
2232
|
+
* `file` 是 null,面板就不画链接(点进去撞 404 是 2026-09-11 实测过的 bug)。
|
|
2233
|
+
*/
|
|
2234
|
+
const pointAt = (skill) => {
|
|
2235
|
+
if (typeof skill.file !== 'string') return
|
|
2236
|
+
if (props.openPreview?.(skill.file) !== true) setError(t('原生预览打不开这条路(它在工作区外?)'))
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2239
|
+
if (brain === null || brain === undefined) {
|
|
2240
|
+
// 两种情况要分开说:会话还没开始(没有第一轮对话 = 还没有任何事实)vs 真的没有技能或记忆。
|
|
2241
|
+
const note =
|
|
2242
|
+
data === undefined
|
|
2243
|
+
? t('这个会话还没开始:面板的数据来自会话日志,发第一句话之后,这里会显示工作区的技能目录与记忆。')
|
|
2244
|
+
: t('这个工作区里还没有技能或记忆:用一次 `SaveSkill` 或 `WriteMemory`,或把技能放进 `clear/skills/`。')
|
|
2245
|
+
return h('div', { style: S.wrap }, h('div', { style: S.bar }, h('span', { style: S.title }, t('技能 · 记忆'))), h(Empty, null, note))
|
|
2246
|
+
}
|
|
2247
|
+
const overview = Array.isArray(brain.skills) ? brain.skills : []
|
|
2248
|
+
const memory = brain.memory ?? { count: 0, files: [] }
|
|
2249
|
+
/** 章程读数(内核扫的)+ 打开它的那条原生预览通道。 */
|
|
2250
|
+
const constitution = data === null || data === undefined ? null : (data.constitution ?? null)
|
|
2251
|
+
const openAt = (path) => props.openPreview?.(path)
|
|
2252
|
+
const overviewOf = new Map(overview.map((skill) => [skill.name, skill]))
|
|
2253
|
+
// 目录还没到(内核下一次 pre-step 才发):如实说,不拿 brain.skills 冒充合并目录。
|
|
2254
|
+
const listed = entries === null ? overview.map((skill) => ({ name: skill.name, description: skill.description, source: skill.status === 'candidate' ? 'clearai-workspace' : 'clearai-template', model: skill.status !== 'candidate', user: true, dir: null, inside: null, stale: true })) : entries
|
|
2255
|
+
const groups = []
|
|
2256
|
+
for (const skill of listed) {
|
|
2257
|
+
const group = groupOf(String(skill.source ?? 'unknown'))
|
|
2258
|
+
const bucket = groups.find((item) => item.label === group.label) ?? { label: group.label, order: group.order, items: [] }
|
|
2259
|
+
if (!groups.includes(bucket)) groups.push(bucket)
|
|
2260
|
+
bucket.items.push(skill)
|
|
2261
|
+
}
|
|
2262
|
+
groups.sort((left, right) => left.order - right.order || left.label.localeCompare(right.label))
|
|
2263
|
+
const quotedNames = new Set(listed.map((skill) => skill.name))
|
|
2264
|
+
const orphanUsage = (skills !== null && Array.isArray(skills.usage) ? skills.usage : []).filter((item) => !quotedNames.has(item.name))
|
|
2265
|
+
const usageLine = (item) => {
|
|
2266
|
+
if (item === undefined) return null
|
|
2267
|
+
const last = item.last
|
|
2268
|
+
const pointer = last === null ? '—' : `${last.by === 'model' ? '模型' : '人'}${last.ordinal === null ? '' : ` · 第 ${last.ordinal} 步`}${last.outcome === null || last.outcome === undefined ? '' : ` · ${STEP_OUTCOME[last.outcome] ?? last.outcome}`}`
|
|
2269
|
+
return `${t('本会话 模型 ')}${item.model}${t(' · 人 ')}${item.human}${t(' · 最近 ')}${pointer}`
|
|
2270
|
+
}
|
|
2271
|
+
const row = (skill) => {
|
|
2272
|
+
const own = overviewOf.get(skill.name)
|
|
2273
|
+
const candidate = own?.status === 'candidate'
|
|
2274
|
+
const item = usage.get(skill.name)
|
|
2275
|
+
const canOpen = typeof skill.file === 'string'
|
|
2276
|
+
return h(
|
|
2277
|
+
'div',
|
|
2278
|
+
{ key: skill.name, style: S.row },
|
|
2279
|
+
h(
|
|
2280
|
+
'div',
|
|
2281
|
+
{ style: S.inline },
|
|
2282
|
+
candidate ? h('span', { style: S.tagStrong }, t('候选')) : null,
|
|
2283
|
+
skill.model === false && !candidate ? h('span', { style: S.tag }, t('仅人可引用')) : null,
|
|
2284
|
+
h(
|
|
2285
|
+
'span',
|
|
2286
|
+
{
|
|
2287
|
+
style: S.mono,
|
|
2288
|
+
className: canOpen ? 'clearai-link' : undefined,
|
|
2289
|
+
onClick: canOpen ? () => pointAt(skill) : undefined,
|
|
2290
|
+
title: [
|
|
2291
|
+
String(skill.description ?? '').replace(/\s+/g, ' ').trim(),
|
|
2292
|
+
own === undefined ? '' : `${own.bytes}${t(' 字节 · 资源 ')}${own.resources} · ${dash(own.tier ?? own.version)}`,
|
|
2293
|
+
].filter((line) => line !== '').join('\n'),
|
|
2294
|
+
},
|
|
2295
|
+
skill.name,
|
|
2296
|
+
),
|
|
2297
|
+
/**
|
|
2298
|
+
* 机器读数(字节 / 资源数 / 层级)**不渲染**:它们不帮人认出一条技能,
|
|
2299
|
+
* 20 条排下来就是噪声(真数据这一格 4741 字里很大一份是它们 ✗),
|
|
2300
|
+
* 而且藏成 `display:none` 也还是 DOM 里的噪声 ⇒ 干脆不画,挂到技能名的 tooltip(§31)。
|
|
2301
|
+
*/
|
|
2302
|
+
candidate ? h(Chip, { disabled: busy, onClick: () => promote(skill.name), title: t('采纳:改写 frontmatter,模型从此加载得到它') }, t('采纳')) : null,
|
|
2303
|
+
// §20 混合路径:同一件事也可以摆到原生提问卡上答(卡里带它的描述与后果)。
|
|
2304
|
+
candidate ? h('span', { className: busy ? undefined : 'clearai-link', style: S.faint, onClick: busy ? undefined : () => promote(skill.name, true), title: t('用原生提问卡决定') }, t('用提问卡决定')) : null,
|
|
2305
|
+
),
|
|
2306
|
+
/**
|
|
2307
|
+
* 描述只留**认得出这条技能的那一句**(真数据里每条是一整段「适用/不适用」✗,
|
|
2308
|
+
* 18 条一起看就是信息爆炸)。全文在技能名的 tooltip 里,要选技能用原生的 `/` 触发菜单(§31)。
|
|
2309
|
+
*/
|
|
2310
|
+
skill.description === null || skill.description === undefined || skill.description === ''
|
|
2311
|
+
? null
|
|
2312
|
+
: h('div', { style: S.faint }, brief(skill.description, 60)),
|
|
2313
|
+
usageLine(item) === null ? null : h('div', { style: S.dim }, usageLine(item)),
|
|
2314
|
+
// 工作区外的技能:面板读不到正文(路径守卫),所以如实给绝对路径 + 宿主的编辑器入口。
|
|
2315
|
+
skill.inside === false && typeof skill.dir === 'string' ? h('div', { style: S.faint }, `${skill.dir}${t(' · 在工作区外,面板不读正文')}`) : null,
|
|
2316
|
+
skill.inside === false && typeof skill.dir === 'string' && Array.isArray(apps) && typeof apps[0] === 'string'
|
|
2317
|
+
? h(Chip, { onClick: () => openInApp(skill.dir) }, `${t('在 ')}${apps[0]}${t(' 里打开')}`)
|
|
2318
|
+
: null,
|
|
2319
|
+
)
|
|
2320
|
+
}
|
|
2321
|
+
return h(
|
|
2322
|
+
'div',
|
|
2323
|
+
{ style: S.wrap },
|
|
2324
|
+
h(
|
|
2325
|
+
'div',
|
|
2326
|
+
{ style: S.bar },
|
|
2327
|
+
h('span', { style: S.title }, t('技能 · 记忆')),
|
|
2328
|
+
/**
|
|
2329
|
+
* 计数在下面各段标题里(§30/§31:同屏重复计数 = 噪声);
|
|
2330
|
+
* 「与模型看到的是同一张表」是实现保证、不是用户信息,与事实那一格同一条规矩 → 去掉。
|
|
2331
|
+
* 只留**状态**:目录到没到、这份是不是"本会话还没有第一轮对话"的工作区现状。
|
|
2332
|
+
*/
|
|
2333
|
+
entries === null || fromLive ? h('span', { style: S.faint }, entries === null ? t('目录还没到(内核下一次 pre-step 会发)') : t('工作区现状(本会话还没有第一轮对话,这份还没进投影)')) : null,
|
|
2334
|
+
),
|
|
2335
|
+
error !== null ? h('div', { style: S.faint }, `${t('面板动作失败:')}${error}`) : null,
|
|
2336
|
+
/**
|
|
2337
|
+
* 章程那一行(这个页签的第一层):**只有文件系统事实** + 一个点开就走的原生预览。
|
|
2338
|
+
*
|
|
2339
|
+
* 为什么不再有「占位 X/Y 条」(2026-09-11 砍的,用户一问点醒):那是对文本做格式解析
|
|
2340
|
+
* ——模型把 §5 写成 `- **变更记录**(一行即…)`,条目数就从 18 数成 17,
|
|
2341
|
+
* **改一个标点「事实」就变**;而且它奖励的是「把数字清掉」而不是「把章程写实」。
|
|
2342
|
+
* 章程每回合本来就被原生指令文件整份注入模型上下文,面板这边只需要说清
|
|
2343
|
+
* 「它在哪、什么时候动过」——`mtime` 不依赖任何格式约定。
|
|
2344
|
+
*/
|
|
2345
|
+
constitution === null || constitution === undefined
|
|
2346
|
+
? null
|
|
2347
|
+
: h(
|
|
2348
|
+
'div',
|
|
2349
|
+
{ style: S.section },
|
|
2350
|
+
h('div', { style: S.head }, t('项目章程')),
|
|
2351
|
+
h(
|
|
2352
|
+
'div',
|
|
2353
|
+
{ style: S.inline },
|
|
2354
|
+
/**
|
|
2355
|
+
* 文件名只在**链接**上说一次(原来段标题里还写一遍 ⇒ 同一行同一路径出现两遍 ✗)。
|
|
2356
|
+
* 点它是**动作**(原生预览),字节数与改动时间是文件系统事实 —— 这些留着有意义。
|
|
2357
|
+
*/
|
|
2358
|
+
h(
|
|
2359
|
+
Link,
|
|
2360
|
+
{ onClick: () => openAt(constitution.legacy === true ? 'clear/project.md' : 'PROJECT.md'), title: t('用原生预览打开章程(它每回合整份注入模型上下文)') },
|
|
2361
|
+
h('span', { style: S.mono }, constitution.legacy === true ? 'clear/project.md' : 'PROJECT.md'),
|
|
2362
|
+
),
|
|
2363
|
+
h('span', { style: S.faint }, `${t('最后改动 ')}${stampOf(constitution.modifiedAt)} · ${constitution.bytes}${t(' 字节')}`),
|
|
2364
|
+
),
|
|
2365
|
+
),
|
|
2366
|
+
...groups.map((group) =>
|
|
2367
|
+
h(
|
|
2368
|
+
'div',
|
|
2369
|
+
{ key: group.label, style: S.section },
|
|
2370
|
+
h('div', { style: S.head }, `${group.label}(${group.items.length})`),
|
|
2371
|
+
group.items.map((skill) => row(skill)),
|
|
2372
|
+
),
|
|
2373
|
+
),
|
|
2374
|
+
orphanUsage.length === 0
|
|
2375
|
+
? null
|
|
2376
|
+
: h(
|
|
2377
|
+
'div',
|
|
2378
|
+
{ style: S.section },
|
|
2379
|
+
h('div', { style: S.head }, `${t('目录里已经没有(')}${orphanUsage.length})`),
|
|
2380
|
+
orphanUsage.map((item) => h('div', { key: item.name, style: S.row }, h('span', { style: S.mono }, item.name), h('span', { style: S.faint }, usageLine(item) ?? ''))),
|
|
2381
|
+
),
|
|
2382
|
+
h(
|
|
2383
|
+
'div',
|
|
2384
|
+
{ style: S.section },
|
|
2385
|
+
h('div', { style: S.head }, `${t('记忆(')}${memory.count}${t(' 条)')}`),
|
|
2386
|
+
(Array.isArray(memory.files) ? memory.files : []).map((file) =>
|
|
2387
|
+
h(
|
|
2388
|
+
'div',
|
|
2389
|
+
{ key: file.file, style: S.row },
|
|
2390
|
+
h(Link, { onClick: () => props.openPreview?.(file.path) }, h('span', { style: S.mono }, file.file)),
|
|
2391
|
+
/**
|
|
2392
|
+
* 一个记忆文件里的条目可能十几条 ⇒ 行里只给**前两条 + 共几条**,
|
|
2393
|
+
* 全文进 tooltip(§31:默认少而准,细节靠悬停/点开)。
|
|
2394
|
+
*/
|
|
2395
|
+
h(
|
|
2396
|
+
'span',
|
|
2397
|
+
{ style: S.faint, title: (file.entries ?? []).map((entry) => `${entry.kind === 'lesson' ? '经验' : '事实'}:${entry.title}`).join('\n') },
|
|
2398
|
+
(() => {
|
|
2399
|
+
const all = (file.entries ?? []).map((entry) => `${entry.kind === 'lesson' ? '经验' : '事实'}:${entry.title}`)
|
|
2400
|
+
if (all.length === 0) return ''
|
|
2401
|
+
return all.length <= 2 ? all.join(' / ') : `${all.slice(0, 2).join(' / ')}${t(' 等 ')}${all.length}${t(' 条')}`
|
|
2402
|
+
})(),
|
|
2403
|
+
),
|
|
2404
|
+
),
|
|
2405
|
+
),
|
|
2406
|
+
memory.count === 0 ? h('div', { style: S.faint }, t('还没有记忆。')) : null,
|
|
2407
|
+
),
|
|
2408
|
+
)
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2411
|
+
/**
|
|
2412
|
+
* 人门区(阶段 4,D2=B)。**人门优先于运行态**——ClearAI 的 UI 规则:等人的事永远最先说。
|
|
2413
|
+
* 写通道只有五个动词,而且是**只给人**的:模型能调的工具面里没有它们(DESIGN 的
|
|
2414
|
+
* 「读事实、只写人门」)。按下去之后由宿主平面把它变成一条带结构化标记的用户消息,
|
|
2415
|
+
* 折进投影(`by:'user'`),面板随投影自己更新。
|
|
2416
|
+
*/
|
|
2417
|
+
function Inbox(props) {
|
|
2418
|
+
const data = props.data
|
|
2419
|
+
const sessionId = data === null || data === undefined ? null : data.sessionId
|
|
2420
|
+
const items = data === null || data === undefined || !Array.isArray(data.inbox) ? [] : data.inbox
|
|
2421
|
+
const [busy, setBusy] = React.useState(null)
|
|
2422
|
+
const [error, setError] = React.useState(null)
|
|
2423
|
+
// 放弃的分叉:要写缘由才提交(两步),缘由按分叉 id 存着——
|
|
2424
|
+
// 同一个屏上可能有两条分叉,不能共用一个输入框。
|
|
2425
|
+
const [abandonFork, setAbandon] = React.useState(null)
|
|
2426
|
+
const [abandonNote, setAbandonNote] = React.useState('')
|
|
2427
|
+
if (items.length === 0) return null
|
|
2428
|
+
const send = (item, extra) => {
|
|
2429
|
+
setBusy(`${item.kind}:${item.plan || ''}:${item.step || ''}`)
|
|
2430
|
+
setError(null)
|
|
2431
|
+
// `gate` 只有「用提问卡决定」那一步用得上(§20):它是**界面手势**,不是事实动词。
|
|
2432
|
+
const body = { sessionId, action: item.human_action, plan: item.plan ?? null, fork: extra?.fork ?? null, branch: extra?.branch ?? null, gate: extra?.gate ?? null, skill: extra?.skill ?? null }
|
|
2433
|
+
fetch('/api/clearai/gate', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
|
|
2434
|
+
.then(readResponse)
|
|
2435
|
+
.then((result) => {
|
|
2436
|
+
if (result.ok !== true) setError(result.error)
|
|
2437
|
+
})
|
|
2438
|
+
.catch((thrown) => setError(String(thrown?.message ?? thrown)))
|
|
2439
|
+
.finally(() => setBusy(null))
|
|
2440
|
+
}
|
|
2441
|
+
/**
|
|
2442
|
+
* 收件箱里现在只有**真门**(计划确认已砍:它是记号不是闸门,见 fold 的 HUMAN_GATE_ACTIONS)。
|
|
2443
|
+
*
|
|
2444
|
+
* §37:门要什么由**数据**说(`item.needs`),不在这里按 kind 猜 ✗:
|
|
2445
|
+
* · `click` ⇒ 给按钮(白名单动词:裁决 / 采纳);
|
|
2446
|
+
* · `word` ⇒ 给**一句提示**(「说一句话就行 —— <要说什么>」),
|
|
2447
|
+
* 因为那种门本来就不是点击能表达的(复核 / 解除阻塞),而它照样会按住续跑。
|
|
2448
|
+
*/
|
|
2449
|
+
const label = (item) => (item.needs === 'click' ? (item.kind === 'skill_candidate' ? t('采纳') : t('裁决')) : null)
|
|
2450
|
+
return h(
|
|
2451
|
+
'div',
|
|
2452
|
+
{ style: S.gate },
|
|
2453
|
+
h('div', { style: S.head }, `${t('需要你 ')}${items.length}`),
|
|
2454
|
+
...(data.forks || []).filter((fork) => fork.phase === 'deciding').length > 0
|
|
2455
|
+
? (data.forks || [])
|
|
2456
|
+
.filter((fork) => fork.phase === 'deciding')
|
|
2457
|
+
.map((fork) =>
|
|
2458
|
+
h(
|
|
2459
|
+
'div',
|
|
2460
|
+
{ key: `adopt-${fork.id}`, style: S.gateRow },
|
|
2461
|
+
h('span', { style: S.mono }, `${fork.stepId} · ${fork.question}`),
|
|
2462
|
+
/**
|
|
2463
|
+
* §20 混合路径:同一道门,也可以摆到**原生提问卡**上去答。
|
|
2464
|
+
* 为什么两条路都留:面板这条是熟手的快路(一步到位);提问卡那条把
|
|
2465
|
+
* 判据、各分支的读数与推荐**铺在对话框里**,而且它会挡住输入框——
|
|
2466
|
+
* 人正在看对话时,那是最不会被忽略的位置。两条路的落账完全相同。
|
|
2467
|
+
*/
|
|
2468
|
+
h(
|
|
2469
|
+
'button',
|
|
2470
|
+
{
|
|
2471
|
+
type: 'button',
|
|
2472
|
+
className: 'clearai-btn',
|
|
2473
|
+
disabled: busy !== null,
|
|
2474
|
+
title: t('把这道裁决摆到原生提问卡上(带你读到的判据与各分支读数)'),
|
|
2475
|
+
onClick: () => send({ kind: 'fork_adopt', human_action: 'ask', plan: fork.plan ?? null, step: fork.stepId }, { gate: 'fork_adopt', fork: fork.id }),
|
|
2476
|
+
},
|
|
2477
|
+
t('用提问卡决定'),
|
|
2478
|
+
),
|
|
2479
|
+
...fork.branches.map((branch) =>
|
|
2480
|
+
h(
|
|
2481
|
+
'button',
|
|
2482
|
+
{
|
|
2483
|
+
key: branch.id,
|
|
2484
|
+
type: 'button',
|
|
2485
|
+
tone: 'warn',
|
|
2486
|
+
disabled: busy !== null,
|
|
2487
|
+
onClick: () => send({ kind: 'fork_adopt', human_action: 'adopt_branch', plan: fork.plan ?? null, step: fork.stepId }, { fork: fork.id, branch: branch.id }),
|
|
2488
|
+
},
|
|
2489
|
+
`${t('采纳 ')}${branch.label}${branch.reading === null || branch.reading === undefined ? '' : `(${branch.reading})`}`,
|
|
2490
|
+
),
|
|
2491
|
+
),
|
|
2492
|
+
/**
|
|
2493
|
+
* 放弃要留痕:两下才生效(先点开缘由框,写了才能提交)。
|
|
2494
|
+
* 单次点击就放弃会把「为什么」永远丢掉——而放弃正是最需要缘由的那一类决定。
|
|
2495
|
+
* 与树下详情同一条纪律(那边是按钮 disabled,这里是两步)。
|
|
2496
|
+
*/
|
|
2497
|
+
h(
|
|
2498
|
+
'button',
|
|
2499
|
+
{
|
|
2500
|
+
type: 'button',
|
|
2501
|
+
className: 'clearai-btn',
|
|
2502
|
+
disabled: busy !== null,
|
|
2503
|
+
onClick: () => setAbandon(fork.id),
|
|
2504
|
+
title: t('放弃要留痕:点一下写缘由,写了才能提交'),
|
|
2505
|
+
},
|
|
2506
|
+
t('放弃这条分叉'),
|
|
2507
|
+
),
|
|
2508
|
+
abandonFork === fork.id
|
|
2509
|
+
? h(
|
|
2510
|
+
'span',
|
|
2511
|
+
{ style: { display: 'inline-flex', gap: 6, alignItems: 'center' } },
|
|
2512
|
+
h('input', {
|
|
2513
|
+
value: abandonNote,
|
|
2514
|
+
placeholder: t('为什么放弃?(留痕可考)'),
|
|
2515
|
+
onChange: (event) => setAbandonNote(event.target.value),
|
|
2516
|
+
style: { fontSize: 11.5, padding: '2px 6px', borderRadius: 6, border: '.5px solid var(--dsw-alias-border-l3)', background: 'transparent', color: 'inherit', minWidth: 140 },
|
|
2517
|
+
}),
|
|
2518
|
+
h(
|
|
2519
|
+
'button',
|
|
2520
|
+
{
|
|
2521
|
+
type: 'button',
|
|
2522
|
+
className: 'clearai-btn',
|
|
2523
|
+
disabled: busy !== null || abandonNote.trim() === '',
|
|
2524
|
+
onClick: () => send({ kind: 'fork_adopt', human_action: 'abandon_fork', plan: fork.plan ?? null, step: fork.stepId }, { fork: fork.id, note: abandonNote.trim() }),
|
|
2525
|
+
title: abandonNote.trim() === '' ? t('缘由必填') : t('确认放弃(留档不删,ref 保留)'),
|
|
2526
|
+
},
|
|
2527
|
+
t('确认放弃'),
|
|
2528
|
+
),
|
|
2529
|
+
)
|
|
2530
|
+
: null,
|
|
2531
|
+
),
|
|
2532
|
+
)
|
|
2533
|
+
: [],
|
|
2534
|
+
...items.map((item, index) =>
|
|
2535
|
+
h(
|
|
2536
|
+
'div',
|
|
2537
|
+
{ key: `${item.kind}-${index}`, style: S.gateRow, title: item.kind },
|
|
2538
|
+
/** 机器词(`provisional_review` 这类)**不上屏** ✗ —— 进 tooltip,给人看的是标题那句话。 */
|
|
2539
|
+
label(item) === null ? null : h('span', { style: S.tagStrong }, `${t('要你')}${label(item)}`),
|
|
2540
|
+
h('span', null, item.title),
|
|
2541
|
+
h('span', { style: S.faint }, item.summary),
|
|
2542
|
+
label(item) !== null
|
|
2543
|
+
? h(
|
|
2544
|
+
'button',
|
|
2545
|
+
{ type: 'button', className: 'clearai-btn', disabled: busy !== null, onClick: () => send(item, null) },
|
|
2546
|
+
label(item),
|
|
2547
|
+
)
|
|
2548
|
+
: item.needs === 'word'
|
|
2549
|
+
? h('span', { style: { ...S.faint, opacity: 0.9 } }, `${t('说一句话就行 —— ')}${item.ask ?? '说一句你的决定'}`)
|
|
2550
|
+
: null,
|
|
2551
|
+
),
|
|
2552
|
+
),
|
|
2553
|
+
error === null ? null : h('div', { style: S.faint }, `${t('没送出去:')}${error}`),
|
|
2554
|
+
)
|
|
2555
|
+
}
|
|
2556
|
+
|
|
2557
|
+
/**
|
|
2558
|
+
* 旁观入口(阶段 4 的 4.5):点一下跳到那个子会话,**只看不动手**。
|
|
2559
|
+
* 判断不许匿名——评估者/仲裁是谁、在哪,面板上给得出入口。
|
|
2560
|
+
* 拿不到会话服务就退化成一行文本 id:宁可少一个按钮,也不假装能跳。
|
|
2561
|
+
*/
|
|
2562
|
+
function Spectator(props) {
|
|
2563
|
+
const sessionId = props.sessionId
|
|
2564
|
+
const open = props.open
|
|
2565
|
+
if (typeof sessionId !== 'string' || sessionId === '') return null
|
|
2566
|
+
if (typeof open !== 'function') return h('span', { style: S.faint }, `${t('评估者会话 ')}${sessionId}`)
|
|
2567
|
+
return h(
|
|
2568
|
+
'button',
|
|
2569
|
+
{ type: 'button', className: 'clearai-chip', title: sessionId, onClick: () => open(sessionId) },
|
|
2570
|
+
props.label ?? t('旁观评估者'),
|
|
2571
|
+
)
|
|
2572
|
+
}
|
|
2573
|
+
|
|
2574
|
+
/**
|
|
2575
|
+
* 续跑档的两个小工具(纯函数,便于直接断言文案)。
|
|
2576
|
+
*
|
|
2577
|
+
* **说行为,不说机制**(§18):人要回答的是「我走开的时候它还接着干吗」,不是「这是哪个档」。
|
|
2578
|
+
* 哲学词(人在场 / 无人值守)留在文档、运行态卡与提示词里——那些读者是模型和写文档的人;
|
|
2579
|
+
* 给人点的那一格说人话,并在 tooltip 里注明两个词是一回事。
|
|
2580
|
+
*
|
|
2581
|
+
* 为什么是「多问我 / 自己拿主意」而不是「先问我 / 别问我」:两档都**会**为不可约的判断开门
|
|
2582
|
+
* (湿实验、护栏级授权、L4 放行),所以只能写程度,不能写绝对——绝对的说法是假承诺。
|
|
2583
|
+
*/
|
|
2584
|
+
const TIER_TEXT = lazyTable(() => ({ attended: t('多问我'), unattended: t('自己拿主意') }))
|
|
2585
|
+
const TIER_HINT = lazyTable(() => ({
|
|
2586
|
+
attended: t('续跑:多问我——每个阶段收尾就停下,等你给下一阶段(文档里叫「人在场」)。点一下切成「自己拿主意」。'),
|
|
2587
|
+
unattended: t('续跑:自己拿主意——立约即授权,按轮数自己往下跑,只在不可约的判断上开门(文档里叫「无人值守」)。点一下切成「多问我」。'),
|
|
2588
|
+
}))
|
|
2589
|
+
const tierLabel = (value, pending) => `${TIER_TEXT[value] ?? value}${pending ? '(已切,下一步生效)' : ''}`
|
|
2590
|
+
|
|
2591
|
+
/**
|
|
2592
|
+
* 「多问我 / 自己跑」那个开关**删掉了**(§34)。
|
|
2593
|
+
*
|
|
2594
|
+
* 第一性原理 + 奥坎姆:「我要不要在场」是**运行时状态**——有没有门开着、有没有裁决在飞、
|
|
2595
|
+
* 有没有开着的步;这些系统自己知道 ✗,不该要用户预先声明。
|
|
2596
|
+
* 而且那一档还顺手把「计划经人确认」变成系统自己签的 ✗(与 L4「人放行」同一类病)。
|
|
2597
|
+
* 续跑本身走**原生 goal**(宿主的回合驱动);我们只决定"什么时候该布防、给多大保险丝"。
|
|
2598
|
+
*/
|
|
2599
|
+
/**
|
|
2600
|
+
* 计划的小图形(§18.3):一棵**迷你世界树**——一根脊柱、一条岔、两个节点。
|
|
2601
|
+
* 用 `currentColor`:颜色留给状态(等人确认 = 琥珀,受阻 = 琥珀,收尾 = 灰),形状留给语义。
|
|
2602
|
+
* 为什么要有它:工具行是要抢地盘的地方(实测:我们的两个字面项占了整行 28%,
|
|
2603
|
+
* 窄窗口下会把发送键挤到第二行)。图标能省一半宽度,而它指向的正是「世界树」那一页。
|
|
2604
|
+
*/
|
|
2605
|
+
function PlanGlyph() {
|
|
2606
|
+
return h(
|
|
2607
|
+
'svg',
|
|
2608
|
+
{ width: 12, height: 12, viewBox: '0 0 12 12', 'aria-hidden': 'true', style: { flex: '0 0 auto', display: 'block' } },
|
|
2609
|
+
h('path', { d: 'M3 2.2 V10.2', stroke: 'currentColor', strokeWidth: 1.2, fill: 'none', strokeLinecap: 'round' }),
|
|
2610
|
+
h('path', { d: 'M3 5.2 H8.4', stroke: 'currentColor', strokeWidth: 1.2, fill: 'none', strokeLinecap: 'round' }),
|
|
2611
|
+
h('circle', { cx: 3, cy: 2.2, r: 1.5, fill: 'currentColor' }),
|
|
2612
|
+
h('circle', { cx: 3, cy: 9.4, r: 1.4, fill: 'none', stroke: 'currentColor', strokeWidth: 1.1 }),
|
|
2613
|
+
h('circle', { cx: 9.2, cy: 5.2, r: 1.4, fill: 'none', stroke: 'currentColor', strokeWidth: 1.1 }),
|
|
2614
|
+
)
|
|
2615
|
+
}
|
|
2616
|
+
|
|
2617
|
+
/**
|
|
2618
|
+
* 计划芯片(§17.3):坐在原生 plan 座位上的那一格。
|
|
2619
|
+
*
|
|
2620
|
+
* 只说**计划**这一件事(进度、受阻、待确认),不重复状态条已经说过的话——
|
|
2621
|
+
* 一个事实只在一块面上说,是这个面板与原生 dock 之间的分工。
|
|
2622
|
+
* 没有计划时渲染 `null`:座位保持空着,而不是显一个「什么都没有」的假控件。
|
|
2623
|
+
* 它也不造第二个动词:推进的唯一动词是 `AdvancePlan`,那属于模型,不属于界面;
|
|
2624
|
+
* 这一格能做的只有一件——把右栏的「世界树」打开。
|
|
2625
|
+
*/
|
|
2626
|
+
function PlanChip(props) {
|
|
2627
|
+
const data = typeof props.useProjection === 'function' ? props.useProjection('clearai') : undefined
|
|
2628
|
+
const plan = data === null || data === undefined ? null : data.plan
|
|
2629
|
+
if (plan === null || plan === undefined) return null
|
|
2630
|
+
const total = plan.totalCount ?? 0
|
|
2631
|
+
const done = plan.advancedCount ?? 0
|
|
2632
|
+
/**
|
|
2633
|
+
* 说人话(§18):内部 id(`p-xxxx`)不进这一格——它对人没有信息量,只让芯片变长;
|
|
2634
|
+
* 状态优先于数字(等人确认 / 受阻 / 已收尾都是**要人注意**的那一类)。
|
|
2635
|
+
*/
|
|
2636
|
+
/**
|
|
2637
|
+
* 一格只放**一个符号**(§18.3):进度,或者一件要人注意的事。
|
|
2638
|
+
* 要人动手的那件事(等你确认)由**输入框下那条**说「需要你 N」——它在事实面上,
|
|
2639
|
+
* 而这里只是工具行里的一个指针,不能把两个事实挤在一格里。
|
|
2640
|
+
*/
|
|
2641
|
+
/**
|
|
2642
|
+
* §35:原来输入框下**单独占一行**的派生状态条删掉了 ——
|
|
2643
|
+
* 「已达成 · 100%」与原生目标提示、与这颗 chip 的 `3/8` 说的是同一件事 ✗,
|
|
2644
|
+
* 却把输入框整行顶上去 ✗。留下的只有**可点、且只有我们知道**的两件:
|
|
2645
|
+
* 需要你 N(人门计数,点了开世界树)· 续跑停着(为什么停,人是可以处置的)
|
|
2646
|
+
*/
|
|
2647
|
+
const inboxCount = Array.isArray(data?.inbox) ? data.inbox.length : 0
|
|
2648
|
+
const cont = data?.continuation ?? null
|
|
2649
|
+
const holdNote = cont === null || cont === undefined || cont.state !== 'paused' ? null : `${t('续跑停着:')}${HOLD_WHY[cont.why] ?? cont.why ?? '策略暂停'}`
|
|
2650
|
+
const pending = plan.confirmationPending === true
|
|
2651
|
+
const blocked = plan.blocked !== null && plan.blocked !== undefined
|
|
2652
|
+
const attention = pending || blocked
|
|
2653
|
+
/**
|
|
2654
|
+
* 符号**恒定是进度**(形状稳定才学得会):要人注意不在符号上换字,
|
|
2655
|
+
* 而是换颜色(与世界树同一条规矩:形状说状态,别让人去猜一个 '?')。
|
|
2656
|
+
* 「需要你 N」由输入框下那条说——那才是事实面该管的事。
|
|
2657
|
+
*/
|
|
2658
|
+
const symbol = `${done}/${total}`
|
|
2659
|
+
const brief = typeof plan.brief === 'string' && plan.brief.trim() !== '' ? plan.brief.trim() : null
|
|
2660
|
+
const meaning = pending ? t('计划在建,**等你确认**') : blocked ? t('计划受阻,等人处置') : plan.status === 'closed' ? `${t('计划已收尾(')}${done}/${total}${t(' 步)')}` : `${t('计划已交付 ')}${done}/${total}${t(' 步')}`
|
|
2661
|
+
/**
|
|
2662
|
+
* 等人时**只说一句**:先「需要你 N」(人门计数),没有门才说续跑停着的原因。
|
|
2663
|
+
* 两者是同一根轴(为什么在等人)⇒ 一格只放一个,不并列(§35)。
|
|
2664
|
+
*/
|
|
2665
|
+
const waiting = inboxCount > 0 ? `${t('需要你 ')}${inboxCount}` : null
|
|
2666
|
+
const attentionNow = attention || waiting !== null
|
|
2667
|
+
return h(
|
|
2668
|
+
'button',
|
|
2669
|
+
{
|
|
2670
|
+
type: 'button',
|
|
2671
|
+
className: 'clearai-toolctl',
|
|
2672
|
+
disabled: props.locked === true,
|
|
2673
|
+
title: `${meaning}${waiting === null ? '' : ` · ${waiting}`} · ${plan.id}${brief === null ? '' : ` · ${brief}`}${t('(点一下开右栏「世界树」看拓扑)')}`,
|
|
2674
|
+
'aria-label': `${meaning}${waiting === null ? '' : ` · ${waiting}`}${t('(点一下开右栏「世界树」)')}`,
|
|
2675
|
+
// 「要人注意」用与世界树同一族的琥珀(同一套主题令牌,不新造颜色)。
|
|
2676
|
+
style: attentionNow ? { color: 'rgb(var(--dsw-color-warning, 245 158 11))', fontWeight: 600 } : undefined,
|
|
2677
|
+
onClick: () => props.openRail?.('clearai-worldtree'),
|
|
2678
|
+
},
|
|
2679
|
+
h(PlanGlyph, null),
|
|
2680
|
+
h('span', { style: { minWidth: 14, textAlign: 'center' } }, symbol),
|
|
2681
|
+
waiting === null ? null : h('span', { style: { marginLeft: 6, fontSize: 11.5 } }, waiting),
|
|
2682
|
+
)
|
|
2683
|
+
}
|
|
2684
|
+
|
|
2685
|
+
/**
|
|
2686
|
+
* 策略暂停的**理由**对照表(值来自内核 `holdReason`,不是这里自创的):
|
|
2687
|
+
* 面板要说的正是「平台说不出的那一句」——为什么停。新增理由时这张表跟着补,
|
|
2688
|
+
* 补漏了也不骗人:认不出来的值原样显示,而不是显示一个体面的空话。
|
|
2689
|
+
*/
|
|
2690
|
+
/** 窗口停下的理由 = **真的有人的事**(§34 删掉了「一阶段收尾」那条:它是档位的表达)。 */
|
|
2691
|
+
const HOLD_WHY = lazyTable(() => ({
|
|
2692
|
+
audit: t('等独立裁决'),
|
|
2693
|
+
plan_confirm: t('计划待确认'),
|
|
2694
|
+
gate: t('有人在等你'),
|
|
2695
|
+
unknown: t('策略暂停(理由没记下,已在日志里告警)'),
|
|
2696
|
+
}))
|
|
2697
|
+
|
|
2698
|
+
/**
|
|
2699
|
+
* 一步的**人话**名字(§18):`do` 是模型写的那句「做什么」,截短了放进一行。
|
|
2700
|
+
* 内部步 id(`s-3`)只进 tooltip 与世界树——它在日志里是账,在界面上不是给人看的。
|
|
2701
|
+
*/
|
|
2702
|
+
function shortDo(text) {
|
|
2703
|
+
const value = String(text ?? '').trim()
|
|
2704
|
+
return value.length <= 12 ? value : `${value.slice(0, 12)}…`
|
|
2705
|
+
}
|
|
2706
|
+
|
|
2707
|
+
/**
|
|
2708
|
+
* **续跑注**(§35):只在**有话要说**时出现的一格。
|
|
2709
|
+
*
|
|
2710
|
+
* 输入框下那条整行删掉之后,它说的三句话里有两句**别处无处可说** ✗:
|
|
2711
|
+
* · 「续跑已撤回」——人按下清除的那一瞬,dock 已经消失,而内核还没到下一拍;
|
|
2712
|
+
* · 「续跑停着:<理由>」——平台只会说"目标已暂停",说不出**为什么**;
|
|
2713
|
+
* 所以把它们移到工具行(与计划 chip 同一行,**不占新行**):正常跑着时一个字都不说 ✓。
|
|
2714
|
+
*/
|
|
2715
|
+
function ContinuationNote(props) {
|
|
2716
|
+
const data = typeof props.useProjection === 'function' ? props.useProjection('clearai') : undefined
|
|
2717
|
+
const hostGoal = typeof props.useProjection === 'function' ? props.useProjection('goal') : undefined
|
|
2718
|
+
const cont = data === null || data === undefined ? null : (data.continuation ?? null)
|
|
2719
|
+
if (cont === null) return null
|
|
2720
|
+
const vanished = hostGoal === null || hostGoal === undefined
|
|
2721
|
+
/** 门要**一句话**时(dock 只会说"目标已暂停"),这句话说得更准:等你说一声 ✓ */
|
|
2722
|
+
const wordGate = Array.isArray(data?.inbox) && data.inbox.some((item) => item.needs === 'word')
|
|
2723
|
+
const holdText = wordGate ? t('等你说一句话') : (HOLD_WHY[cont.why] ?? cont.why ?? t('策略暂停'))
|
|
2724
|
+
const text =
|
|
2725
|
+
cont.state === 'withdrawn' || ((cont.state === 'armed' || cont.state === 'paused') && vanished)
|
|
2726
|
+
? t('续跑已撤回')
|
|
2727
|
+
: cont.state === 'paused'
|
|
2728
|
+
? `${t('续跑停着:')}${holdText}`
|
|
2729
|
+
: null
|
|
2730
|
+
if (text === null) return null
|
|
2731
|
+
return h(
|
|
2732
|
+
'span',
|
|
2733
|
+
{
|
|
2734
|
+
className: 'clearai-toolctl',
|
|
2735
|
+
style: { opacity: 0.8, fontSize: 11.5, cursor: 'default' },
|
|
2736
|
+
title: t('这是系统对自己说的话(我们自己按的暂停 / 人清掉的窗口),不是运行档'),
|
|
2737
|
+
},
|
|
2738
|
+
text,
|
|
2739
|
+
)
|
|
2740
|
+
}
|
|
2741
|
+
|
|
2742
|
+
/**
|
|
2743
|
+
* §35 输入框下那条派生状态条**删掉了**。
|
|
2744
|
+
*
|
|
2745
|
+
* 它说的事(阶段 / 完成度 / 当前步)与**原生目标提示**、与工具行那颗计划 chip 的 `N/M`
|
|
2746
|
+
* 说的是同一件 ✗ —— 而它**独占一行,把输入框整个顶上去** ✗。
|
|
2747
|
+
* 唯一可点、且只有我们知道的那一件(「需要你 N」)已经并进计划 chip:
|
|
2748
|
+
* 同一行、一点直达世界树。剩下的「为什么停」也在那颗 chip 上只说一句。
|
|
2749
|
+
*/
|
|
2750
|
+
/**
|
|
2751
|
+
* 席位跟着会话预设进出。
|
|
2752
|
+
*
|
|
2753
|
+
* 为什么必须这样:`conversation.view` 是**产品级**插座,它的主人遍历全部注册项投影出
|
|
2754
|
+
* 视图导航,不按会话过滤(实测 `dsh-client-ui-conversation` 的 viewTabs() 只读 id 与 label)。
|
|
2755
|
+
* 所以一个宿主平面的插件一旦注册,每个会话、每种模式都会多出一个页。
|
|
2756
|
+
* 这个面板是循证模式的一部分,它只在那个模式里该出现——于是注册与注销都跟着
|
|
2757
|
+
* 当前会话的 `projectionValues.agentPreset` 走。
|
|
2758
|
+
*/
|
|
2759
|
+
/** 包一层**只做一次**:现造会让 React 每次渲染都当成新组件,把面板整个重挂。 */
|
|
2760
|
+
const LocalizedDeliverables = withLocale(Deliverables)
|
|
2761
|
+
const LocalizedFacts = withLocale(Facts)
|
|
2762
|
+
const LocalizedWorldTree = withLocale(WorldTree)
|
|
2763
|
+
const LocalizedBrainTab = withLocale(BrainTab)
|
|
2764
|
+
|
|
2765
|
+
/** 会话预设的 id:面板只在这个模式的会话里出现。 */
|
|
2766
|
+
const PRESET_ID = 'clearai'
|
|
2767
|
+
|
|
2768
|
+
function apply(ctx) {
|
|
2769
|
+
// 这四个服务都声明在 `inject` 里,所以这里直接用属性读(Cordis 保证就位);
|
|
2770
|
+
// 用 `ctx.get()` 也读得到,但属性形式同时受 inject 的「服务重载后重新激活」保护。
|
|
2771
|
+
const { slots, sessions, sidebarRightTabs, sidebarRight } = ctx
|
|
2772
|
+
/**
|
|
2773
|
+
* 语言:接 DSH 原生的 locale 座位(可选 —— 老宿主或测试桩里没有它,面板退化成中文)。
|
|
2774
|
+
* 词典以**源文**为键注册进那个座位,于是 `t('已提出')` 走的是宿主那条查找链:
|
|
2775
|
+
* 缺一条英文就落回中文,而不是显示成 key。
|
|
2776
|
+
*/
|
|
2777
|
+
const locale = typeof ctx.get === 'function' ? ctx.get('locale') : undefined
|
|
2778
|
+
if (locale !== undefined && typeof locale.bind === 'function') {
|
|
2779
|
+
ctx.effect(() => locale.register(LOCALE_NS, { zh: LOCALE_ZH, en: LOCALE_EN }), 'clearai: locale dictionaries')
|
|
2780
|
+
localeBound = locale.bind(LOCALE_NS)
|
|
2781
|
+
/**
|
|
2782
|
+
* 语言变了要做两件事:① 让**组件**重画;② 把**座位重挂**一遍。
|
|
2783
|
+
*
|
|
2784
|
+
* ② 不是洁癖:座位上的标签是**注册那一刻**求值的,只重画组件的话,中栏页签会
|
|
2785
|
+
* 停在上一种语言(实测:整页已经切成中文,页签还写着 Deliverables / Facts)。
|
|
2786
|
+
* 走宿主自己的 `locale/change` 事件(它声明了这个事件,也正是为这种情况留的)。
|
|
2787
|
+
*/
|
|
2788
|
+
ctx.effect(() => {
|
|
2789
|
+
const onLocaleChange = () => {
|
|
2790
|
+
localeBound = locale.bind(LOCALE_NS)
|
|
2791
|
+
for (const listener of localeListeners) listener()
|
|
2792
|
+
for (const resync of localeResyncs) resync()
|
|
2793
|
+
}
|
|
2794
|
+
// 事件的订阅走宿主自己的 ctx.on;拿不到就退化成"只绑一次"(老宿主仍然能用,只是切语言要刷新)。
|
|
2795
|
+
if (typeof ctx.on !== 'function') return () => {}
|
|
2796
|
+
const off = ctx.on('locale/change', onLocaleChange)
|
|
2797
|
+
return typeof off === 'function' ? off : () => {}
|
|
2798
|
+
}, 'clearai: locale change')
|
|
2799
|
+
}
|
|
2800
|
+
// 面板的 CSS(原生那套药丸按钮与主题令牌):带 data-plugin 标记,插件卸载时一起收掉。
|
|
2801
|
+
ctx.effect(installStyles, 'clearai: panel css')
|
|
2802
|
+
|
|
2803
|
+
const isCurrentPreset = () => {
|
|
2804
|
+
try {
|
|
2805
|
+
const state = sessions.list.getSnapshot()
|
|
2806
|
+
const session = state === undefined || state.current === undefined ? undefined : state.byId[state.current]
|
|
2807
|
+
const preset = session === undefined || session === null ? undefined : session.projectionValues?.agentPreset
|
|
2808
|
+
return typeof preset === 'string' && preset === PRESET_ID
|
|
2809
|
+
} catch {
|
|
2810
|
+
return false
|
|
2811
|
+
}
|
|
2812
|
+
}
|
|
2813
|
+
|
|
2814
|
+
/** 打开一个子会话(旁观)。两个服务都问一遍,拿不到就返回 undefined——组件会退化成文本。 */
|
|
2815
|
+
const openSpectator = (id) => {
|
|
2816
|
+
if (typeof sessions.open === 'function') return sessions.open(id)
|
|
2817
|
+
const workspace = ctx.get('uiWorkspace')
|
|
2818
|
+
if (workspace !== undefined && typeof workspace.openSession === 'function') return workspace.openSession(id)
|
|
2819
|
+
return undefined
|
|
2820
|
+
}
|
|
2821
|
+
|
|
2822
|
+
const seats = []
|
|
2823
|
+
/**
|
|
2824
|
+
* `options` 可以是对象,也可以是**函数**。为什么要有函数这一路:座位上的标签在
|
|
2825
|
+
* 注册那一刻就被求值了,传对象等于把语言冻住(实测:整页切成中文,页签还写着英文)。
|
|
2826
|
+
* 传函数就让每次注册都重新求值——语言一变重挂一次,标签跟着走。
|
|
2827
|
+
*/
|
|
2828
|
+
const occupy = (name, options, component) => {
|
|
2829
|
+
let disposer = null
|
|
2830
|
+
const sync = () => {
|
|
2831
|
+
const wanted = isCurrentPreset()
|
|
2832
|
+
const resolved = typeof options === 'function' ? options() : options
|
|
2833
|
+
if (wanted && disposer === null) {
|
|
2834
|
+
disposer = slots.register({ name, ...resolved }, component)
|
|
2835
|
+
} else if (!wanted && disposer !== null) {
|
|
2836
|
+
disposer()
|
|
2837
|
+
disposer = null
|
|
2838
|
+
}
|
|
2839
|
+
}
|
|
2840
|
+
// 注册属于这个 fiber:`ctx.effect` 拿的是 `slots.inject` 的 disposer,
|
|
2841
|
+
// 插件被卸载时插座的等待与已经落下的席位一起收掉(原生插件同一写法)。
|
|
2842
|
+
ctx.effect(() => slots.inject(name, sync), `clearai: seat ${name}`)
|
|
2843
|
+
seats.push(sync)
|
|
2844
|
+
localeResyncs.add(() => {
|
|
2845
|
+
if (disposer === null) return
|
|
2846
|
+
disposer()
|
|
2847
|
+
disposer = null
|
|
2848
|
+
sync()
|
|
2849
|
+
})
|
|
2850
|
+
}
|
|
2851
|
+
|
|
2852
|
+
/**
|
|
2853
|
+
* 右栏三张页签(定案)。
|
|
2854
|
+
*
|
|
2855
|
+
* 为什么在右栏而不是中栏:`conversation.view` 是「一次看一个」的视图——看树时看不到对话;
|
|
2856
|
+
* 而右栏能与对话并排,还能拖出成浮窗、能全屏(树的车道是横向铺开的)。
|
|
2857
|
+
*
|
|
2858
|
+
* 注册契约(照抄 `dsh-client-ui-sidebar-files` 的写法):
|
|
2859
|
+
* · `sidebarRightTabs.register({id, kind, title, guide})` 注册**页签类型**;
|
|
2860
|
+
* · 体与标题各自注册在 `sidebar.right.pane.tab` / `sidebar.right.pane.tab.title`(keyed,钥匙是类型 id);
|
|
2861
|
+
* · `sidebarRight.openTab(kind)` 程序化打开(跨面跳转靠它)。
|
|
2862
|
+
*/
|
|
2863
|
+
/**
|
|
2864
|
+
* 图标挑法(原生那 75 个里选,语义要对得上):
|
|
2865
|
+
* · 世界树 = `IconBranchOutline16`(计划拓扑与分叉,原生就用它画分支)
|
|
2866
|
+
* · 技能 · 记忆 = `IconSkillOutline16`(原生专门有一枚技能图标)
|
|
2867
|
+
* 拿不到就留空 ⇒ 原生默认字形。
|
|
2868
|
+
*/
|
|
2869
|
+
/**
|
|
2870
|
+
* 右栏只剩两格(§23):**世界树**(计划的一切:脊柱、车道、闸门、要你拍板的那一下)
|
|
2871
|
+
* 与 **技能 · 记忆**(外脑)。
|
|
2872
|
+
*
|
|
2873
|
+
* 「进展」撤了:它原先装的四段各有归宿——计划与世界线的行归世界树,
|
|
2874
|
+
* 假设/观测/证据/事实归中栏「事实」,目标与判据归世界树的页眉,
|
|
2875
|
+
* 而"当下什么状态"由输入框下那条**常驻事实条**回答(它一直在,不用切页签)。
|
|
2876
|
+
* 页签越少,越不需要向人解释每个页签该在什么时候看。
|
|
2877
|
+
*/
|
|
2878
|
+
const rightRail = [
|
|
2879
|
+
{ id: 'clearai-worldtree', kind: 'clearai-worldtree', label: t('世界树'), order: 15, description: t('计划的拓扑与闸门:脊柱、叉开的车道、收在哪、要你拍哪一下'), icon: NATIVE_ICONS.IconBranchOutline16 },
|
|
2880
|
+
{ id: 'clearai-brain', kind: 'clearai-brain', label: t('技能 · 记忆'), order: 20, description: t('合并目录、本会话用法、可引用可采纳'), icon: NATIVE_ICONS.IconSkillOutline16 },
|
|
2881
|
+
]
|
|
2882
|
+
const shown = new Set()
|
|
2883
|
+
const syncRail = () => {
|
|
2884
|
+
const wanted = isCurrentPreset()
|
|
2885
|
+
if (!wanted) {
|
|
2886
|
+
for (const id of [...shown]) {
|
|
2887
|
+
const entry = railDisposers.get(id)
|
|
2888
|
+
if (entry !== undefined) entry()
|
|
2889
|
+
railDisposers.delete(id)
|
|
2890
|
+
shown.delete(id)
|
|
2891
|
+
}
|
|
2892
|
+
return
|
|
2893
|
+
}
|
|
2894
|
+
for (const tab of rightRail) {
|
|
2895
|
+
if (shown.has(tab.id)) continue
|
|
2896
|
+
shown.add(tab.id)
|
|
2897
|
+
const own = []
|
|
2898
|
+
try {
|
|
2899
|
+
own.push(ctx.effect(() => sidebarRightTabs.register({
|
|
2900
|
+
id: tab.id,
|
|
2901
|
+
kind: tab.kind,
|
|
2902
|
+
title: () => tab.label,
|
|
2903
|
+
guide: [{ order: tab.order, title: () => tab.label, description: () => tab.description, icon: tab.icon }],
|
|
2904
|
+
}), `clearai: rail type ${tab.id}`))
|
|
2905
|
+
} catch (error) {
|
|
2906
|
+
// 类型已注册(同名同 layer)不是致命:如实记一笔,继续。
|
|
2907
|
+
console.warn?.(`${t('clearai 面板:右栏页签类型注册失败 ')}${String(error?.message ?? error)}`)
|
|
2908
|
+
}
|
|
2909
|
+
const body =
|
|
2910
|
+
tab.body ??
|
|
2911
|
+
(tab.id === 'clearai-worldtree'
|
|
2912
|
+
? (props) =>
|
|
2913
|
+
h(LocalizedWorldTree, {
|
|
2914
|
+
...props,
|
|
2915
|
+
openPreview: openPreviewFor(props),
|
|
2916
|
+
openSpectator,
|
|
2917
|
+
/**
|
|
2918
|
+
* 反向跳(§27e):记住要展开哪条命题,再用**原生正门**切中栏视图。
|
|
2919
|
+
* `layout` 走可选读法(契约里就给了 `ctx.get('layout')` 这一路);
|
|
2920
|
+
* `selectPanel` 对未注册的 key 会抛 ⇒ 包起来,别把整棵树带下水。
|
|
2921
|
+
*/
|
|
2922
|
+
openFacts: (stepId) => {
|
|
2923
|
+
factsFocus.set({ step: stepId })
|
|
2924
|
+
const layout = ctx.get('layout')
|
|
2925
|
+
if (layout === undefined || typeof layout.selectPanel !== 'function') return false
|
|
2926
|
+
try {
|
|
2927
|
+
layout.selectPanel('clearai-facts')
|
|
2928
|
+
return true
|
|
2929
|
+
} catch {
|
|
2930
|
+
return false
|
|
2931
|
+
}
|
|
2932
|
+
},
|
|
2933
|
+
})
|
|
2934
|
+
: (props) => h(LocalizedBrainTab, { ...props, isCurrentPreset, openRail, openPreview: openPreviewFor(props) }))
|
|
2935
|
+
own.push(ctx.effect(() => slots.inject('sidebar.right.pane.tab', () => slots.register({ name: 'sidebar.right.pane.tab', key: tab.id }, body)), `clearai: rail body ${tab.id}`))
|
|
2936
|
+
own.push(ctx.effect(() => slots.inject('sidebar.right.pane.tab.title', () => slots.register({ name: 'sidebar.right.pane.tab.title', key: tab.id }, () => tab.label)), `clearai: rail title ${tab.id}`))
|
|
2937
|
+
railDisposers.set(tab.id, () => {
|
|
2938
|
+
for (const dispose of own) {
|
|
2939
|
+
try {
|
|
2940
|
+
dispose()
|
|
2941
|
+
} catch {
|
|
2942
|
+
/* 已经放过的不重复处理 */
|
|
2943
|
+
}
|
|
2944
|
+
}
|
|
2945
|
+
})
|
|
2946
|
+
}
|
|
2947
|
+
}
|
|
2948
|
+
const railDisposers = new Map()
|
|
2949
|
+
|
|
2950
|
+
/**
|
|
2951
|
+
* 打开右栏的某张页签(跨面跳转:点「需要你 N」→ 进展)。
|
|
2952
|
+
* 不再有我们自己的「预览」页签了:预览走**原生**的文档预览(`openNativePreview`)——
|
|
2953
|
+
* 那一条既有 markdown/图片/pdf/html 的渲染,也有它自己的分页读盘。
|
|
2954
|
+
*/
|
|
2955
|
+
const openRail = (kind) => {
|
|
2956
|
+
try {
|
|
2957
|
+
sidebarRight?.openTab?.(kind)
|
|
2958
|
+
return true
|
|
2959
|
+
} catch {
|
|
2960
|
+
return false
|
|
2961
|
+
}
|
|
2962
|
+
}
|
|
2963
|
+
|
|
2964
|
+
/**
|
|
2965
|
+
* 这条行所属的会话 id —— 预览地址里那一段**必须是它**。
|
|
2966
|
+
*
|
|
2967
|
+
* 2026-09-11 用户实测的 bug:原来这里是
|
|
2968
|
+
* `openPreview = (path) => openNativePreview(sidebarRight, typeof path === 'string' ? path : '', path)` ——
|
|
2969
|
+
* 第二参本该是**会话 id**,却把路径塞了进去。于是地址成了
|
|
2970
|
+
* `dsh-resource://file/session/products%2Freport.html`,原生读面按 SessionId 查不到
|
|
2971
|
+
* 工作区根,点每一条产物都报
|
|
2972
|
+
* `lookup provider "workspaceFileScope" did not resolve the requested identity`。
|
|
2973
|
+
* 三处入口(产物 / 技能 / 记忆文件)共用这一个闭包,所以是一处坏、三处全坏。
|
|
2974
|
+
*
|
|
2975
|
+
* 取法:座位自己的 `props.sessionId` 优先(更精确);props 没有时兜底读当前会话
|
|
2976
|
+
* (原生 chat 同款:`sessions.list.getSnapshot().current`)。兜底在这里成立,是因为
|
|
2977
|
+
* 面板**只在当前会话的预设是 clearai 时挂载**(`isCurrentPreset`,见上),会话切走
|
|
2978
|
+
* 时面板先注销 —— 不存在「面板还挂着、行的会话已经换了」的窗口。
|
|
2979
|
+
*/
|
|
2980
|
+
const sessionIdFor = (props) => {
|
|
2981
|
+
const fromProps = props === null || props === undefined ? undefined : props.sessionId
|
|
2982
|
+
if (typeof fromProps === 'string' && fromProps !== '') return fromProps
|
|
2983
|
+
try {
|
|
2984
|
+
const current = sessions.list.getSnapshot()?.current
|
|
2985
|
+
return typeof current === 'string' && current !== '' ? current : undefined
|
|
2986
|
+
} catch {
|
|
2987
|
+
return undefined
|
|
2988
|
+
}
|
|
2989
|
+
}
|
|
2990
|
+
/** 每个座位按自己的 props 造一个打开器:共用一个「反正差不多」的闭包就是上面那个 bug。 */
|
|
2991
|
+
const openPreviewFor = (props) => (path) => openNativePreview(sidebarRight, sessionIdFor(props), path)
|
|
2992
|
+
|
|
2993
|
+
/**
|
|
2994
|
+
* 中栏视图:**产物**(UI 定案 §7「中栏第一眼该看我拿到了什么」)。
|
|
2995
|
+
* 原来占着中栏的「循环」面板搬进右栏「进展」——那种全景适合与对话并排,
|
|
2996
|
+
* 而中栏这个位子(native 已有 chat / trajectory)该留给**结果**。
|
|
2997
|
+
*/
|
|
2998
|
+
occupy('conversation.view', () => ({ id: 'clearai-deliverables', order: 15, label: t('产物') }), (props) => h(LocalizedDeliverables, { ...props, openRail, openPreview: openPreviewFor(props) }))
|
|
2999
|
+
/**
|
|
3000
|
+
* 中栏视图:**事实**(§23)——整条闭环一屏看完(假设是起点,事实是沉淀)。
|
|
3001
|
+
* 放在「产物」之后:先看拿到了什么,再看**凭什么**(以及已经确认了什么)。
|
|
3002
|
+
* 这一格与模型读的 `clear/knowledge/facts/INDEX.md` 是**同一张表**。
|
|
3003
|
+
*/
|
|
3004
|
+
/**
|
|
3005
|
+
* 注册时必须把**打开器**交下去(§25 修):这一格的证据出处、事实原件、跳世界树
|
|
3006
|
+
* 全靠这两个回调。漏了它们,界面看着能点、点了什么也不会发生——
|
|
3007
|
+
* 这正是实测里「点击证据没反应」的真因。
|
|
3008
|
+
*/
|
|
3009
|
+
/**
|
|
3010
|
+
* 注册时必须把**打开器**都交下去(§27b):证据的四类出处、事实原件、跳世界树全靠它们。
|
|
3011
|
+
* 漏了它们,界面看着能点、点了什么也不会发生(实测踩过)。
|
|
3012
|
+
*/
|
|
3013
|
+
occupy('conversation.view', () => ({ id: 'clearai-facts', order: 20, label: t('事实') }), (props) => h(LocalizedFacts, { ...props, openRail, openSpectator, openPreview: openPreviewFor(props) }))
|
|
3014
|
+
/**
|
|
3015
|
+
* **计划面坐在原生 plan 那个座位上**(§17.3)。
|
|
3016
|
+
*
|
|
3017
|
+
* 为什么是这个座位:DSH 把「计划」这件事的控件固定在 composer 工具行的
|
|
3018
|
+
* `conversation.input.plan` 上(`dsh-client-ui-plan` 的「Plan ×」),而它只在
|
|
3019
|
+
* `plan/mode` 投影存在时才渲染——我们的预设不挂 `plan-mode`(计划是我们自己的事实,
|
|
3020
|
+
* 不是每回合的策略),所以那个座位在本模式下**一直空着**:位置在,没人说话。
|
|
3021
|
+
* 把自己的计划面放进去,就是「同一个位置、两种预设各自的计划面」,而不是另起一块浮层。
|
|
3022
|
+
*
|
|
3023
|
+
* 两条实现约束(读原生实现量出来的,不是猜的):
|
|
3024
|
+
* · 这个座位是 `single`:同 priority 再注册会**直接抛错**(核心的报错原文是
|
|
3025
|
+
* 「register at a different priority to shadow it (lowest renders)」),
|
|
3026
|
+
* 所以必须给一个更低的 priority 才遮蔽得住;
|
|
3027
|
+
* · 遮蔽的代价是零——原生那个 occupant 在本模式下本来 `return null`。
|
|
3028
|
+
* 而我们只在**当前会话是本预设**时占座(`occupy`),会话切走就还给它。
|
|
3029
|
+
*/
|
|
3030
|
+
occupy('conversation.input.plan', { priority: -1 }, (props) => h(PlanChip, { ...props, openRail }))
|
|
3031
|
+
occupy('conversation.input.left', { id: 'clearai-continuation', order: 110 }, (props) => h(ContinuationNote, props))
|
|
3032
|
+
/**
|
|
3033
|
+
* **续跑档控制搬进工具行**(§18.1:控制归工具行,事实归输入框下方)。
|
|
3034
|
+
* `conversation.input.left` 是**加法**座位(list),与原生「完全权限」并排——
|
|
3035
|
+
* 同一类东西(我能改的)放同一行,这是原生的排版,不是我们发明的。
|
|
3036
|
+
*/
|
|
3037
|
+
// 输入框下方的常驻派生条(运行态卡在人这一侧的对应物)
|
|
3038
|
+
/**
|
|
3039
|
+
* §35 **输入框下那一条不再注册**:它说的话(阶段 / 完成度 / 当前步)与原生目标提示、
|
|
3040
|
+
* 与工具行那颗计划 chip 重复 ✗,却**独占一行把输入框顶上去** ✗。
|
|
3041
|
+
* 唯一可点、且只有我们知道的那件事(「需要你 N」)已经并进计划 chip(同一行,一点直达世界树)。
|
|
3042
|
+
*/
|
|
3043
|
+
ctx.effect(() => sessions.list.subscribe(() => {
|
|
3044
|
+
for (const sync of seats) sync()
|
|
3045
|
+
syncRail()
|
|
3046
|
+
}), t('clearai-loop: 席位跟着会话预设进出'))
|
|
3047
|
+
syncRail()
|
|
3048
|
+
}
|
|
3049
|
+
|
|
3050
|
+
exports.apply = apply
|
|
3051
|
+
exports.inject = inject
|
|
3052
|
+
/**
|
|
3053
|
+
* 测试缝:世界树的拓扑摊平是**纯函数**,而渲染本身没法在没浏览器的地方验。
|
|
3054
|
+
* 把它导出,好让 `test/client.test.mjs` 直接断言拓扑(线性 / 分叉 / 收敛三种形态)。
|
|
3055
|
+
* 不是给别的包用的接口——真要复用,该搬进宿主侧的 fold。
|
|
3056
|
+
*/
|
|
3057
|
+
exports.__topology = { treeRows, treeConns, railPath, treeMarks, treeArcs, arcDash, treeLoops, TREE, TREE_COLOR, rowIndexOf, treeFocus, factsFocus, propositionForStep }
|
|
3058
|
+
/**
|
|
3059
|
+
* 测试缝之二:把组件本身交出去,好让测试用一个「渲染成字符串」的 React 桩
|
|
3060
|
+
* 真的跑一遍渲染路径(捕 undefined 字段访问这类只有渲染时才炸的错)。
|
|
3061
|
+
* 仍然不是给别的包用的接口。
|
|
3062
|
+
*/
|
|
3063
|
+
exports.__components = { PlanChip, ContinuationNote, Deliverables, WorldTree, BrainTab, Inbox, TreeDetail, Facts, FactShelf, PropositionShelf, ClearAIMark, LOOP_LABEL, PROPOSITION_GROUPS }
|
|
3064
|
+
/**
|
|
3065
|
+
* 测试缝之三:命题那一列的**派生**是纯函数(分组、处境、来路、证据链),
|
|
3066
|
+
* 渲染本身没法在没浏览器的地方细究——把它导出去,让测试直接断言派生结果。
|
|
3067
|
+
*/
|
|
3068
|
+
exports.__propositions = { evidenceOf, transitionsOf, whereOf, judgeOf, originsOf, stepOfFact }
|
|
3069
|
+
return module.exports
|
|
3070
|
+
},
|
|
3071
|
+
})
|