dsh-data-domain-knowledge 0.1.0-alpha.2 → 0.1.0-alpha.3
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/README.md +31 -7
- package/cordis.patch.yml +11 -5
- package/lib/client.js +5557 -3056
- package/lib/doc-page.js +7740 -0
- package/lib/types/convert/host.d.ts.map +1 -1
- package/lib/types/convert/host.js +36 -9
- package/lib/types/convert/host.js.map +1 -1
- package/lib/types/diagnosis/cards.d.ts +8 -7
- package/lib/types/diagnosis/cards.d.ts.map +1 -1
- package/lib/types/diagnosis/cards.js +119 -49
- package/lib/types/diagnosis/cards.js.map +1 -1
- package/lib/types/diagnosis/run.d.ts +51 -0
- package/lib/types/diagnosis/run.d.ts.map +1 -0
- package/lib/types/diagnosis/run.js +239 -0
- package/lib/types/diagnosis/run.js.map +1 -0
- package/lib/types/diagnosis/schema.d.ts +25 -2
- package/lib/types/diagnosis/schema.d.ts.map +1 -1
- package/lib/types/diagnosis/schema.js +64 -25
- package/lib/types/diagnosis/schema.js.map +1 -1
- package/lib/types/diagnosis/seed-source.d.ts +17 -2
- package/lib/types/diagnosis/seed-source.d.ts.map +1 -1
- package/lib/types/diagnosis/seed-source.js +18 -1
- package/lib/types/diagnosis/seed-source.js.map +1 -1
- package/lib/types/diagnosis/seed.d.ts +32 -42
- package/lib/types/diagnosis/seed.d.ts.map +1 -1
- package/lib/types/diagnosis/seed.js +187 -51
- package/lib/types/diagnosis/seed.js.map +1 -1
- package/lib/types/diagnosis/store.d.ts +114 -26
- package/lib/types/diagnosis/store.d.ts.map +1 -1
- package/lib/types/diagnosis/store.js +336 -167
- package/lib/types/diagnosis/store.js.map +1 -1
- package/lib/types/diagnosis/types.d.ts +168 -16
- package/lib/types/diagnosis/types.d.ts.map +1 -1
- package/lib/types/diagnosis/types.js +1 -1
- package/lib/types/diagnosis/types.js.map +1 -1
- package/lib/types/document-page.d.ts +39 -0
- package/lib/types/document-page.d.ts.map +1 -0
- package/lib/types/document-page.js +43 -0
- package/lib/types/document-page.js.map +1 -0
- package/lib/types/fs-atomic.d.ts +12 -0
- package/lib/types/fs-atomic.d.ts.map +1 -0
- package/lib/types/fs-atomic.js +42 -0
- package/lib/types/fs-atomic.js.map +1 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +214 -24
- package/lib/types/index.js.map +1 -1
- package/lib/types/knowledge-bases.d.ts +10 -2
- package/lib/types/knowledge-bases.d.ts.map +1 -1
- package/lib/types/knowledge-bases.js +20 -18
- package/lib/types/knowledge-bases.js.map +1 -1
- package/lib/types/proposals.d.ts +166 -0
- package/lib/types/proposals.d.ts.map +1 -0
- package/lib/types/proposals.js +238 -0
- package/lib/types/proposals.js.map +1 -0
- package/lib/types/runtime.d.ts +199 -7
- package/lib/types/runtime.d.ts.map +1 -1
- package/lib/types/runtime.js +728 -145
- package/lib/types/runtime.js.map +1 -1
- package/lib/types/settings-schema.d.ts +2 -2
- package/lib/types/types.d.ts +61 -10
- package/lib/types/types.d.ts.map +1 -1
- package/lib/types/types.js +56 -8
- package/lib/types/types.js.map +1 -1
- package/lib/types/wiki-types.d.ts +8 -1
- package/lib/types/wiki-types.d.ts.map +1 -1
- package/lib/types/wiki-types.js +16 -25
- package/lib/types/wiki-types.js.map +1 -1
- package/package.json +4 -3
- package/preset/tita-data-agent/agent.cordis.yml +11 -5
- package/src/client/DirectoryPicker.tsx +12 -12
- package/src/client/InfoHint.tsx +21 -8
- package/src/client/IntentTab.tsx +20 -26
- package/src/client/KnowledgeBaseTab.tsx +190 -80
- package/src/client/KnowledgePanel.tsx +42 -9
- package/src/client/KnowledgeStepRail.tsx +3 -0
- package/src/client/NodeSettings.tsx +20 -22
- package/src/client/PreviewSections.tsx +87 -33
- package/src/client/ProposalReview.tsx +313 -0
- package/src/client/TypeDefinitionDrawer.tsx +72 -38
- package/src/client/WikiTypeEditor.tsx +193 -77
- package/src/client/arch/AgentArchitectureGraph.tsx +143 -14
- package/src/client/arch/NodeLogicPanel.tsx +6 -3
- package/src/client/arch/model.ts +164 -26
- package/src/client/architecture.css +29 -9
- package/src/client/architecture.tsx +54 -29
- package/src/client/attribution/AttributionGraph.tsx +481 -34
- package/src/client/attribution/AttributionTab.tsx +507 -106
- package/src/client/attribution/CaseView.tsx +9 -14
- package/src/client/attribution/GraphStage.tsx +25 -18
- package/src/client/attribution/RunView.tsx +27 -9
- package/src/client/attribution/StageChrome.tsx +296 -140
- package/src/client/attribution/graph-options.ts +611 -47
- package/src/client/attribution/model.ts +559 -30
- package/src/client/convert/ConvertPanel.tsx +6 -1
- package/src/client/doc-page.css +134 -0
- package/src/client/doc-page.tsx +263 -0
- package/src/client/panel-state.ts +105 -0
- package/src/client/request.ts +36 -0
- package/src/client/styles.css +317 -43
- package/src/client/useFitScale.ts +15 -2
- package/src/client/wiki-type-groups.ts +86 -0
- package/src/convert/host.ts +41 -7
- package/src/diagnosis/cards.ts +120 -50
- package/src/diagnosis/run.ts +245 -0
- package/src/diagnosis/schema.ts +67 -26
- package/src/diagnosis/seed-source.ts +28 -3
- package/src/diagnosis/seed.ts +187 -85
- package/src/diagnosis/store.ts +358 -174
- package/src/diagnosis/types.ts +181 -16
- package/src/document-page.ts +51 -0
- package/src/fs-atomic.ts +42 -0
- package/src/index.ts +209 -22
- package/src/knowledge-bases.ts +20 -18
- package/src/proposals.ts +337 -0
- package/src/runtime.ts +736 -135
- package/src/types.ts +113 -19
- package/src/wiki-types.ts +17 -27
|
@@ -361,7 +361,12 @@ export function ConvertPanel({ visible }: ConvertPanelProps): ReactNode {
|
|
|
361
361
|
<Button variant="ghost" size="sm" title="详情" onClick={() => void openTask(task)}><ScanEye size={13} /></Button>
|
|
362
362
|
{task.state === 'running' || task.state === 'queued'
|
|
363
363
|
? <Button variant="ghost" size="sm" title="取消" onClick={() => void act(task.id, 'cancel')}><X size={13} /></Button>
|
|
364
|
-
:
|
|
364
|
+
: task.state === 'failed' || task.state === 'cancelled'
|
|
365
|
+
// 只有这两个状态还能重试。已交付的任务**输入在交付后就清理了**,
|
|
366
|
+
// 给它一枚重试按钮,点了只会得到 TASK_ALREADY_DELIVERED,
|
|
367
|
+
// 而修复之前那一次点击还会顺手删掉刚交付的产物。
|
|
368
|
+
? <Button variant="ghost" size="sm" title="重试" onClick={() => void act(task.id, 'retry')}><RotateCcw size={13} /></Button>
|
|
369
|
+
: null}
|
|
365
370
|
{task.state === 'succeeded' && (
|
|
366
371
|
<a className="tita-uiButton" href={`${BASE}/api/tasks/${encodeURIComponent(task.id)}/files/index.md`} target="_blank" rel="noreferrer" title="打开 index.md">
|
|
367
372
|
<Download size={13} />
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 独立整页(SourceRaw 全文预览)的页面样式。
|
|
3
|
+
*
|
|
4
|
+
* 它是一份**单独的浏览器文档**:没有 DSH 外壳、没有侧栏的 380px 宽度限制,唯一的
|
|
5
|
+
* 目的是把一份很长的原文读下来。所以这里的排版决定都围绕阅读:固定一列约 72ch 的
|
|
6
|
+
* 字号宽度、行高 1.75、顶部一条常驻的窄条回答「我在读什么、读到哪儿了」。
|
|
7
|
+
*
|
|
8
|
+
* 颜色全部走共享令牌(`@tita-data-agent/ui/styles.css` 带进来的那一套),元素排版走
|
|
9
|
+
* `@tita-data-agent/ui/markdown.css`。这一页不引入任何 Tailwind 工具类:它要能在一个
|
|
10
|
+
* 没有 DSH 模块表的文档里自足,样式越少越不容易在别处漂移。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
:root { color-scheme: light; }
|
|
14
|
+
|
|
15
|
+
html { -webkit-text-size-adjust: 100%; }
|
|
16
|
+
|
|
17
|
+
body {
|
|
18
|
+
margin: 0;
|
|
19
|
+
background: var(--tita-canvas);
|
|
20
|
+
color: var(--tita-ink);
|
|
21
|
+
font-family: var(--tita-font);
|
|
22
|
+
font-size: 15px;
|
|
23
|
+
line-height: 1.75;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/* 常驻窄条:长文档滚到一半时,「这是哪一份、还有多少」必须还在视野里。 */
|
|
27
|
+
.docPage-bar {
|
|
28
|
+
position: sticky;
|
|
29
|
+
top: 0;
|
|
30
|
+
z-index: 2;
|
|
31
|
+
display: flex;
|
|
32
|
+
align-items: center;
|
|
33
|
+
gap: 10px;
|
|
34
|
+
padding: 9px max(24px, calc(50vw - 380px));
|
|
35
|
+
border-bottom: 1px solid var(--tita-line);
|
|
36
|
+
background: color-mix(in srgb, var(--tita-surface) 92%, transparent);
|
|
37
|
+
backdrop-filter: blur(6px);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.docPage-bar h1 {
|
|
41
|
+
min-width: 0;
|
|
42
|
+
flex: 1 1 auto;
|
|
43
|
+
margin: 0;
|
|
44
|
+
overflow: hidden;
|
|
45
|
+
font-size: 14px;
|
|
46
|
+
font-weight: 680;
|
|
47
|
+
letter-spacing: -0.01em;
|
|
48
|
+
text-overflow: ellipsis;
|
|
49
|
+
white-space: nowrap;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.docPage-barMeta { flex: 0 0 auto; color: var(--tita-ink-muted); font-size: 11.5px; }
|
|
53
|
+
|
|
54
|
+
.docPage-main { padding: 26px 24px 96px; }
|
|
55
|
+
|
|
56
|
+
/* 正文一列:65–75ch 是阅读行长的区间,中文按 15px 算约 40 个汉字换成 760px 上限。 */
|
|
57
|
+
.docPage-column { max-width: 760px; margin: 0 auto; }
|
|
58
|
+
|
|
59
|
+
.docPage-head { margin-bottom: 22px; }
|
|
60
|
+
|
|
61
|
+
.docPage-head h2 {
|
|
62
|
+
margin: 0 0 6px;
|
|
63
|
+
font-size: 22px;
|
|
64
|
+
font-weight: 700;
|
|
65
|
+
letter-spacing: -0.02em;
|
|
66
|
+
line-height: 1.3;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.docPage-headMeta { margin: 0; color: var(--tita-ink-muted); font-size: 12.5px; }
|
|
70
|
+
/* 路径与 id 是**逐字比对**的值:等宽,别和正文混成一个字体。 */
|
|
71
|
+
.docPage-headMeta code {
|
|
72
|
+
font-family: var(--tita-mono);
|
|
73
|
+
font-size: 0.92em;
|
|
74
|
+
}
|
|
75
|
+
.docPage-headMeta code:last-child { word-break: break-all; }
|
|
76
|
+
|
|
77
|
+
/* 分页读取:每一段自己是一个渲染单元(见 doc-page.tsx 的理由)。段与段之间靠
|
|
78
|
+
首个子元素的上外边距接上 —— `markdown.css` 把每段第一个子元素的上边距清零了
|
|
79
|
+
(那是给抽屉顶部用的),所以第二段起要把它还回来,否则接缝处会贴在一起。 */
|
|
80
|
+
.docPage-segment + .docPage-segment .tita-markdownLocal > :first-child { margin-top: 1.2em; }
|
|
81
|
+
|
|
82
|
+
.docPage-foot {
|
|
83
|
+
display: flex;
|
|
84
|
+
flex-direction: column;
|
|
85
|
+
align-items: center;
|
|
86
|
+
gap: 8px;
|
|
87
|
+
margin-top: 34px;
|
|
88
|
+
padding-top: 18px;
|
|
89
|
+
border-top: 1px solid var(--tita-line);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.docPage-progress { color: var(--tita-ink-muted); font-size: 12px; font-variant-numeric: tabular-nums; }
|
|
93
|
+
|
|
94
|
+
.docPage-more {
|
|
95
|
+
height: 32px;
|
|
96
|
+
padding: 0 16px;
|
|
97
|
+
border: 1px solid var(--tita-line-strong);
|
|
98
|
+
border-radius: 999px;
|
|
99
|
+
background: var(--tita-surface);
|
|
100
|
+
color: var(--tita-ink);
|
|
101
|
+
font: inherit;
|
|
102
|
+
font-size: 13px;
|
|
103
|
+
font-weight: 600;
|
|
104
|
+
cursor: pointer;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.docPage-more:hover { border-color: var(--tita-primary); color: var(--tita-primary); }
|
|
108
|
+
.docPage-more:disabled { opacity: 0.55; cursor: default; }
|
|
109
|
+
.docPage-more:focus-visible { outline: 3px solid var(--tita-focus-ring); outline-offset: 1px; }
|
|
110
|
+
|
|
111
|
+
/* 自动加载的哨兵:它没有内容,只是「滚到这里就取下一页」的位置标记。 */
|
|
112
|
+
.docPage-sentinel { height: 1px; }
|
|
113
|
+
|
|
114
|
+
.docPage-state {
|
|
115
|
+
display: flex;
|
|
116
|
+
flex-direction: column;
|
|
117
|
+
align-items: flex-start;
|
|
118
|
+
gap: 8px;
|
|
119
|
+
max-width: 760px;
|
|
120
|
+
margin: 0 auto;
|
|
121
|
+
color: var(--tita-ink-muted);
|
|
122
|
+
font-size: 14px;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.docPage-state h2 { margin: 0; color: var(--tita-ink); font-size: 17px; font-weight: 680; }
|
|
126
|
+
.docPage-state p { margin: 0; line-height: 1.7; }
|
|
127
|
+
.docPage-state code {
|
|
128
|
+
padding: 0.12em 0.36em;
|
|
129
|
+
border: 1px solid var(--tita-line);
|
|
130
|
+
border-radius: 4px;
|
|
131
|
+
background: var(--tita-surface-muted);
|
|
132
|
+
font-family: var(--tita-mono);
|
|
133
|
+
font-size: 0.88em;
|
|
134
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SourceRaw 的**独立整页**:在一个新标签里读一份原文的全文。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要它(而不是把抽屉加宽):抽屉只有 380px,而原文可能是一份几十万字的转换
|
|
5
|
+
* 文档。抽屉因此只发前 `SOURCE_PREVIEW_CHARS`(4000)个字符 —— 那是预览,不是阅读。
|
|
6
|
+
* 要读全文就得有一个**为阅读建的**页面:整页宽度、常驻的标题条、一列 65–75ch 的行长。
|
|
7
|
+
*
|
|
8
|
+
* 三个决定:
|
|
9
|
+
*
|
|
10
|
+
* 1. **分页取,不一次取全文**。「打开要等半天」的成因就是把整份正文一次发过来;
|
|
11
|
+
* 这里一次只要 20000 字符,滚到底再取下一页(哨兵自动取,按钮是它的可见形态与
|
|
12
|
+
* 无障碍通路)。所以这份页面第一次可读的时间与文档大小**无关**。
|
|
13
|
+
* 2. **每页各自是一个渲染单元**,不把累计文本拼成一份再渲染。拼起来等于每翻一页
|
|
14
|
+
* 都把前面重渲染一遍(O(n²) 的渲染开销);分开渲染让每页的成本只和这一页有关,
|
|
15
|
+
* 代价是分页接缝可能落在一个块中间 —— 服务端已经把切点对齐到空行。
|
|
16
|
+
* 3. **它没有 DSH 模块表**:一个普通浏览器文档,所以正文走共享的本地子集渲染器
|
|
17
|
+
* (`@tita-data-agent/ui/markdown`),而不是面板里那个平台原子。这也是渲染器
|
|
18
|
+
* 必须有共享一份的原因。
|
|
19
|
+
*
|
|
20
|
+
* 入口地址:`/knowledge/documents/<docId>/view?kb=<知识库 id>`(外壳由宿主渲染,
|
|
21
|
+
* 见 `standaloneDocumentHtml()`)。
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { StrictMode, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
|
25
|
+
import { createRoot } from 'react-dom/client'
|
|
26
|
+
import { LocalMarkdown } from '@tita-data-agent/ui/markdown'
|
|
27
|
+
import '@tita-data-agent/ui/styles.css'
|
|
28
|
+
import '@tita-data-agent/ui/markdown.css'
|
|
29
|
+
import './doc-page.css'
|
|
30
|
+
import { formatBytes } from './format.ts'
|
|
31
|
+
|
|
32
|
+
/** 一次取多少字符。与抽屉的预览上限同量级:一页 ≈ 一次能读完的一屏多一点。 */
|
|
33
|
+
const PAGE_CHARS = 20000
|
|
34
|
+
|
|
35
|
+
interface AssetRef { rel: string; bytes: number; url: string }
|
|
36
|
+
|
|
37
|
+
interface DocumentMeta {
|
|
38
|
+
docId: string
|
|
39
|
+
title: string
|
|
40
|
+
sourceType: string
|
|
41
|
+
sourceTypeKnown?: boolean
|
|
42
|
+
sourceFormat: string
|
|
43
|
+
parser: string
|
|
44
|
+
bytes: number
|
|
45
|
+
contentChars: number
|
|
46
|
+
contentTruncated: boolean
|
|
47
|
+
relativePath: string
|
|
48
|
+
ext: string
|
|
49
|
+
updatedAt: string
|
|
50
|
+
assets: AssetRef[]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface ContentPage {
|
|
54
|
+
text: string
|
|
55
|
+
offset: number
|
|
56
|
+
nextOffset: number
|
|
57
|
+
totalChars: number
|
|
58
|
+
eof: boolean
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface Envelope<T> { ok?: boolean; data?: T; error?: { message?: string; suggestion?: string } }
|
|
62
|
+
|
|
63
|
+
async function requestJson<T>(url: string): Promise<T> {
|
|
64
|
+
const response = await fetch(url)
|
|
65
|
+
const text = await response.text()
|
|
66
|
+
if (!text.trim()) throw new Error(`服务端返回空响应(HTTP ${response.status})`)
|
|
67
|
+
let payload: Envelope<T>
|
|
68
|
+
try { payload = JSON.parse(text) as Envelope<T> } catch { throw new Error(`服务端返回了无效响应(HTTP ${response.status})`) }
|
|
69
|
+
if (!response.ok || payload.ok === false) throw new Error(payload.error?.message ?? `请求失败(${response.status})`)
|
|
70
|
+
if (payload.data === undefined) throw new Error('服务端没有返回内容')
|
|
71
|
+
return payload.data
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 这个页面只服务一条地址形状;不匹配时如实说。 */
|
|
75
|
+
function readTarget(): { docId: string; kb: string } | undefined {
|
|
76
|
+
const match = /^\/knowledge\/documents\/([^/]+)\/view$/.exec(window.location.pathname)
|
|
77
|
+
if (match === null) return undefined
|
|
78
|
+
let docId = match[1] as string
|
|
79
|
+
try { docId = decodeURIComponent(docId) } catch { /* 不是合法的转义就按原样用 */ }
|
|
80
|
+
return { docId, kb: new URLSearchParams(window.location.search).get('kb') ?? '' }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 正文里的图片引用 → 能加载的 URL。
|
|
85
|
+
*
|
|
86
|
+
* 服务端在详情里已经把它认出来的每一张图放在 `assets` 里(`rel` 是相对知识库根,
|
|
87
|
+
* `url` 是 `/knowledge/assets/...`),但正文里写的是**相对这篇文档**的引用
|
|
88
|
+
* (`assets/pic-1.png`),两者不是同一个字符串。所以按三步认:整段相对路径相等、
|
|
89
|
+
* 以它为结尾、再退到文件名。都认不出来就交回 `null`,由渲染器显示 alt 文字 ——
|
|
90
|
+
* 显示一个坏图比显示「这张图没认出来」更糟。
|
|
91
|
+
*/
|
|
92
|
+
function imageResolverOf(assets: AssetRef[]): ((source: string) => string | null) | undefined {
|
|
93
|
+
if (assets.length === 0) return undefined
|
|
94
|
+
const byRel = new Map(assets.map(asset => [asset.rel, asset.url]))
|
|
95
|
+
const byName = new Map<string, string>()
|
|
96
|
+
for (const asset of assets) {
|
|
97
|
+
const name = asset.rel.split('/').pop() ?? ''
|
|
98
|
+
if (name !== '' && !byName.has(name)) byName.set(name, asset.url)
|
|
99
|
+
}
|
|
100
|
+
return (source: string): string | null => {
|
|
101
|
+
if (/^(?:https?:|data:)/i.test(source)) return source
|
|
102
|
+
const clean = source.replace(/^\.\//, '').replace(/^\/+/, '')
|
|
103
|
+
const exact = byRel.get(clean)
|
|
104
|
+
if (exact !== undefined) return exact
|
|
105
|
+
for (const [rel, url] of byRel) if (rel.endsWith(`/${clean}`)) return url
|
|
106
|
+
const name = clean.split('/').pop() ?? ''
|
|
107
|
+
return byName.get(name) ?? null
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function App(): ReactNode {
|
|
112
|
+
const target = useMemo(readTarget, [])
|
|
113
|
+
const [phase, setPhase] = useState<'loading' | 'ready' | 'error'>('loading')
|
|
114
|
+
const [failure, setFailure] = useState<string>('')
|
|
115
|
+
const [meta, setMeta] = useState<DocumentMeta | null>(null)
|
|
116
|
+
const [segments, setSegments] = useState<string[]>([])
|
|
117
|
+
const [shown, setShown] = useState(0)
|
|
118
|
+
const [totalChars, setTotalChars] = useState(0)
|
|
119
|
+
const [eof, setEof] = useState(false)
|
|
120
|
+
const [busy, setBusy] = useState(false)
|
|
121
|
+
const [pageError, setPageError] = useState<string | null>(null)
|
|
122
|
+
const [reloadToken, setReloadToken] = useState(0)
|
|
123
|
+
|
|
124
|
+
/** 取到哪儿了 / 有没有在取 —— 观察器回调与按钮都可能触发,用 ref 挡住重复请求。 */
|
|
125
|
+
const offsetRef = useRef(0)
|
|
126
|
+
const busyRef = useRef(false)
|
|
127
|
+
const sentinelRef = useRef<HTMLDivElement | null>(null)
|
|
128
|
+
|
|
129
|
+
const loadPage = useCallback(async (from: number): Promise<void> => {
|
|
130
|
+
if (target === undefined || busyRef.current) return
|
|
131
|
+
busyRef.current = true
|
|
132
|
+
setBusy(true)
|
|
133
|
+
setPageError(null)
|
|
134
|
+
try {
|
|
135
|
+
const query = new URLSearchParams({ kb: target.kb, offset: String(from), limit: String(PAGE_CHARS) })
|
|
136
|
+
const page = await requestJson<ContentPage>(`/knowledge/documents/${encodeURIComponent(target.docId)}/content?${query.toString()}`)
|
|
137
|
+
if (page.text !== '') setSegments(previous => [...previous, page.text])
|
|
138
|
+
offsetRef.current = page.nextOffset
|
|
139
|
+
setShown(page.nextOffset)
|
|
140
|
+
setTotalChars(page.totalChars)
|
|
141
|
+
setEof(page.eof)
|
|
142
|
+
} catch (error) {
|
|
143
|
+
setPageError(error instanceof Error ? error.message : String(error))
|
|
144
|
+
} finally {
|
|
145
|
+
busyRef.current = false
|
|
146
|
+
setBusy(false)
|
|
147
|
+
}
|
|
148
|
+
}, [target])
|
|
149
|
+
|
|
150
|
+
/** 第一页:先把详情读来(标题、大小、资产),再取正文第一页。 */
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
if (target === undefined) { setPhase('error'); return undefined }
|
|
153
|
+
let cancelled = false
|
|
154
|
+
offsetRef.current = 0
|
|
155
|
+
setPhase('loading'); setFailure('')
|
|
156
|
+
setSegments([]); setShown(0); setTotalChars(0); setEof(false); setPageError(null)
|
|
157
|
+
void (async () => {
|
|
158
|
+
try {
|
|
159
|
+
const query = new URLSearchParams(target.kb === '' ? {} : { kb: target.kb })
|
|
160
|
+
const payload = await requestJson<{ document: DocumentMeta }>(`/knowledge/documents/${encodeURIComponent(target.docId)}?${query.toString()}`)
|
|
161
|
+
if (cancelled) return
|
|
162
|
+
setMeta(payload.document)
|
|
163
|
+
setTotalChars(payload.document.contentChars)
|
|
164
|
+
// 浏览器标签页的标题:整页是拿来和别的东西并排看的,标签上得认得出是哪一份。
|
|
165
|
+
window.document.title = `${payload.document.title} · 原文`
|
|
166
|
+
setPhase('ready')
|
|
167
|
+
await loadPage(0)
|
|
168
|
+
} catch (error) {
|
|
169
|
+
if (cancelled) return
|
|
170
|
+
setFailure(error instanceof Error ? error.message : String(error))
|
|
171
|
+
setPhase('error')
|
|
172
|
+
}
|
|
173
|
+
})()
|
|
174
|
+
return () => { cancelled = true }
|
|
175
|
+
}, [target, loadPage, reloadToken])
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* 滚到底自动取下一页。
|
|
179
|
+
*
|
|
180
|
+
* `rootMargin` 提前 600px 触发,所以正常阅读不会看到「正在读」这一行 —— 按钮仍然
|
|
181
|
+
* 留在页脚,它是这条通路的**可见形态**:没有 JS 观察器的环境(或用户就是想手动翻)
|
|
182
|
+
* 也能读完,键盘用户 Tab 得到它。
|
|
183
|
+
*/
|
|
184
|
+
useEffect(() => {
|
|
185
|
+
const node = sentinelRef.current
|
|
186
|
+
if (node === null || phase !== 'ready' || eof) return undefined
|
|
187
|
+
const observer = new IntersectionObserver(
|
|
188
|
+
entries => { if (entries.some(entry => entry.isIntersecting)) void loadPage(offsetRef.current) },
|
|
189
|
+
{ rootMargin: '600px 0px' },
|
|
190
|
+
)
|
|
191
|
+
observer.observe(node)
|
|
192
|
+
return () => observer.disconnect()
|
|
193
|
+
}, [phase, eof, loadPage, segments.length])
|
|
194
|
+
|
|
195
|
+
const resolveImage = useMemo(() => imageResolverOf(meta?.assets ?? []), [meta])
|
|
196
|
+
|
|
197
|
+
if (target === undefined) {
|
|
198
|
+
return <main className="docPage-main"><div className="docPage-state">
|
|
199
|
+
<h2>这个地址不是一份文档的整页</h2>
|
|
200
|
+
<p>整页的地址形状是 <code>/knowledge/documents/<docId>/view?kb=<知识库 id></code>。
|
|
201
|
+
它由知识库面板里「原文 → 内容 → 新页面打开」打开,不需要手写。</p>
|
|
202
|
+
</div></main>
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (phase === 'loading') {
|
|
206
|
+
return <main className="docPage-main"><p className="docPage-state" role="status">正在读取这份原文…</p></main>
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (phase === 'error' || meta === null) {
|
|
210
|
+
return <main className="docPage-main"><div className="docPage-state">
|
|
211
|
+
<h2>没能读到这份原文</h2>
|
|
212
|
+
<p>文档 id:<code>{target.docId}</code></p>
|
|
213
|
+
{failure !== '' && <p role="alert">{failure}</p>}
|
|
214
|
+
<button type="button" className="docPage-more" onClick={() => setReloadToken(token => token + 1)}>重试</button>
|
|
215
|
+
</div></main>
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const metaLine = [
|
|
219
|
+
meta.sourceTypeKnown === false ? `${meta.sourceType}(未知分类)` : meta.sourceType,
|
|
220
|
+
meta.ext,
|
|
221
|
+
meta.sourceFormat || 'unknown',
|
|
222
|
+
formatBytes(meta.bytes),
|
|
223
|
+
`${meta.contentChars.toLocaleString()} 字符`,
|
|
224
|
+
].join(' · ')
|
|
225
|
+
|
|
226
|
+
return <>
|
|
227
|
+
<header className="docPage-bar">
|
|
228
|
+
<h1 title={meta.title}>{meta.title}</h1>
|
|
229
|
+
<span className="docPage-barMeta">
|
|
230
|
+
{eof ? '已到末尾' : `已显示 ${shown.toLocaleString()} / ${totalChars.toLocaleString()} 字符`}
|
|
231
|
+
</span>
|
|
232
|
+
</header>
|
|
233
|
+
<main className="docPage-main">
|
|
234
|
+
<div className="docPage-column">
|
|
235
|
+
<div className="docPage-head">
|
|
236
|
+
<h2>{meta.title}</h2>
|
|
237
|
+
<p className="docPage-headMeta">{metaLine} · <code>{meta.relativePath}</code></p>
|
|
238
|
+
</div>
|
|
239
|
+
|
|
240
|
+
{segments.map((text, index) => <div className="docPage-segment" key={`segment-${index}`}>
|
|
241
|
+
<LocalMarkdown text={text} {...(resolveImage === undefined ? {} : { resolveImage })} />
|
|
242
|
+
</div>)}
|
|
243
|
+
|
|
244
|
+
<div className="docPage-foot">
|
|
245
|
+
<p className="docPage-progress" role="status">
|
|
246
|
+
{pageError !== null
|
|
247
|
+
? `这一页没取到:${pageError}`
|
|
248
|
+
: eof
|
|
249
|
+
? `已到文档末尾 · 共 ${totalChars.toLocaleString()} 字符`
|
|
250
|
+
: `已显示 ${shown.toLocaleString()} / ${totalChars.toLocaleString()} 字符`}
|
|
251
|
+
</p>
|
|
252
|
+
{!eof && <button type="button" className="docPage-more" disabled={busy} onClick={() => void loadPage(offsetRef.current)}>
|
|
253
|
+
{pageError !== null ? '重试这一页' : busy ? '正在读…' : '加载更多'}
|
|
254
|
+
</button>}
|
|
255
|
+
<div className="docPage-sentinel" ref={sentinelRef} aria-hidden="true" />
|
|
256
|
+
</div>
|
|
257
|
+
</div>
|
|
258
|
+
</main>
|
|
259
|
+
</>
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const container = document.getElementById('tita-document-root')
|
|
263
|
+
if (container !== null) createRoot(container).render(<StrictMode><App /></StrictMode>)
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「上次看到哪儿」:一级 tab、知识库分栏、选中的知识库。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么它们必须活在 React 之外
|
|
5
|
+
*
|
|
6
|
+
* DSH 会在若干动作之后重新挂载右侧面板 —— 刷新页面必然,换会话按宿主的保留策略也会,
|
|
7
|
+
* 而 `useState` 随手就没了。用户看到的是**每次切回来都跳回「Agent 概览」**
|
|
8
|
+
* (2026-09-26 GUI 反馈原话)。兄弟插件 `dsh-data-table` 的 `store.ts` 记着同一条结论,
|
|
9
|
+
* 它记的是上次打开的工作簿;这里记的是三个位置。
|
|
10
|
+
*
|
|
11
|
+
* ## 三条约定
|
|
12
|
+
*
|
|
13
|
+
* 1. **读出来的值必须校验。** 存的是上一版写下的字符串时(tab 改名、知识库被删、
|
|
14
|
+
* 手改过 localStorage),面板不能因此走进一个不存在的位置。所以带 `valid` 的那个读法
|
|
15
|
+
* 对着注册表核对,不认识就返回 `null`,由调用方用它自己的默认值 —— 校验放在调用方,
|
|
16
|
+
* 因为**注册表在那边**(`VIEW_ORDER` / `KNOWLEDGE_TABS`),这个模块不认识它们。
|
|
17
|
+
* 2. **存储是尽力而为。** 锁死 localStorage 的浏览器里退化成「这个页面生命周期内有效」:
|
|
18
|
+
* 每个值同时留一份内存副本,内存优先,全程不抛。
|
|
19
|
+
* 3. **key 只在这里拼。** 三处各写一次字符串,改名时就会漏掉一处,然后表现为
|
|
20
|
+
* 「有的记住了、有的没记住」。
|
|
21
|
+
*
|
|
22
|
+
* 这个模块**不 import 任何东西**(连 React 都不 import),所以它可以被单独打包进 node
|
|
23
|
+
* 里跑真实现 —— `tests/panel-state.test.mjs` 就是这么测的。
|
|
24
|
+
*
|
|
25
|
+
* @module
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 被记住的位置 → localStorage key。
|
|
30
|
+
*
|
|
31
|
+
* `dsh.` 前缀与兄弟插件一致(`dsh.data-sheet.lastWorkbookId`),便于在浏览器里
|
|
32
|
+
* 「哪些是本产品写的」一眼可辨;中间那段是插件名,避免不同插件撞 key。
|
|
33
|
+
*/
|
|
34
|
+
const KEYS = {
|
|
35
|
+
view: 'dsh.data-domain-knowledge.activeView',
|
|
36
|
+
knowledgeTab: 'dsh.data-domain-knowledge.knowledgeTab',
|
|
37
|
+
knowledgeBase: 'dsh.data-domain-knowledge.activeBase',
|
|
38
|
+
} as const
|
|
39
|
+
|
|
40
|
+
export type RememberedSlot = keyof typeof KEYS
|
|
41
|
+
|
|
42
|
+
/** 内存副本:localStorage 不可用时的兜底,同时也避免每次读都碰存储。 */
|
|
43
|
+
const cache = new Map<RememberedSlot, string>()
|
|
44
|
+
|
|
45
|
+
function storage(): Storage | null {
|
|
46
|
+
try {
|
|
47
|
+
return globalThis.localStorage ?? null
|
|
48
|
+
} catch {
|
|
49
|
+
// 某些浏览器在「禁止所有 Cookie」时连读 `localStorage` 这个属性都会抛。
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function readRaw(slot: RememberedSlot): string | null {
|
|
55
|
+
const cached = cache.get(slot)
|
|
56
|
+
if (cached !== undefined) return cached === '' ? null : cached
|
|
57
|
+
let raw: string | null = null
|
|
58
|
+
try {
|
|
59
|
+
raw = storage()?.getItem(KEYS[slot]) ?? null
|
|
60
|
+
} catch {
|
|
61
|
+
raw = null
|
|
62
|
+
}
|
|
63
|
+
cache.set(slot, raw ?? '')
|
|
64
|
+
return raw === null || raw === '' ? null : raw
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 读一个记住的值,并且**只认 `valid` 里的成员**。
|
|
69
|
+
*
|
|
70
|
+
* @param valid - 允许的取值,来自那个东西自己的注册表(tab 顺序表、分栏表)。传空数组
|
|
71
|
+
* 等于「什么都不要」—— 那时返回 `null`,调用方用默认值。
|
|
72
|
+
*/
|
|
73
|
+
export function readRemembered<T extends string>(slot: RememberedSlot, valid: readonly T[]): T | null {
|
|
74
|
+
const raw = readRaw(slot)
|
|
75
|
+
if (raw === null) return null
|
|
76
|
+
return (valid as readonly string[]).includes(raw) ? (raw as T) : null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 读一个**没有注册表可校验**的记住值(目前只有知识库 id —— 它来自服务端,是数据不是枚举)。
|
|
81
|
+
* 拿不到就返回空串,调用方按「没有偏好」处理。
|
|
82
|
+
*/
|
|
83
|
+
export function readRememberedId(slot: RememberedSlot): string {
|
|
84
|
+
return readRaw(slot) ?? ''
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 记住一个值。写失败只影响「换个页面还记得不记得」,不影响本次会话。 */
|
|
88
|
+
export function remember(slot: RememberedSlot, value: string): void {
|
|
89
|
+
if (cache.get(slot) === value) return
|
|
90
|
+
cache.set(slot, value)
|
|
91
|
+
try {
|
|
92
|
+
if (value === '') storage()?.removeItem(KEYS[slot])
|
|
93
|
+
else storage()?.setItem(KEYS[slot], value)
|
|
94
|
+
} catch {
|
|
95
|
+
// 写不进去就用内存那份;它已经更新过了。
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 测试用:忘掉内存副本,让下一次读重新问存储。 */
|
|
100
|
+
export function forgetRememberedCache(): void {
|
|
101
|
+
cache.clear()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 测试用:这个模块会写哪些 key。把它暴露出来,测试才能断言不撞别的插件。 */
|
|
105
|
+
export const REMEMBERED_KEYS: Readonly<Record<RememberedSlot, string>> = KEYS
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 插件面板与宿主路由之间的**那一层薄封装**:把信封拆成「成功的数据」或一个能读的错误。
|
|
3
|
+
*
|
|
4
|
+
* 它存在的理由是**错误信息必须带 suggestion**:服务端返回的 `suggestion` 是「下一步该
|
|
5
|
+
* 做什么」(去哪个设置、先调哪个工具),丢掉它之后界面上只剩一句错误码,而用户没有
|
|
6
|
+
* 别的办法知道该往哪走。
|
|
7
|
+
*
|
|
8
|
+
* 它原来是 `KnowledgeBaseTab.tsx` 里的一个局部函数。抽出来是因为第二个消费者出现了
|
|
9
|
+
* (`ProposalReview.tsx` 的待办队列),而抄第二份的下场是两处的错误处理迟早不一致 ——
|
|
10
|
+
* 而「同一个失败在两屏上读起来不一样」正是最让人怀疑系统状态的那种不一致。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface Envelope<T> {
|
|
14
|
+
ok?: boolean
|
|
15
|
+
data?: T
|
|
16
|
+
warnings?: string[]
|
|
17
|
+
error?: { code?: string; message?: string; suggestion?: string } | null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function requestJson<T>(url: string, init?: RequestInit): Promise<Envelope<T>> {
|
|
21
|
+
const response = await fetch(url, init)
|
|
22
|
+
const text = await response.text()
|
|
23
|
+
if (!text.trim()) throw new Error(`服务端返回空响应(HTTP ${response.status})`)
|
|
24
|
+
let payload: Envelope<T>
|
|
25
|
+
try {
|
|
26
|
+
payload = JSON.parse(text) as Envelope<T>
|
|
27
|
+
} catch {
|
|
28
|
+
throw new Error(`服务端返回了无效响应(HTTP ${response.status})`)
|
|
29
|
+
}
|
|
30
|
+
const error = payload.error
|
|
31
|
+
if (!response.ok || payload.ok === false) {
|
|
32
|
+
const detail = [error?.code, error?.message].filter(Boolean).join(' · ') || `请求失败(${response.status})`
|
|
33
|
+
throw new Error(error?.suggestion ? `${detail} — ${error.suggestion}` : detail)
|
|
34
|
+
}
|
|
35
|
+
return payload
|
|
36
|
+
}
|