@cteno/sdk-linux-x64 0.0.0-stage → 2.10.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.daemon +31 -0
- package/README.md +4 -2
- package/agents/flow-analyzer/AGENT.md +71 -0
- package/bin/cteno-host-cli +4 -0
- package/bin/cteno-wiki +0 -0
- package/bin/rg +0 -0
- package/bin/secret-exec +0 -0
- package/manifest.json +12 -0
- package/package.json +33 -4
- package/plugins/agent-secrets/.cteno-plugin/plugin.json +133 -0
- package/plugins/agent-secrets/bin/linux-x64/fnox +4 -0
- package/plugins/agent-secrets/bin/linux-x64/secret-exec +0 -0
- package/plugins/agent-secrets/skills/secret/SKILL.md +201 -0
- package/plugins/cteno.browser/.cteno-plugin/plugin.json +65 -0
- package/plugins/cteno.browser/skills/browser/SKILL.md +28 -0
- package/plugins/cteno.inbox/.cteno-plugin/plugin.json +132 -0
- package/plugins/cteno.inbox/bin/all/cteno-connector-credential +31 -0
- package/plugins/cteno.inbox/bin/all/cteno-telegram-connector +33 -0
- package/plugins/cteno.inbox/bin/linux-x64/cteno-inbox +0 -0
- package/plugins/cteno.inbox/skills/cteno-inbox/SKILL.md +374 -0
- package/plugins/cteno.inbox/tools/inbox_focus/TOOL.md +44 -0
- package/plugins/cteno.memory/.cteno-plugin/plugin.json +176 -0
- package/plugins/cteno.memory/agents/llm-wiki/AGENT.md +96 -0
- package/plugins/cteno.memory/bin/linux-x64/cteno-wiki +0 -0
- package/plugins/cteno.memory/skills/codebase-wiki/SKILL.md +52 -0
- package/plugins/cteno.memory/skills/cteno.memory.native/SKILL.md +110 -0
- package/plugins/cteno.memory/tools/memory/TOOL.md +492 -0
- package/plugins/cteno.memory/ui/index.html +822 -0
- package/plugins/cteno.models/.cteno-plugin/plugin.json +191 -0
- package/plugins/cteno.websearch/.cteno-plugin/plugin.json +45 -0
- package/plugins/jev/.cteno-plugin/plugin.json +309 -0
- package/plugins/jev/skills/jev-browser/SKILL.md +66 -0
- package/plugins/jev/skills/jev-browser/jev_browser.py +335 -0
- package/skills/agent-create/SKILL.md +86 -0
- package/skills/cteno-host-cli/SKILL.md +256 -0
- package/skills/docx/LICENSE +23 -0
- package/skills/docx/NOTICE.md +8 -0
- package/skills/docx/SKILL.md +69 -0
- package/skills/docx/references/cjk_typography.md +357 -0
- package/skills/docx/references/design_principles.md +819 -0
- package/skills/docx/references/revisions-and-comments.md +88 -0
- package/skills/docx/references/track_changes_guide.md +200 -0
- package/skills/docx/references/typography_guide.md +294 -0
- package/skills/docx/scripts/cjk_fonts.py +244 -0
- package/skills/docx/scripts/docx_comments.py +289 -0
- package/skills/docx/scripts/docx_common.py +92 -0
- package/skills/docx/scripts/docx_create.py +259 -0
- package/skills/docx/scripts/docx_edit.py +250 -0
- package/skills/docx/scripts/docx_read.py +149 -0
- package/skills/docx/scripts/docx_revisions.py +146 -0
- package/skills/docx/scripts/docx_template.py +70 -0
- package/skills/docx/scripts/docx_validate.py +156 -0
- package/skills/docx/scripts/render.py +250 -0
- package/skills/docx/tests/test_docx_skill.py +525 -0
- package/skills/home-devices/SKILL.md +121 -0
- package/skills/mcp/SKILL.md +72 -0
- package/skills/mcp/scripts/mcp_setup.py +439 -0
- package/skills/pdf/LICENSE +23 -0
- package/skills/pdf/NOTICE.md +8 -0
- package/skills/pdf/SKILL.md +63 -0
- package/skills/pdf/references/design-tokens.md +381 -0
- package/skills/pdf/references/forms.md +99 -0
- package/skills/pdf/references/nano-pdf-editing.md +40 -0
- package/skills/pdf/references/ocr-extraction.md +165 -0
- package/skills/pdf/scripts/_raster.py +76 -0
- package/skills/pdf/scripts/extract_marker.py +88 -0
- package/skills/pdf/scripts/extract_pymupdf.py +101 -0
- package/skills/pdf/scripts/pdf_create.py +191 -0
- package/skills/pdf/scripts/pdf_fill_form.py +97 -0
- package/skills/pdf/scripts/pdf_form_layout.py +168 -0
- package/skills/pdf/scripts/pdf_make_form.py +145 -0
- package/skills/pdf/scripts/pdf_merge.py +50 -0
- package/skills/pdf/scripts/pdf_meta.py +115 -0
- package/skills/pdf/scripts/pdf_page_image.py +99 -0
- package/skills/pdf/scripts/pdf_read.py +153 -0
- package/skills/pdf/scripts/pdf_secure.py +71 -0
- package/skills/pdf/scripts/pdf_split.py +84 -0
- package/skills/pdf/scripts/pdf_stamp.py +143 -0
- package/skills/pdf/scripts/pdf_watermark.py +51 -0
- package/skills/pdf/scripts/render.py +250 -0
- package/skills/pdf/tests/test_pdf_skill.py +415 -0
- package/skills/pptx/LICENSE +23 -0
- package/skills/pptx/NOTICE.md +8 -0
- package/skills/pptx/SKILL.md +62 -0
- package/skills/pptx/references/design-system.md +392 -0
- package/skills/pptx/references/pptxgenjs.md +420 -0
- package/skills/pptx/references/slide-types.md +413 -0
- package/skills/pptx/scripts/cjk_fonts.py +244 -0
- package/skills/pptx/scripts/pptx_create.py +240 -0
- package/skills/pptx/scripts/pptx_edit.py +436 -0
- package/skills/pptx/scripts/pptx_from_template.py +88 -0
- package/skills/pptx/scripts/pptx_read.py +131 -0
- package/skills/pptx/scripts/pptx_render.py +96 -0
- package/skills/pptx/scripts/render.py +250 -0
- package/skills/pptx/tests/test_powerpoint_skill.py +477 -0
- package/skills/skill-create/SKILL.md +114 -0
- package/skills/video-generation/SKILL.md +27 -0
- package/skills/xlsx/LICENSE +23 -0
- package/skills/xlsx/NOTICE.md +8 -0
- package/skills/xlsx/SKILL.md +63 -0
- package/skills/xlsx/references/format.md +71 -0
- package/skills/xlsx/references/restructuring.md +71 -0
- package/skills/xlsx/scripts/csv_to_xlsx.py +110 -0
- package/skills/xlsx/scripts/formula_check.py +428 -0
- package/skills/xlsx/scripts/render.py +250 -0
- package/skills/xlsx/scripts/xlsx_create.py +265 -0
- package/skills/xlsx/scripts/xlsx_edit.py +269 -0
- package/skills/xlsx/scripts/xlsx_read.py +166 -0
- package/skills/xlsx/scripts/xlsx_recalc.py +119 -0
- package/skills/xlsx/scripts/xlsx_restructure.py +343 -0
- package/skills/xlsx/scripts/xlsx_to_csv.py +75 -0
- package/skills/xlsx/tests/test_xlsx_skill.py +544 -0
- package/tools/create_goal/TOOL.md +36 -0
- package/tools/image_generation/TOOL.md +81 -0
- package/tools/skill/TOOL.md +117 -0
- package/tools/update_profile_notes/TOOL.md +39 -0
|
@@ -0,0 +1,822 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
|
6
|
+
<title>记忆</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
--bg: #0f1115; --panel: #171a21; --panel-2: #1e222b; --border: #2a2f3a;
|
|
10
|
+
--text: #e6e8ee; --muted: #9aa3b2; --accent: #8e7cff; --link: #4da6ff;
|
|
11
|
+
--code-bg: #11141a;
|
|
12
|
+
}
|
|
13
|
+
* { box-sizing: border-box; }
|
|
14
|
+
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--text);
|
|
15
|
+
font: 14px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", "Microsoft YaHei", sans-serif; }
|
|
16
|
+
#app { display: flex; flex-direction: column; height: 100%; }
|
|
17
|
+
header { display: flex; align-items: center; gap: 10px; padding: 10px 14px;
|
|
18
|
+
border-bottom: 1px solid var(--border); background: var(--panel); flex: none; }
|
|
19
|
+
header .title { font-weight: 600; letter-spacing: .2px; }
|
|
20
|
+
header .title .dot { color: var(--accent); }
|
|
21
|
+
header .spacer { flex: 1; }
|
|
22
|
+
select, button {
|
|
23
|
+
background: var(--panel-2); color: var(--text); border: 1px solid var(--border);
|
|
24
|
+
border-radius: 8px; padding: 5px 10px; font-size: 13px; cursor: pointer;
|
|
25
|
+
}
|
|
26
|
+
button:hover, select:hover { border-color: var(--accent); }
|
|
27
|
+
main { display: flex; flex: 1; min-height: 0; position: relative; }
|
|
28
|
+
#menu { display: none; padding: 5px 9px; line-height: 1; font-size: 15px; }
|
|
29
|
+
#scrim { display: none; }
|
|
30
|
+
nav { width: 256px; flex: none; border-right: 1px solid var(--border);
|
|
31
|
+
background: var(--panel); overflow-y: auto; padding: 8px; }
|
|
32
|
+
nav .group { color: var(--muted); font-size: 11px; text-transform: uppercase;
|
|
33
|
+
letter-spacing: .6px; padding: 10px 8px 4px; }
|
|
34
|
+
nav .scope-head { color: var(--text); font-size: 12px; font-weight: 600;
|
|
35
|
+
padding: 12px 8px 2px; }
|
|
36
|
+
nav .scope-head:first-child { padding-top: 4px; }
|
|
37
|
+
.empty .hint { display: block; margin-top: 8px; font-size: 13px; }
|
|
38
|
+
nav a { display: block; padding: 6px 8px; border-radius: 7px; color: var(--text);
|
|
39
|
+
text-decoration: none; cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
40
|
+
nav a:hover { background: var(--panel-2); }
|
|
41
|
+
nav .group.toggle { cursor: pointer; display: flex; align-items: center; gap: 4px; user-select: none; }
|
|
42
|
+
nav .group.toggle:hover { color: var(--text); }
|
|
43
|
+
nav .group .caret { width: 10px; display: inline-block; }
|
|
44
|
+
nav .group .count { margin-left: auto; font-size: 11px; color: var(--muted); }
|
|
45
|
+
nav a.more { color: var(--muted); font-size: 12px; }
|
|
46
|
+
#nav-filter { position: sticky; top: 0; z-index: 1; width: 100%; margin: 2px 0 6px; padding: 6px 9px; border-radius: 8px;
|
|
47
|
+
border: 1px solid var(--border); background: var(--panel-2); color: var(--text); font-size: 13px; outline: none; }
|
|
48
|
+
#nav-filter:focus { border-color: var(--accent); }
|
|
49
|
+
article .sentinel { color: var(--muted); text-align: center; padding: 16px; cursor: pointer; font-size: 12px; }
|
|
50
|
+
nav a.active { background: rgba(142,124,255,.18); color: #fff; }
|
|
51
|
+
article { flex: 1; min-width: 0; overflow-y: auto; padding: 22px 30px; overflow-wrap: break-word; }
|
|
52
|
+
article h1 { font-size: 22px; margin: 0 0 14px; }
|
|
53
|
+
article h2 { font-size: 17px; margin: 22px 0 8px; border-bottom: 1px solid var(--border); padding-bottom: 4px; }
|
|
54
|
+
article h3 { font-size: 15px; margin: 16px 0 6px; }
|
|
55
|
+
article a { color: var(--link); text-decoration: none; cursor: pointer; }
|
|
56
|
+
article a:hover { text-decoration: underline; }
|
|
57
|
+
article code { background: var(--code-bg); border: 1px solid var(--border);
|
|
58
|
+
border-radius: 5px; padding: 1px 5px; font-size: 12.5px;
|
|
59
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
60
|
+
article pre { background: var(--code-bg); border: 1px solid var(--border);
|
|
61
|
+
border-radius: 8px; padding: 12px 14px; overflow-x: auto; }
|
|
62
|
+
article pre code { border: 0; padding: 0; }
|
|
63
|
+
article ul, article ol { padding-left: 22px; }
|
|
64
|
+
.meta { color: var(--muted); font-size: 12px; margin: -8px 0 16px; }
|
|
65
|
+
.empty, .err { color: var(--muted); padding: 40px; text-align: center; }
|
|
66
|
+
.err { color: #ff8a8a; }
|
|
67
|
+
.frontmatter { color: var(--muted); font-size: 12px; background: var(--panel);
|
|
68
|
+
border: 1px solid var(--border); border-radius: 8px; padding: 8px 12px; margin: 0 0 16px;
|
|
69
|
+
white-space: pre-wrap; overflow-wrap: anywhere;
|
|
70
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
71
|
+
|
|
72
|
+
/* Narrow pane: the sidebar becomes a slide-over drawer toggled by ☰, and the
|
|
73
|
+
article goes full-width. The CEF plugin pane is often ~400px wide. */
|
|
74
|
+
@media (max-width: 600px) {
|
|
75
|
+
#menu { display: inline-flex; align-items: center; }
|
|
76
|
+
header { padding: 9px 10px; gap: 8px; }
|
|
77
|
+
header .title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
78
|
+
nav {
|
|
79
|
+
position: absolute; top: 0; left: 0; bottom: 0; z-index: 10;
|
|
80
|
+
width: 80%; max-width: 280px;
|
|
81
|
+
transform: translateX(-100%); transition: transform .18s ease;
|
|
82
|
+
box-shadow: 2px 0 16px rgba(0, 0, 0, .45);
|
|
83
|
+
}
|
|
84
|
+
#app.nav-open nav { transform: translateX(0); }
|
|
85
|
+
#scrim { display: block; position: absolute; inset: 0; z-index: 9;
|
|
86
|
+
background: rgba(0, 0, 0, .45); opacity: 0; pointer-events: none;
|
|
87
|
+
transition: opacity .18s ease; }
|
|
88
|
+
#app.nav-open #scrim { opacity: 1; pointer-events: auto; }
|
|
89
|
+
article { padding: 16px 16px; }
|
|
90
|
+
}
|
|
91
|
+
/* Full-text search: header box, results replace the article until cleared. */
|
|
92
|
+
#search { flex: 1 1 220px; max-width: 360px; min-width: 0; padding: 5px 10px; border-radius: 8px;
|
|
93
|
+
border: 1px solid var(--border); background: var(--panel-2); color: var(--text); font-size: 13px; outline: none; }
|
|
94
|
+
#search:focus { border-color: var(--accent); }
|
|
95
|
+
#search + .spacer { flex: 0 1 auto; }
|
|
96
|
+
.search-head { display: flex; align-items: center; gap: 10px; color: var(--muted); font-size: 12px; margin: 0 0 12px; }
|
|
97
|
+
.search-head label { display: inline-flex; align-items: center; gap: 4px; cursor: pointer; }
|
|
98
|
+
.search-head .grow { flex: 1; }
|
|
99
|
+
.search-head a { color: var(--link); cursor: pointer; }
|
|
100
|
+
.hit { display: block; padding: 10px 12px; border: 1px solid var(--border); border-radius: 10px;
|
|
101
|
+
margin: 0 0 8px; cursor: pointer; background: var(--panel); }
|
|
102
|
+
.hit:hover { border-color: var(--accent); }
|
|
103
|
+
.hit .t { color: var(--text); font-weight: 600; }
|
|
104
|
+
.hit .m { color: var(--muted); font-size: 11px; margin-left: 6px; font-weight: 400; }
|
|
105
|
+
.hit .s { color: var(--muted); font-size: 13px; margin-top: 3px; overflow-wrap: anywhere; }
|
|
106
|
+
mark { background: rgba(142, 124, 255, .35); color: inherit; border-radius: 3px; padding: 0 1px; }
|
|
107
|
+
mark.current { background: rgba(255, 196, 0, .55); }
|
|
108
|
+
@media (max-width: 600px) { #search { flex-basis: 120px; } }
|
|
109
|
+
/* Dream outcomes: failed runs need a person; new results are informational. */
|
|
110
|
+
#dreams { flex: none; border-bottom: 1px solid var(--border); background: var(--panel);
|
|
111
|
+
padding: 8px 14px; font-size: 13px; max-height: 38vh; overflow-y: auto; }
|
|
112
|
+
#dreams:empty { display: none; }
|
|
113
|
+
#dreams .row { display: flex; align-items: flex-start; gap: 8px; padding: 6px 0; }
|
|
114
|
+
#dreams .row + .row { border-top: 1px solid var(--border); }
|
|
115
|
+
#dreams .badge { flex: none; width: 8px; height: 8px; border-radius: 4px; margin-top: 6px; }
|
|
116
|
+
#dreams .failed .badge { background: #ff9500; }
|
|
117
|
+
#dreams .ok .badge { background: var(--link); }
|
|
118
|
+
#dreams .body { flex: 1; min-width: 0; }
|
|
119
|
+
#dreams .what { color: var(--text); }
|
|
120
|
+
#dreams .detail { color: var(--muted); font-size: 12px; margin-top: 2px; word-break: break-word; }
|
|
121
|
+
#dreams .pages a { color: var(--link); cursor: pointer; margin-right: 8px; }
|
|
122
|
+
#dreams .actions { flex: none; display: flex; gap: 6px; }
|
|
123
|
+
</style>
|
|
124
|
+
</head>
|
|
125
|
+
<body>
|
|
126
|
+
<div id="app">
|
|
127
|
+
<header>
|
|
128
|
+
<button id="menu" title="页面" aria-label="页面">☰</button>
|
|
129
|
+
<span class="title">记忆</span>
|
|
130
|
+
<input id="search" type="search" placeholder="搜索记忆内容" autocomplete="off" data-testid="memory-search" hidden />
|
|
131
|
+
<span class="spacer"></span>
|
|
132
|
+
<select id="scope" title="记忆范围">
|
|
133
|
+
<option value="auto" selected>全部</option>
|
|
134
|
+
<option value="private">私有</option>
|
|
135
|
+
<option value="global">共享</option>
|
|
136
|
+
</select>
|
|
137
|
+
<button id="refresh" title="刷新">刷新</button>
|
|
138
|
+
</header>
|
|
139
|
+
<div id="dreams" data-testid="memory-dream-alerts"></div>
|
|
140
|
+
<main>
|
|
141
|
+
<nav id="nav">
|
|
142
|
+
<input id="nav-filter" type="search" placeholder="筛选页面" autocomplete="off" data-testid="memory-nav-filter" />
|
|
143
|
+
<div id="nav-list" data-testid="memory-nav"></div>
|
|
144
|
+
</nav>
|
|
145
|
+
<article id="page"><div class="empty">加载中…</div></article>
|
|
146
|
+
<div id="scrim"></div>
|
|
147
|
+
</main>
|
|
148
|
+
</div>
|
|
149
|
+
|
|
150
|
+
<script>
|
|
151
|
+
/* ---------------------------------------------------------------------------
|
|
152
|
+
* window.cteno — the unified plugin-page bridge. The page NEVER touches RPC or
|
|
153
|
+
* sockets directly; it calls cteno.call(method, params) and the host container
|
|
154
|
+
* implements the transport. A container that can inject the bridge BEFORE this
|
|
155
|
+
* script (the CEF pane: the host injects a CDP-binding window.cteno) wins; this
|
|
156
|
+
* inline postMessage transport is the fallback for iframe (desktop) / RN webview
|
|
157
|
+
* (mobile) containers that can't pre-inject. Either way the page only ever uses
|
|
158
|
+
* window.cteno.call — identical contract across all containers.
|
|
159
|
+
* ------------------------------------------------------------------------- */
|
|
160
|
+
if (!window.cteno) (function () {
|
|
161
|
+
var seq = 0;
|
|
162
|
+
var pending = {};
|
|
163
|
+
function post(msg) {
|
|
164
|
+
// Desktop: iframe → parent. Mobile (react-native-webview): ReactNativeWebView.
|
|
165
|
+
if (window.ReactNativeWebView && window.ReactNativeWebView.postMessage) {
|
|
166
|
+
window.ReactNativeWebView.postMessage(JSON.stringify(msg));
|
|
167
|
+
} else if (window.parent && window.parent !== window) {
|
|
168
|
+
window.parent.postMessage(msg, '*');
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
window.addEventListener('message', function (ev) {
|
|
172
|
+
var d = ev.data;
|
|
173
|
+
if (typeof d === 'string') { try { d = JSON.parse(d); } catch (e) { return; } }
|
|
174
|
+
if (!d || !d.__ctenoReply || d.id == null) return;
|
|
175
|
+
var p = pending[d.id];
|
|
176
|
+
if (!p) return;
|
|
177
|
+
delete pending[d.id];
|
|
178
|
+
if (d.success) p.resolve(d.data); else p.reject(new Error(d.error || 'bridge error'));
|
|
179
|
+
});
|
|
180
|
+
window.cteno = {
|
|
181
|
+
call: function (method, params) {
|
|
182
|
+
return new Promise(function (resolve, reject) {
|
|
183
|
+
var id = ++seq;
|
|
184
|
+
pending[id] = { resolve: resolve, reject: reject };
|
|
185
|
+
post({ __cteno: true, id: id, method: method, params: params || {} });
|
|
186
|
+
setTimeout(function () {
|
|
187
|
+
if (pending[id]) { delete pending[id]; reject(new Error('bridge timeout: ' + method)); }
|
|
188
|
+
}, 20000);
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
})();
|
|
193
|
+
|
|
194
|
+
/* --------------------------- tiny markdown render -------------------------- */
|
|
195
|
+
/* @pure-begin */
|
|
196
|
+
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) { return ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]); }); }
|
|
197
|
+
function splitFrontmatter(md) {
|
|
198
|
+
var m = /^---\n([\s\S]*?)\n---\n?/.exec(md);
|
|
199
|
+
if (m) return { fm: m[1], body: md.slice(m[0].length) };
|
|
200
|
+
return { fm: null, body: md };
|
|
201
|
+
}
|
|
202
|
+
function renderInline(s) {
|
|
203
|
+
s = esc(s);
|
|
204
|
+
s = s.replace(/`([^`]+)`/g, '<code>$1</code>');
|
|
205
|
+
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
|
206
|
+
s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function (_, txt, href) {
|
|
207
|
+
var ext = /^https?:\/\//.test(href);
|
|
208
|
+
return '<a data-href="' + esc(href) + '"' + (ext ? ' data-ext="1"' : '') + '>' + txt + '</a>';
|
|
209
|
+
});
|
|
210
|
+
return s;
|
|
211
|
+
}
|
|
212
|
+
function renderMarkdown(md) {
|
|
213
|
+
var lines = md.split('\n'), out = [], i = 0;
|
|
214
|
+
while (i < lines.length) {
|
|
215
|
+
var ln = lines[i];
|
|
216
|
+
if (/^```/.test(ln)) {
|
|
217
|
+
var buf = []; i++;
|
|
218
|
+
while (i < lines.length && !/^```/.test(lines[i])) { buf.push(esc(lines[i])); i++; }
|
|
219
|
+
i++; out.push('<pre><code>' + buf.join('\n') + '</code></pre>'); continue;
|
|
220
|
+
}
|
|
221
|
+
var h = /^(#{1,6})\s+(.*)$/.exec(ln);
|
|
222
|
+
if (h) { var lvl = h[1].length; out.push('<h' + lvl + '>' + renderInline(h[2]) + '</h' + lvl + '>'); i++; continue; }
|
|
223
|
+
if (/^\s*[-*]\s+/.test(ln)) {
|
|
224
|
+
var items = [];
|
|
225
|
+
while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) { items.push('<li>' + renderInline(lines[i].replace(/^\s*[-*]\s+/, '')) + '</li>'); i++; }
|
|
226
|
+
out.push('<ul>' + items.join('') + '</ul>'); continue;
|
|
227
|
+
}
|
|
228
|
+
if (/^\s*\d+\.\s+/.test(ln)) {
|
|
229
|
+
var oitems = [];
|
|
230
|
+
while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) { oitems.push('<li>' + renderInline(lines[i].replace(/^\s*\d+\.\s+/, '')) + '</li>'); i++; }
|
|
231
|
+
out.push('<ol>' + oitems.join('') + '</ol>'); continue;
|
|
232
|
+
}
|
|
233
|
+
if (ln.trim() === '') { i++; continue; }
|
|
234
|
+
var para = [ln]; i++;
|
|
235
|
+
while (i < lines.length && lines[i].trim() !== '' && !/^(#{1,6}\s|```|\s*[-*]\s|\s*\d+\.\s)/.test(lines[i])) { para.push(lines[i]); i++; }
|
|
236
|
+
out.push('<p>' + renderInline(para.join(' ')) + '</p>');
|
|
237
|
+
}
|
|
238
|
+
return out.join('\n');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/* ------------------------- pure view helpers (tested) ----------------------- */
|
|
242
|
+
var SCOPE_LABELS = { 'private': '私有记忆', 'global': '共享记忆' };
|
|
243
|
+
var GROUP_LABELS = {
|
|
244
|
+
knowledge: '知识', logs: '日志', project: '项目', workflow: '流程',
|
|
245
|
+
incidents: '复盘', raw: '文档', docs: '文档', pages: '页面'
|
|
246
|
+
};
|
|
247
|
+
var PAGE_LABELS = { 'index.md': '索引', 'log.md': '变更记录', 'USER.md': '关于我', 'MEMORY.md': '旧版记忆' };
|
|
248
|
+
function groupLabel(g) { return GROUP_LABELS[g] || g; }
|
|
249
|
+
function pageLabel(p) {
|
|
250
|
+
if (PAGE_LABELS[p]) return PAGE_LABELS[p];
|
|
251
|
+
return p.replace(/^.*\//, '').replace(/\.md$/, '');
|
|
252
|
+
}
|
|
253
|
+
/** Bookkeeping pages every scope has; they are not "things remembered". */
|
|
254
|
+
function isBookkeeping(p) { return p === 'index.md' || p === 'log.md' || /^logs\//.test(p); }
|
|
255
|
+
/**
|
|
256
|
+
* Which entries the browser shows. Explicit scope: only that scope. Auto: BOTH
|
|
257
|
+
* scopes — a Jelly with shared memory writes its facts to the global space
|
|
258
|
+
* while its private space still holds index/log bookkeeping, so "private wins"
|
|
259
|
+
* used to hide everything it remembered. Untagged entries belong to the
|
|
260
|
+
* requested scope (older backends).
|
|
261
|
+
*/
|
|
262
|
+
function visibleEntries(tagged, scope) {
|
|
263
|
+
var fallback = scope === 'global' ? 'global' : 'private';
|
|
264
|
+
var out = [];
|
|
265
|
+
(tagged || []).forEach(function (e) {
|
|
266
|
+
if (!/\.md$/.test(e.path)) return;
|
|
267
|
+
if (isHiddenPath(e.path)) return;
|
|
268
|
+
var s = e.scope || fallback;
|
|
269
|
+
if (scope !== 'auto' && s !== scope) return;
|
|
270
|
+
out.push({ scope: s, path: e.path });
|
|
271
|
+
});
|
|
272
|
+
out.sort(function (a, b) {
|
|
273
|
+
if (a.scope !== b.scope) return a.scope === 'private' ? -1 : 1;
|
|
274
|
+
return a.path < b.path ? -1 : a.path > b.path ? 1 : 0;
|
|
275
|
+
});
|
|
276
|
+
return out;
|
|
277
|
+
}
|
|
278
|
+
/** Landing page: the index of the first scope that actually remembers something. */
|
|
279
|
+
function landingEntry(entries) {
|
|
280
|
+
if (!entries.length) return null;
|
|
281
|
+
var scopes = ['private', 'global'];
|
|
282
|
+
for (var i = 0; i < scopes.length; i++) {
|
|
283
|
+
var s = scopes[i];
|
|
284
|
+
var mine = entries.filter(function (e) { return e.scope === s; });
|
|
285
|
+
if (!mine.some(function (e) { return !isBookkeeping(e.path); })) continue;
|
|
286
|
+
var idx = mine.filter(function (e) { return e.path === 'index.md'; })[0];
|
|
287
|
+
return idx || mine.filter(function (e) { return !isBookkeeping(e.path); })[0];
|
|
288
|
+
}
|
|
289
|
+
return entries.filter(function (e) { return e.path === 'index.md'; })[0] || entries[0];
|
|
290
|
+
}
|
|
291
|
+
/** Nav groups: scope header (only when both scopes show) → path group → pages. */
|
|
292
|
+
function navModel(entries) {
|
|
293
|
+
var scopes = [];
|
|
294
|
+
entries.forEach(function (e) { if (scopes.indexOf(e.scope) < 0) scopes.push(e.scope); });
|
|
295
|
+
var showScope = scopes.length > 1;
|
|
296
|
+
return scopes.map(function (s) {
|
|
297
|
+
var groups = {};
|
|
298
|
+
entries.filter(function (e) { return e.scope === s; }).forEach(function (e) {
|
|
299
|
+
var g = e.path === 'index.md' || e.path === 'log.md' ? '' : (e.path.indexOf('/') >= 0 ? e.path.split('/')[0] : 'pages');
|
|
300
|
+
(groups[g] = groups[g] || []).push(e);
|
|
301
|
+
});
|
|
302
|
+
return {
|
|
303
|
+
scope: s,
|
|
304
|
+
title: showScope ? SCOPE_LABELS[s] : null,
|
|
305
|
+
groups: Object.keys(groups).sort().map(function (g) {
|
|
306
|
+
return { title: g ? groupLabel(g) : null, pages: groups[g].map(function (e) {
|
|
307
|
+
return { scope: e.scope, path: e.path, label: pageLabel(e.path) };
|
|
308
|
+
}) };
|
|
309
|
+
})
|
|
310
|
+
};
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
var EMPTY_MEMORY_HTML = '<div class="empty" data-testid="memory-empty">还没有记忆' +
|
|
314
|
+
'<span class="hint">聊天时让我「记住」一件事,它就会出现在这里。</span></div>';
|
|
315
|
+
/**
|
|
316
|
+
* The backend generates index.md with an English heading and bare file-stem
|
|
317
|
+
* link titles ("# Memory Index", "[USER](USER.md)"). Present it in Chinese —
|
|
318
|
+
* view-only: the file on disk is the machine-maintained canonical index and
|
|
319
|
+
* stays untouched. Only the generated index page is rewritten.
|
|
320
|
+
*/
|
|
321
|
+
function localizeIndexMarkdown(path, md) {
|
|
322
|
+
if (path !== 'index.md' || typeof md !== 'string') return md;
|
|
323
|
+
return md
|
|
324
|
+
.replace(/^#\s+Memory Index\s*$/m, '# 记忆索引')
|
|
325
|
+
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, function (all, title, href) {
|
|
326
|
+
var file = href.replace(/^\.\//, '');
|
|
327
|
+
var stem = file.replace(/^.*\//, '').replace(/\.md$/, '');
|
|
328
|
+
if (PAGE_LABELS[file] && title === stem) return '[' + PAGE_LABELS[file] + '](' + href + ')';
|
|
329
|
+
return all;
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Dot segments are bookkeeping (`.memory-changes/<run>/pre/0.md` = dream
|
|
334
|
+
* journal pre-images). New backends no longer list them; older ones do.
|
|
335
|
+
*/
|
|
336
|
+
function isHiddenPath(p) { return /(^|\/)\./.test(p); }
|
|
337
|
+
|
|
338
|
+
/* Lazy loading. A mature wiki has thousands of pages and an index.md of
|
|
339
|
+
* ~2k lines; rendering all of it on open is what made the page stall. */
|
|
340
|
+
var NAV_GROUP_PAGE = 100; // rows rendered per nav group before 「显示更多」
|
|
341
|
+
var PAGE_CHUNK_LINES = 300; // markdown lines rendered per article chunk
|
|
342
|
+
|
|
343
|
+
function navGroupKey(scope, title) { return scope + ':' + (title || ''); }
|
|
344
|
+
/** Case-insensitive match on label or path; empty query keeps everything. */
|
|
345
|
+
function filterEntries(entries, query) {
|
|
346
|
+
var q = String(query || '').trim().toLowerCase();
|
|
347
|
+
if (!q) return entries;
|
|
348
|
+
return entries.filter(function (e) {
|
|
349
|
+
return e.path.toLowerCase().indexOf(q) >= 0 || pageLabel(e.path).toLowerCase().indexOf(q) >= 0;
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Nav markup. Groups are collapsed unless open (`ui.open[key]`), the
|
|
354
|
+
* untitled root group (索引/变更记录) or a filter is active; an open group
|
|
355
|
+
* renders at most `ui.shown[key]` (default NAV_GROUP_PAGE) rows plus a
|
|
356
|
+
* 「显示更多」 row. Collapsed groups cost one header line.
|
|
357
|
+
*/
|
|
358
|
+
function navHtml(model, ui) {
|
|
359
|
+
var html = '';
|
|
360
|
+
model.forEach(function (sec) {
|
|
361
|
+
if (sec.title) html += '<div class="scope-head">' + esc(sec.title) + '</div>';
|
|
362
|
+
sec.groups.forEach(function (g) {
|
|
363
|
+
var key = navGroupKey(sec.scope, g.title);
|
|
364
|
+
var open = !g.title || ui.filtering || !!(ui.open && ui.open[key]);
|
|
365
|
+
if (g.title) {
|
|
366
|
+
html += '<div class="group toggle" data-group="' + esc(key) + '">' +
|
|
367
|
+
'<span class="caret">' + (open ? '▾' : '▸') + '</span>' + esc(g.title) +
|
|
368
|
+
'<span class="count">' + g.pages.length + '</span></div>';
|
|
369
|
+
}
|
|
370
|
+
if (!open) return;
|
|
371
|
+
var limit = (ui.shown && ui.shown[key]) || NAV_GROUP_PAGE;
|
|
372
|
+
g.pages.slice(0, limit).forEach(function (pg) {
|
|
373
|
+
var active = pg.path === ui.current && (!ui.currentScope || pg.scope === ui.currentScope);
|
|
374
|
+
html += '<a data-path="' + esc(pg.path) + '" data-scope="' + esc(pg.scope) + '"' +
|
|
375
|
+
(active ? ' class="active"' : '') + ' title="' + esc(pg.path) + '">' + esc(pg.label) + '</a>';
|
|
376
|
+
});
|
|
377
|
+
if (g.pages.length > limit) {
|
|
378
|
+
html += '<a class="more" data-more="' + esc(key) + '">显示更多(还有 ' + (g.pages.length - limit) + ' 页)</a>';
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
return html;
|
|
383
|
+
}
|
|
384
|
+
/** Nav group key that holds a page, so opening a page opens its group. */
|
|
385
|
+
function groupKeyOf(model, path, scope) {
|
|
386
|
+
for (var i = 0; i < model.length; i++) {
|
|
387
|
+
for (var j = 0; j < model[i].groups.length; j++) {
|
|
388
|
+
var g = model[i].groups[j];
|
|
389
|
+
if (g.pages.some(function (p) { return p.path === path && (!scope || p.scope === scope); })) {
|
|
390
|
+
return navGroupKey(model[i].scope, g.title);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* One chunk of a page by lines; mirrors the backend `read` paging
|
|
398
|
+
* (`limit_lines`): a boundary inside a ``` fence moves to the fence end, so
|
|
399
|
+
* every chunk renders as valid markdown on its own.
|
|
400
|
+
*/
|
|
401
|
+
function pageLines(content, offset, limit) {
|
|
402
|
+
var lines = String(content).split('\n');
|
|
403
|
+
var total = lines.length;
|
|
404
|
+
var start = Math.min(offset, total);
|
|
405
|
+
var end = Math.min(start + Math.max(1, limit), total);
|
|
406
|
+
var fences = 0;
|
|
407
|
+
for (var i = 0; i < end; i++) if (/^\s*```/.test(lines[i])) fences++;
|
|
408
|
+
while (end < total && fences % 2 === 1) { if (/^\s*```/.test(lines[end])) fences++; end++; }
|
|
409
|
+
return { content: lines.slice(start, end).join('\n'), offsetLines: start,
|
|
410
|
+
nextOffsetLines: end < total ? end : null, totalLines: total };
|
|
411
|
+
}
|
|
412
|
+
/** Search terms: whitespace-separated, lowercased, de-duplicated (mirrors the backend AND match). */
|
|
413
|
+
function searchTerms(q) {
|
|
414
|
+
var seen = {}, out = [];
|
|
415
|
+
String(q || '').toLowerCase().split(/\s+/).forEach(function (t) { if (t && !seen[t]) { seen[t] = 1; out.push(t); } });
|
|
416
|
+
return out;
|
|
417
|
+
}
|
|
418
|
+
/** Escaped HTML of `text` with every term wrapped in <mark>; longest term wins on overlap. */
|
|
419
|
+
function markTerms(text, terms) {
|
|
420
|
+
var s = String(text == null ? '' : text);
|
|
421
|
+
if (!terms || !terms.length) return esc(s);
|
|
422
|
+
var lower = s.toLowerCase(), out = '', i = 0;
|
|
423
|
+
var sorted = terms.slice().sort(function (a, b) { return b.length - a.length; });
|
|
424
|
+
while (i < s.length) {
|
|
425
|
+
var hit = null;
|
|
426
|
+
for (var k = 0; k < sorted.length; k++) { if (lower.substr(i, sorted[k].length) === sorted[k]) { hit = sorted[k]; break; } }
|
|
427
|
+
if (hit) { out += '<mark>' + esc(s.substr(i, hit.length)) + '</mark>'; i += hit.length; }
|
|
428
|
+
else { out += esc(s[i]); i++; }
|
|
429
|
+
}
|
|
430
|
+
return out;
|
|
431
|
+
}
|
|
432
|
+
var SEARCH_GROUP_LABELS = { root: '页面', logs: '日志', raw: '文档', knowledge: '知识', workflow: '做事方法', project: '项目', incidents: '经验教训', reference: '参考', feedback: '反馈' };
|
|
433
|
+
/** Search results markup (pure). */
|
|
434
|
+
function searchResultsHtml(res, terms, opts) {
|
|
435
|
+
var results = (res && res.results) || [];
|
|
436
|
+
var head = '<div class="search-head"><span class="grow" data-testid="memory-search-count">' +
|
|
437
|
+
(results.length ? ('找到 ' + res.total + ' 页' + (res.truncated ? ',显示前 ' + results.length + ' 页' : '')) : '没有找到包含「' + esc(terms.join(' ')) + '」的页面') +
|
|
438
|
+
'</span><label><input type="checkbox" id="search-logs"' + (opts && opts.includeLogs ? ' checked' : '') + ' /> 包括日志</label></div>';
|
|
439
|
+
return head + results.map(function (r) {
|
|
440
|
+
var meta = (SEARCH_GROUP_LABELS[r.group] || r.group) + (r.scope === 'global' ? ' · 共享' : '');
|
|
441
|
+
return '<a class="hit" data-testid="memory-search-hit" data-hit-path="' + esc(r.path) + '" data-hit-scope="' + esc(r.scope) + '">' +
|
|
442
|
+
'<div class="t">' + markTerms(r.title, terms) + '<span class="m">' + esc(meta) + '</span></div>' +
|
|
443
|
+
(r.snippet ? '<div class="s">' + markTerms(r.snippet, terms) + '</div>' : '') + '</a>';
|
|
444
|
+
}).join('');
|
|
445
|
+
}
|
|
446
|
+
/* @pure-end */
|
|
447
|
+
|
|
448
|
+
/* ------------------------------- wiki state ------------------------------- */
|
|
449
|
+
var state = { scope: 'auto', files: [], entries: [], current: 'index.md', currentScope: null, error: null,
|
|
450
|
+
filter: '', model: [], navUi: { open: {}, shown: {} }, pagedRead: null, readSeq: 0 };
|
|
451
|
+
var navEl = document.getElementById('nav');
|
|
452
|
+
var navListEl = document.getElementById('nav-list');
|
|
453
|
+
var navFilterEl = document.getElementById('nav-filter');
|
|
454
|
+
var pageEl = document.getElementById('page');
|
|
455
|
+
|
|
456
|
+
function cleanPath(tagged) {
|
|
457
|
+
return tagged.replace(/^\[(private[^\]]*|global)\]\s+/, '');
|
|
458
|
+
}
|
|
459
|
+
function taggedScope(tagged) {
|
|
460
|
+
var match = tagged.match(/^\[(private[^\]]*|global)\]\s+/);
|
|
461
|
+
return match ? (match[1].indexOf('private') === 0 ? 'private' : 'global') : null;
|
|
462
|
+
}
|
|
463
|
+
function resolveLink(fromPath, href) {
|
|
464
|
+
// Resolve a page-relative markdown link to a scope-relative path.
|
|
465
|
+
if (/^https?:\/\//.test(href)) return href;
|
|
466
|
+
var dir = fromPath.indexOf('/') >= 0 ? fromPath.replace(/\/[^/]*$/, '') : '';
|
|
467
|
+
var parts = (dir ? dir.split('/') : []).concat(href.split('/'));
|
|
468
|
+
var stack = [];
|
|
469
|
+
for (var k = 0; k < parts.length; k++) {
|
|
470
|
+
var seg = parts[k];
|
|
471
|
+
if (seg === '.' || seg === '') continue;
|
|
472
|
+
if (seg === '..') stack.pop(); else stack.push(seg);
|
|
473
|
+
}
|
|
474
|
+
return stack.join('/');
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async function loadFiles() {
|
|
478
|
+
state.error = null;
|
|
479
|
+
try {
|
|
480
|
+
var data = await window.cteno.call('list', { scope: state.scope });
|
|
481
|
+
var tagged = (Array.isArray(data) ? data : []).map(function (entry) {
|
|
482
|
+
return { scope: taggedScope(entry), path: cleanPath(entry) };
|
|
483
|
+
});
|
|
484
|
+
// The core list contract returns project + global pages (agent recall
|
|
485
|
+
// searches both). Keep the scope tag on every entry so two files with the
|
|
486
|
+
// same path in different scopes never mix; Auto shows both scopes.
|
|
487
|
+
state.entries = visibleEntries(tagged, state.scope);
|
|
488
|
+
state.files = state.entries.map(function (e) { return e.path; });
|
|
489
|
+
} catch (e) {
|
|
490
|
+
state.entries = [];
|
|
491
|
+
state.files = [];
|
|
492
|
+
state.error = String(e.message || e);
|
|
493
|
+
}
|
|
494
|
+
rebuildNav();
|
|
495
|
+
}
|
|
496
|
+
/** Docs catch-up runs behind the page, never in front of it. Older daemons may
|
|
497
|
+
* not expose sync_docs; a mismatch must never turn into an empty page. */
|
|
498
|
+
async function syncDocsInBackground() {
|
|
499
|
+
if (state.scope === 'global') return;
|
|
500
|
+
var res;
|
|
501
|
+
try { res = await window.cteno.call('sync_docs', { scope: state.scope }); }
|
|
502
|
+
catch (e) { console.warn('Memory docs catch-up unavailable:', e); return; }
|
|
503
|
+
var changed = res && ['created', 'removed', 'updated'].some(function (k) { return (res[k] || []).length; });
|
|
504
|
+
if (changed) await loadFiles();
|
|
505
|
+
}
|
|
506
|
+
function rebuildNav() {
|
|
507
|
+
state.model = navModel(filterEntries(state.entries, state.filter));
|
|
508
|
+
renderNav();
|
|
509
|
+
}
|
|
510
|
+
function renderNav() {
|
|
511
|
+
if (state.error) { navListEl.innerHTML = '<div class="err">加载失败:' + esc(state.error) + '</div>'; return; }
|
|
512
|
+
if (!state.entries.length) { navListEl.innerHTML = '<div class="group">暂无页面</div>'; return; }
|
|
513
|
+
if (!state.model.length) { navListEl.innerHTML = '<div class="group">没有匹配的页面</div>'; return; }
|
|
514
|
+
navListEl.innerHTML = navHtml(state.model, {
|
|
515
|
+
open: state.navUi.open, shown: state.navUi.shown, filtering: !!state.filter.trim(),
|
|
516
|
+
current: state.current, currentScope: state.currentScope
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
/** Move the highlight without rebuilding the nav; open the page's group if needed. */
|
|
520
|
+
function markActive() {
|
|
521
|
+
var key = groupKeyOf(state.model, state.current, state.currentScope);
|
|
522
|
+
if (key && !state.navUi.open[key] && !state.filter.trim()) {
|
|
523
|
+
state.navUi.open[key] = true;
|
|
524
|
+
renderNav();
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
var prev = navListEl.querySelectorAll('a.active');
|
|
528
|
+
for (var i = 0; i < prev.length; i++) prev[i].classList.remove('active');
|
|
529
|
+
var links = navListEl.querySelectorAll('a[data-path]');
|
|
530
|
+
for (var j = 0; j < links.length; j++) {
|
|
531
|
+
var a = links[j];
|
|
532
|
+
if (a.getAttribute('data-path') === state.current &&
|
|
533
|
+
(!state.currentScope || a.getAttribute('data-scope') === state.currentScope)) {
|
|
534
|
+
a.classList.add('active');
|
|
535
|
+
break;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/* ---- article: rendered chunk by chunk as the reader scrolls ---------------- */
|
|
541
|
+
var pageObserver = null;
|
|
542
|
+
var capsPromise = null;
|
|
543
|
+
/** Plugin capabilities, probed once per page load; older daemons answer {} or fail. */
|
|
544
|
+
function loadCaps() {
|
|
545
|
+
if (!capsPromise) {
|
|
546
|
+
capsPromise = Promise.resolve().then(function () { return window.cteno.call('capabilities', {}); })
|
|
547
|
+
.then(function (c) { return c || {}; }, function () { return {}; });
|
|
548
|
+
}
|
|
549
|
+
return capsPromise;
|
|
550
|
+
}
|
|
551
|
+
async function hasPagedRead() {
|
|
552
|
+
if (state.pagedRead === null) state.pagedRead = ((await loadCaps()).pagedRead || 0) >= 1;
|
|
553
|
+
return state.pagedRead;
|
|
554
|
+
}
|
|
555
|
+
/** One chunk: from the backend when it pages, else from the whole page read once. */
|
|
556
|
+
async function readChunk(ctx, offset) {
|
|
557
|
+
if (ctx.whole != null) return pageLines(ctx.whole, offset, PAGE_CHUNK_LINES);
|
|
558
|
+
if (await hasPagedRead()) {
|
|
559
|
+
return window.cteno.call('read', { file_path: ctx.path, scope: ctx.scope, offset_lines: offset, limit_lines: PAGE_CHUNK_LINES });
|
|
560
|
+
}
|
|
561
|
+
var content = await window.cteno.call('read', { file_path: ctx.path, scope: ctx.scope });
|
|
562
|
+
if (content == null) return null;
|
|
563
|
+
ctx.whole = String(content);
|
|
564
|
+
return pageLines(ctx.whole, offset, PAGE_CHUNK_LINES);
|
|
565
|
+
}
|
|
566
|
+
function appendChunk(ctx, chunk) {
|
|
567
|
+
var md = chunk.content;
|
|
568
|
+
var html = '';
|
|
569
|
+
if (chunk.offsetLines === 0) {
|
|
570
|
+
var split = splitFrontmatter(md);
|
|
571
|
+
if (split.fm) html += '<div class="frontmatter">' + esc(split.fm) + '</div>';
|
|
572
|
+
md = split.body;
|
|
573
|
+
}
|
|
574
|
+
html += renderMarkdown(localizeIndexMarkdown(ctx.path, md));
|
|
575
|
+
var old = pageEl.querySelector('.sentinel');
|
|
576
|
+
if (old) old.remove();
|
|
577
|
+
pageEl.insertAdjacentHTML('beforeend', html);
|
|
578
|
+
if (ctx.terms) markInPage(ctx.terms);
|
|
579
|
+
ctx.next = chunk.nextOffsetLines;
|
|
580
|
+
if (ctx.next != null) {
|
|
581
|
+
pageEl.insertAdjacentHTML('beforeend', '<div class="sentinel" data-testid="memory-page-more">加载更多…(' +
|
|
582
|
+
ctx.next + ' / ' + chunk.totalLines + ' 行)</div>');
|
|
583
|
+
var s = pageEl.querySelector('.sentinel');
|
|
584
|
+
if (pageObserver) pageObserver.observe(s);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
async function loadMore(ctx) {
|
|
588
|
+
if (ctx.loading || ctx.next == null || ctx.seq !== state.readSeq) return;
|
|
589
|
+
ctx.loading = true;
|
|
590
|
+
try {
|
|
591
|
+
var chunk = await readChunk(ctx, ctx.next);
|
|
592
|
+
if (ctx.seq !== state.readSeq) return;
|
|
593
|
+
if (chunk && typeof chunk === 'object') appendChunk(ctx, chunk);
|
|
594
|
+
} catch (e) {
|
|
595
|
+
var s = pageEl.querySelector('.sentinel');
|
|
596
|
+
if (s) s.textContent = '加载失败,滚动重试:' + String(e.message || e);
|
|
597
|
+
} finally { ctx.loading = false; }
|
|
598
|
+
}
|
|
599
|
+
var currentCtx = null;
|
|
600
|
+
if ('IntersectionObserver' in window) {
|
|
601
|
+
pageObserver = new IntersectionObserver(function (items) {
|
|
602
|
+
items.forEach(function (it) { if (it.isIntersecting && currentCtx) loadMore(currentCtx); });
|
|
603
|
+
}, { root: pageEl, rootMargin: '600px 0px' });
|
|
604
|
+
}
|
|
605
|
+
async function loadPage(path, scopeHint, opts) {
|
|
606
|
+
opts = opts || {};
|
|
607
|
+
if (!opts.terms) state.fromSearch = false;
|
|
608
|
+
state.current = path;
|
|
609
|
+
state.currentScope = scopeHint || (state.scope === 'auto' ? null : state.scope);
|
|
610
|
+
markActive();
|
|
611
|
+
if (!state.entries.length && !state.error) { pageEl.innerHTML = EMPTY_MEMORY_HTML; return; }
|
|
612
|
+
var seq = ++state.readSeq;
|
|
613
|
+
var ctx = { seq: seq, path: path, scope: state.currentScope || undefined, whole: null, next: null, loading: false, terms: opts.terms || null };
|
|
614
|
+
currentCtx = ctx;
|
|
615
|
+
if (pageObserver) pageObserver.disconnect();
|
|
616
|
+
pageEl.innerHTML = '<div class="empty">加载中…</div>';
|
|
617
|
+
try {
|
|
618
|
+
var chunk = await readChunk(ctx, 0);
|
|
619
|
+
if (seq !== state.readSeq) return; // a newer page was opened meanwhile
|
|
620
|
+
if (chunk == null) { pageEl.innerHTML = '<div class="empty">这一页不存在或还没有内容</div>'; return; }
|
|
621
|
+
pageEl.innerHTML = state.fromSearch
|
|
622
|
+
? '<div class="search-head"><a data-testid="memory-search-back" data-back="1">← 返回搜索结果</a><span class="grow"></span><span data-testid="memory-search-hitcount"></span></div>' : '';
|
|
623
|
+
pageEl.scrollTop = 0;
|
|
624
|
+
appendChunk(ctx, chunk);
|
|
625
|
+
if (!pageEl.textContent.trim()) pageEl.innerHTML = '<div class="empty">这一页还没有内容</div>';
|
|
626
|
+
if (ctx.terms) await seekFirstHit(ctx);
|
|
627
|
+
} catch (e) {
|
|
628
|
+
if (seq !== state.readSeq) return;
|
|
629
|
+
pageEl.innerHTML = '<div class="err">加载失败:' + esc(pageLabel(path)) + '<br>' + esc(String(e.message || e)) + '</div>';
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
// One delegated listener for every rendered link (index.md alone has ~2k).
|
|
633
|
+
pageEl.addEventListener('click', function (ev) {
|
|
634
|
+
if (ev.target.closest('[data-back]')) { showSearchResults(); return; }
|
|
635
|
+
var hit = ev.target.closest('[data-hit-path]');
|
|
636
|
+
if (hit) {
|
|
637
|
+
state.fromSearch = true;
|
|
638
|
+
loadPage(hit.getAttribute('data-hit-path'), hit.getAttribute('data-hit-scope'), { terms: searchTerms(state.search.query) });
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
var s = ev.target.closest('.sentinel');
|
|
642
|
+
if (s && currentCtx) { loadMore(currentCtx); return; }
|
|
643
|
+
var a = ev.target.closest('a[data-href]');
|
|
644
|
+
if (!a || a.getAttribute('data-ext')) return; // external: ignore in v1
|
|
645
|
+
var target = resolveLink(state.current, a.getAttribute('data-href'));
|
|
646
|
+
if (!/\.md$/.test(target)) target += '.md';
|
|
647
|
+
loadPage(target, state.currentScope);
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
/* ---- full-text search --------------------------------------------------- */
|
|
651
|
+
var searchEl = document.getElementById('search');
|
|
652
|
+
state.search = { query: '', includeLogs: false, seq: 0, res: null };
|
|
653
|
+
state.fromSearch = false;
|
|
654
|
+
/** Wrap every term occurrence in the rendered page with <mark> (skips code fences already marked). */
|
|
655
|
+
function markInPage(terms) {
|
|
656
|
+
if (!terms || !terms.length) return;
|
|
657
|
+
var walker = document.createTreeWalker(pageEl, NodeFilter.SHOW_TEXT, {
|
|
658
|
+
acceptNode: function (n) {
|
|
659
|
+
if (!n.nodeValue.trim() || (n.parentNode && n.parentNode.closest && n.parentNode.closest('mark,.search-head,.sentinel,.frontmatter'))) return NodeFilter.FILTER_REJECT;
|
|
660
|
+
var low = n.nodeValue.toLowerCase();
|
|
661
|
+
for (var i = 0; i < terms.length; i++) if (low.indexOf(terms[i]) >= 0) return NodeFilter.FILTER_ACCEPT;
|
|
662
|
+
return NodeFilter.FILTER_REJECT;
|
|
663
|
+
}
|
|
664
|
+
});
|
|
665
|
+
var nodes = []; while (walker.nextNode()) nodes.push(walker.currentNode);
|
|
666
|
+
nodes.forEach(function (n) {
|
|
667
|
+
var span = document.createElement('span');
|
|
668
|
+
span.innerHTML = markTerms(n.nodeValue, terms);
|
|
669
|
+
var frag = document.createDocumentFragment();
|
|
670
|
+
while (span.firstChild) frag.appendChild(span.firstChild);
|
|
671
|
+
n.parentNode.replaceChild(frag, n);
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
/** Load further chunks until the first hit is rendered (bounded), then scroll to it. */
|
|
675
|
+
async function seekFirstHit(ctx) {
|
|
676
|
+
for (var i = 0; i < 40 && !pageEl.querySelector('mark') && ctx.next != null && ctx.seq === state.readSeq; i++) {
|
|
677
|
+
await loadMore(ctx);
|
|
678
|
+
}
|
|
679
|
+
if (ctx.seq !== state.readSeq) return;
|
|
680
|
+
var marks = pageEl.querySelectorAll('mark');
|
|
681
|
+
var cnt = pageEl.querySelector('[data-testid="memory-search-hitcount"]');
|
|
682
|
+
if (cnt) cnt.textContent = marks.length ? ('本页已显示 ' + marks.length + ' 处') : '命中在页面标题或路径里';
|
|
683
|
+
if (marks.length) { marks[0].classList.add('current'); marks[0].scrollIntoView({ block: 'center' }); }
|
|
684
|
+
}
|
|
685
|
+
function showSearchResults() {
|
|
686
|
+
state.readSeq++; currentCtx = null; if (pageObserver) pageObserver.disconnect();
|
|
687
|
+
var terms = searchTerms(state.search.query);
|
|
688
|
+
pageEl.innerHTML = searchResultsHtml(state.search.res, terms, { includeLogs: state.search.includeLogs });
|
|
689
|
+
pageEl.scrollTop = 0;
|
|
690
|
+
}
|
|
691
|
+
async function runSearch() {
|
|
692
|
+
var q = state.search.query, terms = searchTerms(q);
|
|
693
|
+
var seq = ++state.search.seq;
|
|
694
|
+
if (!terms.length) { state.search.res = null; state.fromSearch = false; loadPage(state.current, state.currentScope); return; }
|
|
695
|
+
state.readSeq++; currentCtx = null;
|
|
696
|
+
pageEl.innerHTML = '<div class="empty">搜索中…</div>';
|
|
697
|
+
try {
|
|
698
|
+
var res = await window.cteno.call('search', { scope: state.scope, query: q, limit: 50, include_logs: state.search.includeLogs });
|
|
699
|
+
if (seq !== state.search.seq) return; // a newer query is in flight
|
|
700
|
+
state.search.res = res || { results: [], total: 0 };
|
|
701
|
+
showSearchResults();
|
|
702
|
+
} catch (e) {
|
|
703
|
+
if (seq !== state.search.seq) return;
|
|
704
|
+
pageEl.innerHTML = '<div class="err">搜索失败:' + esc(String(e.message || e)) + '</div>';
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
var searchTimer = null;
|
|
708
|
+
searchEl.addEventListener('input', function () {
|
|
709
|
+
clearTimeout(searchTimer);
|
|
710
|
+
searchTimer = setTimeout(function () { state.search.query = searchEl.value; runSearch(); }, 250);
|
|
711
|
+
});
|
|
712
|
+
searchEl.addEventListener('keydown', function (ev) {
|
|
713
|
+
if (ev.key === 'Escape') { searchEl.value = ''; clearTimeout(searchTimer); state.search.query = ''; runSearch(); }
|
|
714
|
+
if (ev.key === 'Enter') { clearTimeout(searchTimer); state.search.query = searchEl.value; runSearch(); }
|
|
715
|
+
});
|
|
716
|
+
pageEl.addEventListener('change', function (ev) {
|
|
717
|
+
if (ev.target && ev.target.id === 'search-logs') { state.search.includeLogs = ev.target.checked; runSearch(); }
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
var appEl = document.getElementById('app');
|
|
721
|
+
function closeNav() { appEl.classList.remove('nav-open'); }
|
|
722
|
+
|
|
723
|
+
navListEl.addEventListener('click', function (ev) {
|
|
724
|
+
var g = ev.target.closest('[data-group]');
|
|
725
|
+
if (g) { var k = g.getAttribute('data-group'); state.navUi.open[k] = !state.navUi.open[k]; renderNav(); return; }
|
|
726
|
+
var more = ev.target.closest('[data-more]');
|
|
727
|
+
if (more) { var mk = more.getAttribute('data-more'); state.navUi.shown[mk] = ((state.navUi.shown[mk]) || NAV_GROUP_PAGE) + NAV_GROUP_PAGE; renderNav(); return; }
|
|
728
|
+
var a = ev.target.closest('a[data-path]');
|
|
729
|
+
if (a) { loadPage(a.getAttribute('data-path'), a.getAttribute('data-scope')); closeNav(); }
|
|
730
|
+
});
|
|
731
|
+
var filterTimer = null;
|
|
732
|
+
navFilterEl.addEventListener('input', function () {
|
|
733
|
+
clearTimeout(filterTimer);
|
|
734
|
+
filterTimer = setTimeout(function () { state.filter = navFilterEl.value; rebuildNav(); }, 120);
|
|
735
|
+
});
|
|
736
|
+
document.getElementById('menu').addEventListener('click', function () {
|
|
737
|
+
appEl.classList.toggle('nav-open');
|
|
738
|
+
});
|
|
739
|
+
document.getElementById('scrim').addEventListener('click', closeNav);
|
|
740
|
+
document.getElementById('scope').addEventListener('change', function (ev) {
|
|
741
|
+
state.scope = ev.target.value; refresh();
|
|
742
|
+
});
|
|
743
|
+
document.getElementById('refresh').addEventListener('click', refresh);
|
|
744
|
+
|
|
745
|
+
// ---- Dream outcomes -------------------------------------------------------
|
|
746
|
+
var dreamsEl = document.getElementById('dreams');
|
|
747
|
+
function dreamScope() { return state.scope === 'global' ? 'global' : 'private'; }
|
|
748
|
+
function fmtTime(ms) { try { return new Date(ms).toLocaleString(); } catch (e) { return ''; } }
|
|
749
|
+
async function loadDreams() {
|
|
750
|
+
var status;
|
|
751
|
+
try { status = await window.cteno.call('dream_status', { scope: dreamScope() }); }
|
|
752
|
+
catch (e) { dreamsEl.innerHTML = ''; return; } // older backend: no section
|
|
753
|
+
var seenAt = (status && status.seenAtMs) || 0;
|
|
754
|
+
var runs = (status && status.runs) || [];
|
|
755
|
+
var failed = runs.filter(function (r) { return r.status === 'failed' && !r.dismissed; });
|
|
756
|
+
var fresh = runs.filter(function (r) { return r.status === 'ok' && (r.finishedAtMs || 0) > seenAt; });
|
|
757
|
+
var html = '';
|
|
758
|
+
failed.forEach(function (r) {
|
|
759
|
+
html += '<div class="row failed" data-testid="memory-dream-failed">' +
|
|
760
|
+
'<span class="badge"></span><div class="body">' +
|
|
761
|
+
'<div class="what">记忆整理失败 · ' + esc(fmtTime(r.finishedAtMs || r.startedAtMs)) + '</div>' +
|
|
762
|
+
'<div class="detail">' + esc(r.error || '未知错误') + (r.summary ? '<br>' + esc(r.summary) : '') + '</div>' +
|
|
763
|
+
'</div><div class="actions">' +
|
|
764
|
+
(r.hasSession ? '<button data-open="' + esc(r.runId) + '" data-testid="memory-dream-open">进入对话</button>' : '') +
|
|
765
|
+
'<button data-dismiss="' + esc(r.runId) + '" data-testid="memory-dream-dismiss">忽略</button>' +
|
|
766
|
+
'</div></div>';
|
|
767
|
+
});
|
|
768
|
+
fresh.forEach(function (r) {
|
|
769
|
+
var pages = (r.pages || []).map(function (p) {
|
|
770
|
+
return '<a data-page="' + esc(p) + '">' + esc(p) + '</a>';
|
|
771
|
+
}).join('');
|
|
772
|
+
html += '<div class="row ok" data-testid="memory-dream-new">' +
|
|
773
|
+
'<span class="badge"></span><div class="body">' +
|
|
774
|
+
'<div class="what">新的记忆整理结果 · ' + esc(fmtTime(r.finishedAtMs)) + '</div>' +
|
|
775
|
+
(r.summary ? '<div class="detail">' + esc(r.summary) + '</div>' : '') +
|
|
776
|
+
(pages ? '<div class="detail pages">' + pages + '</div>' : '') +
|
|
777
|
+
'</div><div class="actions">' +
|
|
778
|
+
(r.hasSession ? '<button data-open="' + esc(r.runId) + '">查看对话</button>' : '') +
|
|
779
|
+
'</div></div>';
|
|
780
|
+
});
|
|
781
|
+
dreamsEl.innerHTML = html;
|
|
782
|
+
// Opening the page counts as seeing new results; failures stay until
|
|
783
|
+
// dismissed or fixed (a later ok report under the same session).
|
|
784
|
+
if (fresh.length) {
|
|
785
|
+
try { await window.cteno.call('dream_mark_seen', { scope: dreamScope() }); } catch (e) {}
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
dreamsEl.addEventListener('click', async function (ev) {
|
|
789
|
+
var t = ev.target.closest('[data-open],[data-dismiss],[data-page]');
|
|
790
|
+
if (!t) return;
|
|
791
|
+
if (t.hasAttribute('data-page')) { loadPage(t.getAttribute('data-page')); return; }
|
|
792
|
+
if (t.hasAttribute('data-dismiss')) {
|
|
793
|
+
try { await window.cteno.call('dream_dismiss', { scope: dreamScope(), run_id: t.getAttribute('data-dismiss') }); }
|
|
794
|
+
catch (e) { console.warn('dismiss failed', e); }
|
|
795
|
+
loadDreams();
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
try {
|
|
799
|
+
var res = await window.cteno.call('platform.session.open', { session_id: t.getAttribute('data-open') });
|
|
800
|
+
// The CEF pane cannot reach the app router: the host hands back the
|
|
801
|
+
// pane's own conversation URL and the page navigates there.
|
|
802
|
+
if (res && res.url) window.location.href = res.url;
|
|
803
|
+
} catch (e) { alert('无法打开对话:' + (e.message || e)); }
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
async function refresh() {
|
|
807
|
+
loadDreams();
|
|
808
|
+
var caps = loadCaps(); // in flight alongside list: no extra round trip
|
|
809
|
+
await loadFiles();
|
|
810
|
+
if (((await caps).search || 0) >= 1) searchEl.hidden = false;
|
|
811
|
+
await hasPagedRead();
|
|
812
|
+
var landing = landingEntry(state.entries);
|
|
813
|
+
var opened = loadPage(landing ? landing.path : 'index.md', landing ? landing.scope : null);
|
|
814
|
+
syncDocsInBackground();
|
|
815
|
+
await opened;
|
|
816
|
+
// A scope switch / refresh keeps an active search: re-run it in the new scope.
|
|
817
|
+
if (searchTerms(state.search.query).length) runSearch();
|
|
818
|
+
}
|
|
819
|
+
refresh();
|
|
820
|
+
</script>
|
|
821
|
+
</body>
|
|
822
|
+
</html>
|