psyclaw 0.27.0 → 0.27.5

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.
Files changed (56) hide show
  1. package/README.md +9 -5
  2. package/dist/apps/panel/index.html +78 -3406
  3. package/dist/src/adapters/pi/extension.js +406 -107
  4. package/dist/src/adapters/pi/extension.js.map +1 -1
  5. package/dist/src/adapters/pi/rpc.js +7 -4
  6. package/dist/src/adapters/pi/rpc.js.map +1 -1
  7. package/dist/src/branding.js +7 -5
  8. package/dist/src/branding.js.map +1 -1
  9. package/dist/src/chat.js +0 -5
  10. package/dist/src/chat.js.map +1 -1
  11. package/dist/src/cli.js +0 -0
  12. package/dist/src/core/citations.d.ts +2 -0
  13. package/dist/src/core/citations.js +4 -1
  14. package/dist/src/core/citations.js.map +1 -1
  15. package/dist/src/core/docx.js +2 -2
  16. package/dist/src/core/docx.js.map +1 -1
  17. package/dist/src/index.d.ts +3 -0
  18. package/dist/src/index.js +3 -0
  19. package/dist/src/index.js.map +1 -1
  20. package/dist/src/literature/archive.d.ts +30 -0
  21. package/dist/src/literature/archive.js +133 -0
  22. package/dist/src/literature/archive.js.map +1 -0
  23. package/dist/src/orchestration/research-agents.d.ts +32 -0
  24. package/dist/src/orchestration/research-agents.js +225 -0
  25. package/dist/src/orchestration/research-agents.js.map +1 -0
  26. package/dist/src/panel/extension.js +2 -52
  27. package/dist/src/panel/extension.js.map +1 -1
  28. package/dist/src/panel/server.d.ts +2 -0
  29. package/dist/src/panel/server.js +113 -74
  30. package/dist/src/panel/server.js.map +1 -1
  31. package/dist/src/project/hitl.js +2 -0
  32. package/dist/src/project/hitl.js.map +1 -1
  33. package/dist/src/project/paths.d.ts +2 -6
  34. package/dist/src/project/paths.js +4 -12
  35. package/dist/src/project/paths.js.map +1 -1
  36. package/dist/src/skills/recommended.d.ts +10 -0
  37. package/dist/src/skills/recommended.js +85 -7
  38. package/dist/src/skills/recommended.js.map +1 -1
  39. package/dist/src/telemetry/export.d.ts +32 -0
  40. package/dist/src/telemetry/export.js +102 -1
  41. package/dist/src/telemetry/export.js.map +1 -1
  42. package/dist/src/tui/provider-picker.d.ts +48 -0
  43. package/dist/src/tui/provider-picker.js +115 -0
  44. package/dist/src/tui/provider-picker.js.map +1 -0
  45. package/dist/src/workflows/academic-export.d.ts +14 -0
  46. package/dist/src/workflows/academic-export.js +42 -0
  47. package/dist/src/workflows/academic-export.js.map +1 -0
  48. package/dist/src/workflows/meta-analysis.d.ts +1 -0
  49. package/dist/src/workflows/meta-analysis.js +17 -9
  50. package/dist/src/workflows/meta-analysis.js.map +1 -1
  51. package/dist/src/workflows/publish.js +14 -0
  52. package/dist/src/workflows/publish.js.map +1 -1
  53. package/package.json +1 -1
  54. package/skills/core/academic-grill/SKILL.md +77 -0
  55. package/skills/core/manifest.json +3 -2
  56. package/skills/recommended/catalog.json +210 -44
@@ -1,3416 +1,88 @@
1
- <!DOCTYPE html>
2
- <html lang="zh-CN" data-theme="dark">
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
3
  <head>
4
- <meta charset="utf-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1">
6
- <title>PsyClaw 科研工作台 & 交互中心</title>
7
- <link rel="preconnect" href="https://fonts.googleapis.com">
8
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
- <link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
10
- <style>
11
- /* ==============================================================
12
- 4 nteract-Inspired Themes Palette (Dark / Light / Paper / Nord)
13
- ============================================================== */
14
- :root, [data-theme="dark"] {
15
- color-scheme: dark;
16
- --bg: #090d14;
17
- --bg-card: rgba(18, 24, 38, 0.72);
18
- --bg-card-solid: #121826;
19
- --bg-inner: #090d14;
20
- --line: rgba(255, 255, 255, 0.08);
21
- --line-highlight: rgba(46, 196, 182, 0.28);
22
-
23
- --ink: #f0f4fc;
24
- --ink-muted: #8e9bb2;
25
- --ink-faint: #5a667d;
26
-
27
- --accent: #2ec4b6;
28
- --accent-glow: rgba(46, 196, 182, 0.25);
29
- --accent-ink: #64e5d9;
30
- --accent-bg: rgba(46, 196, 182, 0.12);
31
-
32
- --ok: #22c55e;
33
- --ok-bg: rgba(34, 197, 94, 0.12);
34
- --ok-ink: #4ade80;
35
-
36
- --warn: #f59e0b;
37
- --warn-bg: rgba(245, 158, 11, 0.12);
38
- --warn-ink: #fbbf24;
39
-
40
- --crit: #f43f5e;
41
- --crit-bg: rgba(244, 63, 94, 0.12);
42
- --crit-ink: #fb7185;
43
-
44
- --grad-1: radial-gradient(50rem 35rem at 85% -10%, rgba(46, 196, 182, 0.12), transparent 70%);
45
- --grad-2: radial-gradient(45rem 30rem at 10% 20%, rgba(99, 102, 241, 0.08), transparent 65%);
46
- --grad-3: radial-gradient(60rem 40rem at 50% 110%, rgba(14, 165, 233, 0.07), transparent 70%);
47
- }
48
-
49
- [data-theme="light"] {
50
- color-scheme: light;
51
- --bg: #f8fafc;
52
- --bg-card: rgba(255, 255, 255, 0.88);
53
- --bg-card-solid: #ffffff;
54
- --bg-inner: #f1f5f9;
55
- --line: rgba(0, 0, 0, 0.09);
56
- --line-highlight: rgba(13, 148, 136, 0.4);
57
-
58
- --ink: #0f172a;
59
- --ink-muted: #475569;
60
- --ink-faint: #94a3b8;
61
-
62
- --accent: #0d9488;
63
- --accent-glow: rgba(13, 148, 136, 0.2);
64
- --accent-ink: #0f766e;
65
- --accent-bg: rgba(13, 148, 136, 0.1);
66
-
67
- --ok: #16a34a;
68
- --ok-bg: rgba(22, 163, 74, 0.1);
69
- --ok-ink: #15803d;
70
-
71
- --warn: #d97706;
72
- --warn-bg: rgba(217, 119, 6, 0.1);
73
- --warn-ink: #b45309;
74
-
75
- --crit: #e11d48;
76
- --crit-bg: rgba(225, 29, 72, 0.1);
77
- --crit-ink: #be123c;
78
-
79
- --grad-1: radial-gradient(50rem 35rem at 85% -10%, rgba(13, 148, 136, 0.08), transparent 70%);
80
- --grad-2: radial-gradient(45rem 30rem at 10% 20%, rgba(59, 130, 246, 0.06), transparent 65%);
81
- --grad-3: radial-gradient(60rem 40rem at 50% 110%, rgba(14, 165, 233, 0.05), transparent 70%);
82
- }
83
-
84
- [data-theme="paper"] {
85
- color-scheme: light;
86
- --bg: #f5f0e6;
87
- --bg-card: rgba(255, 253, 247, 0.92);
88
- --bg-card-solid: #fffdf8;
89
- --bg-inner: #ece4d2;
90
- --line: rgba(80, 60, 40, 0.12);
91
- --line-highlight: rgba(180, 83, 9, 0.38);
92
-
93
- --ink: #2b261f;
94
- --ink-muted: #665c4e;
95
- --ink-faint: #9c907e;
96
-
97
- --accent: #b45309;
98
- --accent-glow: rgba(180, 83, 9, 0.2);
99
- --accent-ink: #92400e;
100
- --accent-bg: rgba(180, 83, 9, 0.1);
101
-
102
- --ok: #2e7d32;
103
- --ok-bg: rgba(46, 125, 50, 0.1);
104
- --ok-ink: #1b5e20;
105
-
106
- --warn: #c2410c;
107
- --warn-bg: rgba(194, 65, 12, 0.1);
108
- --warn-ink: #9a3412;
109
-
110
- --crit: #b91c1c;
111
- --crit-bg: rgba(185, 28, 28, 0.1);
112
- --crit-ink: #991b1b;
113
-
114
- --grad-1: radial-gradient(50rem 35rem at 85% -10%, rgba(180, 83, 9, 0.08), transparent 70%);
115
- --grad-2: radial-gradient(45rem 30rem at 10% 20%, rgba(217, 119, 6, 0.06), transparent 65%);
116
- --grad-3: radial-gradient(60rem 40rem at 50% 110%, rgba(161, 98, 7, 0.05), transparent 70%);
117
- }
118
-
119
- [data-theme="nord"] {
120
- color-scheme: dark;
121
- --bg: #0f172a;
122
- --bg-card: rgba(30, 41, 59, 0.8);
123
- --bg-card-solid: #1e293b;
124
- --bg-inner: #0b1120;
125
- --line: rgba(148, 163, 184, 0.14);
126
- --line-highlight: rgba(56, 189, 248, 0.38);
127
-
128
- --ink: #f8fafc;
129
- --ink-muted: #94a3b8;
130
- --ink-faint: #64748b;
131
-
132
- --accent: #38bdf8;
133
- --accent-glow: rgba(56, 189, 248, 0.25);
134
- --accent-ink: #7dd3fc;
135
- --accent-bg: rgba(56, 189, 248, 0.12);
136
-
137
- --ok: #22c55e;
138
- --ok-bg: rgba(34, 197, 94, 0.12);
139
- --ok-ink: #4ade80;
140
-
141
- --warn: #f59e0b;
142
- --warn-bg: rgba(245, 158, 11, 0.12);
143
- --warn-ink: #fbbf24;
144
-
145
- --crit: #f43f5e;
146
- --crit-bg: rgba(244, 63, 94, 0.12);
147
- --crit-ink: #fb7185;
148
-
149
- --grad-1: radial-gradient(50rem 35rem at 85% -10%, rgba(56, 189, 248, 0.12), transparent 70%);
150
- --grad-2: radial-gradient(45rem 30rem at 10% 20%, rgba(99, 102, 241, 0.1), transparent 65%);
151
- --grad-3: radial-gradient(60rem 40rem at 50% 110%, rgba(14, 165, 233, 0.08), transparent 70%);
152
- }
153
-
154
- :root {
155
- --radius-sm: 8px;
156
- --radius-md: 14px;
157
- --radius-lg: 20px;
158
- --font: "Plus Jakarta Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
159
- --mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Consolas, monospace;
160
- --shadow-card: 0 10px 30px -5px rgba(0, 0, 0, 0.2), 0 0 0 1px var(--line);
161
- --shadow-glow: 0 0 25px -3px var(--accent-glow), 0 0 0 1px var(--line-highlight);
162
- }
163
-
164
- * { box-sizing: border-box; margin: 0; padding: 0; }
165
- body {
166
- background-color: var(--bg);
167
- color: var(--ink);
168
- font-family: var(--font);
169
- line-height: 1.6;
170
- -webkit-font-smoothing: antialiased;
171
- min-height: 100vh;
172
- background-image: var(--grad-1), var(--grad-2), var(--grad-3);
173
- background-attachment: fixed;
174
- transition: background-color 0.3s ease, color 0.3s ease;
175
- }
176
-
177
- /* Top Navigation */
178
- .header {
179
- position: sticky;
180
- top: 0;
181
- z-index: 100;
182
- backdrop-filter: blur(20px);
183
- -webkit-backdrop-filter: blur(20px);
184
- background: var(--bg-card);
185
- border-bottom: 1px solid var(--line);
186
- padding: 0.75rem 1.5rem;
187
- transition: background-color 0.3s ease, border-color 0.3s ease;
188
- }
189
- .header-inner {
190
- max-width: 82rem;
191
- margin: 0 auto;
192
- display: flex;
193
- align-items: center;
194
- justify-content: space-between;
195
- gap: 1.2rem;
196
- }
197
- .brand {
198
- display: flex;
199
- align-items: center;
200
- gap: 0.75rem;
201
- text-decoration: none;
202
- }
203
- .brand-logo {
204
- width: 32px;
205
- height: 32px;
206
- border-radius: 10px;
207
- background: linear-gradient(135deg, var(--accent), #3b82f6);
208
- display: grid;
209
- place-items: center;
210
- color: #05141b;
211
- font-weight: 800;
212
- font-size: 1.25rem;
213
- line-height: 1;
214
- box-shadow: 0 0 16px var(--accent-glow);
215
- }
216
- .brand-title {
217
- font-size: 1.15rem;
218
- font-weight: 800;
219
- letter-spacing: -0.02em;
220
- color: var(--ink);
221
- }
222
- .brand-tag {
223
- font-size: 0.75rem;
224
- font-weight: 600;
225
- padding: 0.15rem 0.5rem;
226
- border-radius: 999px;
227
- background: var(--accent-bg);
228
- color: var(--accent-ink);
229
- border: 1px solid var(--line-highlight);
230
- }
231
-
232
- /* Tabs Bar */
233
- .nav-tabs {
234
- display: flex;
235
- align-items: center;
236
- gap: 0.3rem;
237
- background: var(--bg-inner);
238
- padding: 0.25rem;
239
- border-radius: 12px;
240
- border: 1px solid var(--line);
241
- overflow-x: auto;
242
- }
243
- .nav-tab {
244
- display: inline-flex;
245
- align-items: center;
246
- gap: 0.45rem;
247
- padding: 0.45rem 0.85rem;
248
- border-radius: 9px;
249
- font-size: 0.84rem;
250
- font-weight: 600;
251
- color: var(--ink-muted);
252
- background: transparent;
253
- border: none;
254
- cursor: pointer;
255
- white-space: nowrap;
256
- transition: all 0.2s cubic-bezier(0.2, 0.8, 0.2, 1);
257
- }
258
- .nav-tab:hover {
259
- color: var(--ink);
260
- }
261
- .nav-tab.active {
262
- color: var(--ink);
263
- background: var(--bg-card-solid);
264
- box-shadow: 0 2px 8px rgba(0,0,0,0.12), 0 0 0 1px var(--line-highlight);
265
- }
266
-
267
- /* Theme Switcher Segmented Control */
268
- .theme-switcher {
269
- display: flex;
270
- align-items: center;
271
- gap: 2px;
272
- background: var(--bg-inner);
273
- border: 1px solid var(--line);
274
- padding: 3px;
275
- border-radius: 999px;
276
- }
277
- .theme-btn {
278
- display: inline-flex;
279
- align-items: center;
280
- gap: 3px;
281
- padding: 3px 8px;
282
- border-radius: 999px;
283
- font-size: 0.72rem;
284
- font-weight: 600;
285
- color: var(--ink-muted);
286
- background: transparent;
287
- border: none;
288
- cursor: pointer;
289
- transition: all 0.15s ease;
290
- }
291
- .theme-btn:hover { color: var(--ink); }
292
- .theme-btn.active {
293
- color: var(--ink);
294
- background: var(--bg-card-solid);
295
- box-shadow: 0 1px 4px rgba(0,0,0,0.12);
296
- font-weight: 750;
297
- }
298
-
299
- /* Header Trust Indicator */
300
- .header-status {
301
- display: flex;
302
- align-items: center;
303
- gap: 0.8rem;
304
- font-size: 0.8rem;
305
- color: var(--ink-muted);
306
- }
307
- .pulse-indicator {
308
- display: inline-flex;
309
- align-items: center;
310
- gap: 0.35rem;
311
- padding: 0.25rem 0.6rem;
312
- border-radius: 999px;
313
- background: var(--ok-bg);
314
- border: 1px solid rgba(34, 197, 94, 0.25);
315
- color: var(--ok-ink);
316
- font-size: 0.75rem;
317
- font-weight: 600;
318
- }
319
- @media (max-width: 430px) {
320
- .header { padding: 0.65rem 0.75rem; }
321
- .header-inner {
322
- display: grid;
323
- grid-template-columns: minmax(0, 1fr) auto;
324
- gap: 0.6rem;
325
- width: 100%;
326
- }
327
- .brand { min-width: 0; gap: 0.5rem; }
328
- .brand-tag { display: none; }
329
- .nav-tabs {
330
- grid-column: 1 / -1;
331
- grid-row: 2;
332
- min-width: 0;
333
- width: 100%;
334
- }
335
- .header-status { gap: 0.35rem; }
336
- .theme-btn { padding-inline: 5px; }
337
- .pulse-indicator { padding-inline: 0.45rem; }
338
- #heartbeat-text { display: none; }
339
- main { padding: 1rem 0.75rem 4rem; }
340
- }
341
- .pulse-dot {
342
- width: 7px;
343
- height: 7px;
344
- border-radius: 50%;
345
- background: var(--ok);
346
- box-shadow: 0 0 8px var(--ok);
347
- animation: livePulse 2s infinite ease-in-out;
348
- }
349
- @keyframes livePulse {
350
- 0%, 100% { transform: scale(0.85); opacity: 0.6; }
351
- 50% { transform: scale(1.2); opacity: 1; }
352
- }
353
-
354
- /* Main Container */
355
- main {
356
- max-width: 82rem;
357
- margin: 0 auto;
358
- padding: 1.5rem 1.5rem 6rem;
359
- }
360
-
361
- /* Tab Views */
362
- .tab-pane {
363
- display: none;
364
- animation: fadeIn 0.25s ease-out;
365
- }
366
- .tab-pane.active {
367
- display: block;
368
- }
369
- @keyframes fadeIn {
370
- from { opacity: 0; transform: translateY(6px); }
371
- to { opacity: 1; transform: translateY(0); }
372
- }
373
-
374
- /* Typography & Utilities */
375
- h2 {
376
- font-size: 1.45rem;
377
- font-weight: 750;
378
- letter-spacing: -0.02em;
379
- color: var(--ink);
380
- margin-bottom: 0.3rem;
381
- }
382
- .lead {
383
- color: var(--ink-muted);
384
- font-size: 0.92rem;
385
- margin-bottom: 1.4rem;
386
- }
387
- .kicker {
388
- font-size: 0.72rem;
389
- font-weight: 700;
390
- letter-spacing: 0.08em;
391
- text-transform: uppercase;
392
- color: var(--ink-faint);
393
- margin-bottom: 0.4rem;
394
- }
395
- code {
396
- font-family: var(--mono);
397
- font-size: 0.85em;
398
- background: var(--bg-inner);
399
- padding: 0.15rem 0.4rem;
400
- border-radius: 5px;
401
- border: 1px solid var(--line);
402
- color: var(--accent-ink);
403
- }
404
- a { color: var(--accent-ink); text-decoration: none; }
405
- a:hover { text-decoration: underline; }
406
-
407
- /* Pill Badges */
408
- .pill {
409
- display: inline-flex;
410
- align-items: center;
411
- gap: 0.35rem;
412
- padding: 0.2rem 0.65rem;
413
- border-radius: 999px;
414
- font-size: 0.76rem;
415
- font-weight: 650;
416
- border: 1px solid transparent;
417
- }
418
- .pill.neutral { color: var(--ink-muted); background: var(--bg-inner); border-color: var(--line); }
419
- .pill.accent { color: var(--accent-ink); background: var(--accent-bg); border-color: var(--line-highlight); box-shadow: 0 0 10px var(--accent-glow); }
420
- .pill.ok { color: var(--ok-ink); background: var(--ok-bg); border-color: rgba(34, 197, 94, 0.3); }
421
- .pill.warn { color: var(--warn-ink); background: var(--warn-bg); border-color: rgba(245, 158, 11, 0.3); }
422
- .pill.crit { color: var(--crit-ink); background: var(--crit-bg); border-color: rgba(244, 63, 94, 0.3); }
423
- .pill .dot { width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
424
-
425
- /* Glass Bento Card Layout */
426
- .card-bento {
427
- background: var(--bg-card);
428
- backdrop-filter: blur(16px);
429
- -webkit-backdrop-filter: blur(16px);
430
- border: 1px solid var(--line);
431
- border-radius: var(--radius-md);
432
- padding: 1.3rem 1.4rem;
433
- box-shadow: var(--shadow-card);
434
- transition: border-color 0.2s ease, transform 0.2s ease;
435
- }
436
- .card-bento:hover {
437
- border-color: var(--line-highlight);
438
- }
439
-
440
- /* ==============================================================
441
- 1. 交互工作台 (Assistant & Interactive Chat Tab)
442
- ============================================================== */
443
- .chat-workspace {
444
- display: grid;
445
- grid-template-columns: 1fr 340px;
446
- gap: 1.25rem;
447
- align-items: start;
448
- }
449
- @media (max-width: 1024px) {
450
- .chat-workspace { grid-template-columns: 1fr; }
451
- }
452
-
453
- .chat-main {
454
- background: var(--bg-card);
455
- border: 1px solid var(--line);
456
- border-radius: var(--radius-lg);
457
- box-shadow: var(--shadow-card);
458
- display: flex;
459
- flex-direction: column;
460
- height: 74vh;
461
- min-height: 540px;
462
- overflow: hidden;
463
- position: relative;
464
- }
465
-
466
- .chat-header {
467
- padding: 0.9rem 1.25rem;
468
- background: var(--bg-inner);
469
- border-bottom: 1px solid var(--line);
470
- display: flex;
471
- align-items: center;
472
- justify-content: space-between;
473
- }
474
- .chat-header-info {
475
- display: flex;
476
- align-items: center;
477
- gap: 0.6rem;
478
- }
479
-
480
- .chips-container {
481
- padding: 0.65rem 1.1rem;
482
- background: var(--bg-inner);
483
- border-bottom: 1px solid var(--line);
484
- display: flex;
485
- gap: 0.5rem;
486
- overflow-x: auto;
487
- white-space: nowrap;
488
- scrollbar-width: none;
489
- }
490
- .chips-container::-webkit-scrollbar { display: none; }
491
- .prompt-chip {
492
- display: inline-flex;
493
- align-items: center;
494
- gap: 0.35rem;
495
- padding: 0.32rem 0.75rem;
496
- background: var(--bg-card);
497
- border: 1px solid var(--line);
498
- border-radius: 999px;
499
- color: var(--ink-muted);
500
- font-size: 0.78rem;
501
- font-weight: 500;
502
- cursor: pointer;
503
- transition: all 0.15s ease;
504
- }
505
- .prompt-chip:hover {
506
- color: var(--accent-ink);
507
- background: var(--accent-bg);
508
- border-color: var(--line-highlight);
509
- transform: translateY(-1px);
510
- }
511
-
512
- .chat-stream {
513
- flex: 1;
514
- overflow-y: auto;
515
- padding: 1.25rem;
516
- display: flex;
517
- flex-direction: column;
518
- gap: 1.1rem;
519
- }
520
-
521
- .msg-bubble {
522
- display: flex;
523
- gap: 0.85rem;
524
- max-width: 88%;
525
- animation: msgIn 0.25s ease-out;
526
- }
527
- @keyframes msgIn {
528
- from { opacity: 0; transform: translateY(8px); }
529
- to { opacity: 1; transform: translateY(0); }
530
- }
531
- .msg-bubble.user {
532
- align-self: flex-end;
533
- flex-direction: row-reverse;
534
- }
535
- .msg-bubble.assistant {
536
- align-self: flex-start;
537
- }
538
-
539
- .msg-avatar {
540
- width: 32px;
541
- height: 32px;
542
- border-radius: 10px;
543
- flex-shrink: 0;
544
- display: grid;
545
- place-items: center;
546
- font-size: 0.85rem;
547
- font-weight: 700;
548
- }
549
- .msg-bubble.assistant .msg-avatar {
550
- background: linear-gradient(135deg, var(--accent), #3b82f6);
551
- color: #05141b;
552
- box-shadow: 0 0 12px var(--accent-glow);
553
- }
554
- .msg-bubble.user .msg-avatar {
555
- background: var(--bg-inner);
556
- color: var(--ink);
557
- border: 1px solid var(--line);
558
- }
559
-
560
- .msg-content {
561
- background: var(--bg-card-solid);
562
- border: 1px solid var(--line);
563
- border-radius: 14px;
564
- padding: 0.85rem 1.1rem;
565
- font-size: 0.92rem;
566
- line-height: 1.6;
567
- color: var(--ink);
568
- position: relative;
569
- box-shadow: 0 4px 15px rgba(0, 0, 0, 0.08);
570
- }
571
- .msg-bubble.user .msg-content {
572
- background: var(--accent-bg);
573
- border-color: var(--line-highlight);
574
- }
575
- .msg-content pre {
576
- background: var(--bg-inner);
577
- border: 1px solid var(--line);
578
- border-radius: 8px;
579
- padding: 0.75rem;
580
- font-family: var(--mono);
581
- font-size: 0.84em;
582
- overflow-x: auto;
583
- margin: 0.6rem 0;
584
- color: var(--accent-ink);
585
- }
586
- .msg-content blockquote {
587
- border-left: 3px solid var(--accent);
588
- padding-left: 0.75rem;
589
- margin: 0.5rem 0;
590
- color: var(--ink-muted);
591
- font-style: italic;
592
- }
593
-
594
- .chat-composer-box {
595
- padding: 0.9rem 1.1rem;
596
- background: var(--bg-inner);
597
- border-top: 1px solid var(--line);
598
- }
599
- .chat-composer-form {
600
- display: flex;
601
- gap: 0.6rem;
602
- align-items: flex-end;
603
- }
604
- .chat-textarea {
605
- flex: 1;
606
- min-height: 48px;
607
- max-height: 160px;
608
- background: var(--bg-card-solid);
609
- border: 1px solid var(--line);
610
- border-radius: var(--radius-sm);
611
- color: var(--ink);
612
- padding: 0.7rem 0.85rem;
613
- font-family: var(--font);
614
- font-size: 0.92rem;
615
- line-height: 1.45;
616
- resize: none;
617
- outline: none;
618
- transition: border-color 0.2s ease, box-shadow 0.2s ease;
619
- }
620
- .chat-textarea:focus {
621
- border-color: var(--accent);
622
- box-shadow: 0 0 0 2px var(--accent-glow);
623
- }
624
-
625
- .btn-send {
626
- display: inline-flex;
627
- align-items: center;
628
- gap: 0.4rem;
629
- background: linear-gradient(135deg, var(--accent), #0ea5e9);
630
- color: #05141b;
631
- font-weight: 700;
632
- font-size: 0.88rem;
633
- padding: 0.75rem 1.3rem;
634
- border-radius: var(--radius-sm);
635
- border: none;
636
- cursor: pointer;
637
- transition: all 0.15s ease;
638
- box-shadow: 0 0 16px var(--accent-glow);
639
- }
640
- .btn-send:hover {
641
- filter: brightness(1.1);
642
- transform: translateY(-1px);
643
- }
644
- .btn-send:disabled {
645
- opacity: 0.5;
646
- cursor: not-allowed;
647
- transform: none;
648
- }
649
-
650
- /* Chat Sidebar */
651
- .chat-side-cards {
652
- display: flex;
653
- flex-direction: column;
654
- gap: 1rem;
655
- }
656
-
657
- /* ==============================================================
658
- 2. 运行概览 Dashboard (Overview Tab)
659
- ============================================================== */
660
- .overview-grid {
661
- display: grid;
662
- grid-template-columns: repeat(12, 1fr);
663
- gap: 1rem;
664
- }
665
- .bento-12 { grid-column: span 12; }
666
- .bento-8 { grid-column: span 8; }
667
- .bento-6 { grid-column: span 6; }
668
- .bento-4 { grid-column: span 4; }
669
-
670
- @media (max-width: 900px) {
671
- .bento-8, .bento-6, .bento-4 { grid-column: span 12; }
672
- }
673
-
674
- .goal-box {
675
- font-size: 1.12rem;
676
- font-weight: 600;
677
- color: var(--ink);
678
- line-height: 1.5;
679
- margin-top: 0.2rem;
680
- }
681
-
682
- .progress-track {
683
- height: 8px;
684
- background: var(--line);
685
- border-radius: 999px;
686
- overflow: hidden;
687
- margin: 0.6rem 0 0.4rem;
688
- }
689
- .progress-fill {
690
- height: 100%;
691
- border-radius: inherit;
692
- background: linear-gradient(90deg, var(--accent), #38bdf8);
693
- box-shadow: 0 0 10px var(--accent-glow);
694
- transition: width 0.5s cubic-bezier(0.2, 0.8, 0.2, 1);
695
- }
696
-
697
- /* Key-Value Detail List */
698
- .detail-row {
699
- display: flex;
700
- justify-content: space-between;
701
- align-items: baseline;
702
- gap: 1rem;
703
- padding: 0.55rem 0;
704
- border-bottom: 1px solid var(--line);
705
- font-size: 0.9rem;
706
- }
707
- .detail-row:last-child { border-bottom: 0; }
708
- .detail-key { color: var(--ink-muted); font-size: 0.85rem; }
709
-
710
- /* Claim-Evidence Probe Styles */
711
- .probe-grid {
712
- display: grid;
713
- grid-template-columns: 1.15fr 0.85fr;
714
- gap: 1.25rem;
715
- margin-bottom: 1.5rem;
716
- align-items: start;
717
- }
718
- @media (max-width: 960px) {
719
- .probe-grid { grid-template-columns: 1fr; }
720
- }
721
- .manuscript-box {
722
- background: var(--bg-card);
723
- border: 1px solid var(--line);
724
- border-radius: var(--radius-md);
725
- padding: 1.4rem;
726
- font-size: 0.94rem;
727
- line-height: 1.85;
728
- color: var(--ink);
729
- }
730
- .claim-interactive-tag {
731
- background: var(--accent-bg);
732
- border-bottom: 2px solid var(--accent);
733
- color: var(--accent-ink);
734
- font-weight: 600;
735
- padding: 0.12rem 0.4rem;
736
- border-radius: 4px;
737
- cursor: pointer;
738
- transition: all 0.15s ease;
739
- display: inline;
740
- }
741
- .claim-interactive-tag:hover, .claim-interactive-tag.active {
742
- background: var(--accent);
743
- color: #05141b;
744
- box-shadow: 0 0 10px var(--accent-glow);
745
- }
746
- .claim-interactive-tag.uncertain {
747
- background: var(--warn-bg);
748
- border-bottom-color: var(--warn);
749
- color: var(--warn-ink);
750
- }
751
- .claim-interactive-tag.uncertain:hover, .claim-interactive-tag.uncertain.active {
752
- background: var(--warn);
753
- color: #05141b;
754
- }
755
-
756
- .probe-drawer {
757
- background: var(--bg-card-solid);
758
- border: 1px solid var(--line);
759
- border-radius: var(--radius-md);
760
- padding: 1.3rem 1.4rem;
761
- box-shadow: var(--shadow-card);
762
- display: flex;
763
- flex-direction: column;
764
- gap: 0.85rem;
765
- }
766
-
767
- /* Interactive Manuscript & Word Editor Styles */
768
- .editor-workspace {
769
- display: grid;
770
- grid-template-columns: 1.15fr 0.85fr;
771
- gap: 1.25rem;
772
- align-items: start;
773
- }
774
- @media (max-width: 1024px) {
775
- .editor-workspace { grid-template-columns: 1fr; }
776
- }
777
- .editor-toolbar {
778
- display: flex;
779
- flex-wrap: wrap;
780
- align-items: center;
781
- justify-content: space-between;
782
- gap: 0.6rem;
783
- padding: 0.65rem 1rem;
784
- background: var(--bg-inner);
785
- border: 1px solid var(--line);
786
- border-radius: var(--radius-sm);
787
- margin-bottom: 0.9rem;
788
- }
789
- .toolbar-group {
790
- display: flex;
791
- align-items: center;
792
- gap: 0.35rem;
793
- }
794
- .tool-btn {
795
- background: var(--bg-card);
796
- border: 1px solid var(--line);
797
- color: var(--ink);
798
- padding: 0.35rem 0.7rem;
799
- border-radius: 6px;
800
- font-size: 0.78rem;
801
- font-weight: 650;
802
- cursor: pointer;
803
- transition: all 0.15s ease;
804
- display: inline-flex;
805
- align-items: center;
806
- gap: 0.3rem;
807
- }
808
- .tool-btn:hover {
809
- border-color: var(--accent);
810
- color: var(--accent-ink);
811
- }
812
- .tool-btn.active {
813
- background: var(--accent-bg);
814
- border-color: var(--accent);
815
- color: var(--accent-ink);
816
- }
817
- .editor-paper {
818
- background: var(--bg-card-solid);
819
- border: 1px solid var(--line);
820
- border-radius: var(--radius-md);
821
- padding: 2.2rem 2.4rem;
822
- min-height: 580px;
823
- max-height: 72vh;
824
- outline: none;
825
- box-shadow: 0 4px 20px rgba(0,0,0,0.04);
826
- font-family: var(--font);
827
- font-size: 0.96rem;
828
- line-height: 1.85;
829
- color: var(--ink);
830
- overflow-y: auto;
831
- }
832
- .editor-paper:focus {
833
- border-color: var(--line-highlight);
834
- }
835
- .editor-paper h1, .editor-paper h2, .editor-paper h3 {
836
- margin-top: 1.4rem;
837
- margin-bottom: 0.6rem;
838
- color: var(--ink);
839
- line-height: 1.25;
840
- }
841
- .editor-paper p {
842
- margin-bottom: 1rem;
843
- }
844
- .editor-paper blockquote {
845
- border-left: 3px solid var(--accent);
846
- padding-left: 1rem;
847
- margin: 1rem 0;
848
- color: var(--ink-muted);
849
- font-style: italic;
850
- }
851
- .editor-paper .md-image,
852
- .manuscript-preview .md-image {
853
- max-width: 82%;
854
- height: auto;
855
- display: block;
856
- margin: 0.9rem auto;
857
- border-radius: 6px;
858
- border: 1px solid var(--line);
859
- cursor: zoom-in;
860
- }
861
- .editor-paper .md-table,
862
- .manuscript-preview .md-table {
863
- width: 100%;
864
- border-collapse: collapse;
865
- margin: 1rem 0;
866
- font-size: 0.88rem;
867
- border-top: 2px solid var(--ink);
868
- border-bottom: 2px solid var(--ink);
869
- }
870
- .editor-paper .md-table th,
871
- .manuscript-preview .md-table th {
872
- border-bottom: 1px solid var(--ink);
873
- text-align: left;
874
- padding: 0.4rem 0.6rem;
875
- font-weight: 700;
876
- }
877
- .editor-paper .md-table td,
878
- .manuscript-preview .md-table td {
879
- padding: 0.35rem 0.6rem;
880
- }
881
- .editor-paper .md-list,
882
- .manuscript-preview .md-list {
883
- padding-left: 1.4rem;
884
- margin: 0.6rem 0;
885
- }
886
- .editor-paper code {
887
- background: var(--bg-inner);
888
- border: 1px solid var(--line);
889
- border-radius: 4px;
890
- padding: 0.05rem 0.3rem;
891
- font-size: 0.86em;
892
- }
893
-
894
- .editor-markdown-raw {
895
- width: 100%;
896
- min-height: 580px;
897
- max-height: 72vh;
898
- font-family: var(--mono);
899
- font-size: 0.88rem;
900
- line-height: 1.7;
901
- padding: 1.5rem;
902
- background: var(--bg-inner);
903
- border: 1px solid var(--line);
904
- border-radius: var(--radius-md);
905
- color: var(--ink);
906
- resize: vertical;
907
- display: none;
908
- outline: none;
909
- }
910
-
911
- .auditor-card {
912
- background: var(--bg-card);
913
- border: 1px solid var(--line);
914
- border-radius: var(--radius-md);
915
- padding: 1.3rem;
916
- display: flex;
917
- flex-direction: column;
918
- gap: 0.85rem;
919
- }
920
- .claim-list-item {
921
- background: var(--bg-inner);
922
- border: 1px solid var(--line);
923
- border-radius: 8px;
924
- padding: 0.75rem;
925
- cursor: pointer;
926
- transition: all 0.15s ease;
927
- margin-bottom: 0.5rem;
928
- }
929
- .claim-list-item:hover, .claim-list-item.selected {
930
- border-color: var(--accent);
931
- box-shadow: 0 0 10px var(--accent-glow);
932
- }
933
-
934
- /* Tables */
935
- .table-responsive {
936
- overflow-x: auto;
937
- border-radius: var(--radius-sm);
938
- border: 1px solid var(--line);
939
- background: var(--bg-card);
940
- }
941
- table {
942
- width: 100%;
943
- border-collapse: collapse;
944
- font-size: 0.88rem;
945
- text-align: left;
946
- }
947
- th {
948
- background: var(--bg-inner);
949
- color: var(--ink-muted);
950
- font-size: 0.76rem;
951
- font-weight: 700;
952
- text-transform: uppercase;
953
- letter-spacing: 0.05em;
954
- padding: 0.7rem 0.9rem;
955
- border-bottom: 1px solid var(--line);
956
- }
957
- td {
958
- padding: 0.75rem 0.9rem;
959
- border-bottom: 1px solid var(--line);
960
- vertical-align: top;
961
- }
962
- tr:last-child td { border-bottom: none; }
963
- tr:hover td { background: var(--bg-inner); }
964
-
965
- /* Form Elements */
966
- input, select, textarea {
967
- width: 100%;
968
- font-family: var(--font);
969
- font-size: 0.9rem;
970
- color: var(--ink);
971
- background: var(--bg-inner);
972
- border: 1px solid var(--line);
973
- border-radius: var(--radius-sm);
974
- padding: 0.65rem 0.8rem;
975
- outline: none;
976
- transition: all 0.15s ease;
977
- }
978
- input:focus, select:focus, textarea:focus {
979
- border-color: var(--accent);
980
- box-shadow: 0 0 0 2px var(--accent-glow);
981
- }
982
- .form-group {
983
- display: flex;
984
- flex-direction: column;
985
- gap: 0.35rem;
986
- margin-bottom: 0.9rem;
987
- }
988
- .form-label {
989
- font-size: 0.8rem;
990
- font-weight: 600;
991
- color: var(--ink-muted);
992
- }
993
-
994
- /* Buttons */
995
- .btn {
996
- display: inline-flex;
997
- align-items: center;
998
- justify-content: center;
999
- gap: 0.4rem;
1000
- padding: 0.55rem 1rem;
1001
- border-radius: var(--radius-sm);
1002
- font-size: 0.85rem;
1003
- font-weight: 650;
1004
- cursor: pointer;
1005
- border: 1px solid transparent;
1006
- transition: all 0.15s ease;
1007
- }
1008
- .btn-primary {
1009
- background: linear-gradient(135deg, var(--accent), #0ea5e9);
1010
- color: #05141b;
1011
- box-shadow: 0 0 14px var(--accent-glow);
1012
- }
1013
- .btn-primary:hover { filter: brightness(1.1); transform: translateY(-1px); }
1014
- .btn-secondary {
1015
- background: var(--bg-inner);
1016
- color: var(--ink);
1017
- border-color: var(--line);
1018
- }
1019
- .btn-secondary:hover { background: var(--bg-card-solid); border-color: var(--line-highlight); }
1020
- .btn-danger {
1021
- background: var(--crit-bg);
1022
- color: var(--crit-ink);
1023
- border-color: rgba(244, 63, 94, 0.3);
1024
- }
1025
- .btn-danger:hover { background: rgba(244, 63, 94, 0.2); }
1026
-
1027
- /* Skills & Capability Shelf */
1028
- .capability-grid {
1029
- display: grid;
1030
- grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
1031
- gap: 1rem;
1032
- }
1033
- .capability-card {
1034
- background: var(--bg-card);
1035
- border: 1px solid var(--line);
1036
- border-radius: var(--radius-md);
1037
- padding: 1.15rem;
1038
- display: flex;
1039
- flex-direction: column;
1040
- justify-content: space-between;
1041
- transition: all 0.2s ease;
1042
- }
1043
- .capability-card:hover {
1044
- border-color: var(--line-highlight);
1045
- transform: translateY(-2px);
1046
- box-shadow: var(--shadow-glow);
1047
- }
1048
- .capability-header {
1049
- display: flex;
1050
- justify-content: space-between;
1051
- align-items: flex-start;
1052
- margin-bottom: 0.5rem;
1053
- }
1054
- .capability-title {
1055
- font-size: 0.98rem;
1056
- font-weight: 700;
1057
- color: var(--ink);
1058
- }
1059
- .capability-desc {
1060
- color: var(--ink-muted);
1061
- font-size: 0.84rem;
1062
- line-height: 1.5;
1063
- margin-bottom: 0.9rem;
1064
- flex: 1;
1065
- }
1066
-
1067
- /* Modal / Drawer for Preview */
1068
- .modal-overlay {
1069
- position: fixed;
1070
- inset: 0;
1071
- z-index: 200;
1072
- background: rgba(0, 0, 0, 0.7);
1073
- backdrop-filter: blur(8px);
1074
- display: none;
1075
- place-items: center;
1076
- padding: 1.5rem;
1077
- animation: fadeIn 0.2s ease;
1078
- }
1079
- .modal-overlay.active { display: grid; }
1080
- .modal-content {
1081
- background: var(--bg-card-solid);
1082
- border: 1px solid var(--line);
1083
- border-radius: var(--radius-lg);
1084
- width: min(840px, 95vw);
1085
- max-height: 85vh;
1086
- display: flex;
1087
- flex-direction: column;
1088
- box-shadow: 0 25px 60px rgba(0,0,0,0.4), 0 0 0 1px var(--line-highlight);
1089
- }
1090
- .modal-header {
1091
- padding: 1rem 1.4rem;
1092
- border-bottom: 1px solid var(--line);
1093
- display: flex;
1094
- justify-content: space-between;
1095
- align-items: center;
1096
- }
1097
- .modal-body {
1098
- padding: 1.25rem 1.4rem;
1099
- overflow-y: auto;
1100
- font-family: var(--mono);
1101
- font-size: 0.85rem;
1102
- color: var(--ink);
1103
- white-space: pre-wrap;
1104
- background: var(--bg-inner);
1105
- line-height: 1.6;
1106
- }
1107
-
1108
- .feedback-text {
1109
- font-size: 0.82rem;
1110
- margin-top: 0.4rem;
1111
- min-height: 1.2rem;
1112
- color: var(--ink-muted);
1113
- }
1114
- .feedback-text.ok { color: var(--ok-ink); }
1115
- </style>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <title>PsyClaw Panel</title>
7
+ <style>
8
+ :root{color-scheme:light;--bg:#f4f6f8;--surface:#fff;--line:#dfe3e8;--text:#17202a;--muted:#66717d;--accent:#087f73;--accent-soft:#e8f5f3;--ok:#15803d;--warn:#a16207;--bad:#b42318;--radius:6px;font-family:Inter,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
9
+ *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font-size:14px}button,input,select{font:inherit}button{cursor:pointer}.shell{min-height:100vh;display:grid;grid-template-rows:56px 1fr}.topbar{display:flex;align-items:center;justify-content:space-between;padding:0 24px;background:var(--surface);border-bottom:1px solid var(--line)}.brand{display:flex;align-items:center;gap:10px;font-weight:750;font-size:17px}.brand-mark{display:grid;place-items:center;width:30px;height:30px;background:var(--text);color:#fff;border-radius:5px}.local{font-size:12px;color:var(--muted)}.layout{display:grid;grid-template-columns:190px minmax(0,1fr);min-height:0}.nav{padding:18px 12px;background:#eef1f3;border-right:1px solid var(--line)}.nav button{width:100%;display:flex;align-items:center;gap:10px;padding:10px 12px;margin-bottom:4px;border:0;background:transparent;color:var(--muted);text-align:left;border-radius:5px}.nav button.active{background:var(--surface);color:var(--text);box-shadow:0 0 0 1px var(--line);font-weight:650}.main{padding:22px;min-width:0}.view{display:none}.view.active{display:block}.page-head{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;margin-bottom:16px}.page-head h1{font-size:20px;margin:0 0 4px}.page-head p{margin:0;color:var(--muted);font-size:13px}.button{border:1px solid var(--line);background:var(--surface);padding:7px 11px;border-radius:5px;color:var(--text)}.button.primary{background:var(--accent);border-color:var(--accent);color:#fff}.button.small{padding:4px 8px;font-size:12px}.button:disabled{opacity:.5;cursor:default}.panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius)}.workspace{display:grid;grid-template-columns:280px minmax(0,1fr);height:calc(100vh - 124px);overflow:hidden}.tree{border-right:1px solid var(--line);overflow:auto;padding:10px}.tree-section{margin:12px 8px 5px;color:var(--muted);font-size:11px;font-weight:700;text-transform:uppercase}.tree-item{display:flex;align-items:center;gap:8px;width:100%;border:0;background:transparent;padding:7px 8px;border-radius:4px;text-align:left;color:var(--text);overflow:hidden}.tree-item:hover,.tree-item.active{background:var(--accent-soft);color:#075e56}.tree-item span:last-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.viewer{overflow:auto}.viewer-head{position:sticky;top:0;z-index:2;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 18px;background:rgba(255,255,255,.96);border-bottom:1px solid var(--line)}.viewer-path{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.viewer-body{max-width:900px;margin:0 auto;padding:28px 34px 50px}.empty{display:grid;place-items:center;min-height:320px;color:var(--muted)}.markdown{line-height:1.75}.markdown h1,.markdown h2,.markdown h3{line-height:1.3;margin:1.5em 0 .65em}.markdown h1{font-size:26px;border-bottom:1px solid var(--line);padding-bottom:10px}.markdown h2{font-size:20px}.markdown h3{font-size:16px}.markdown p{margin:.8em 0}.markdown blockquote{margin:1em 0;padding:4px 14px;border-left:3px solid var(--accent);color:var(--muted);background:#f8fafb}.markdown code,.code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}.markdown code{background:#f0f2f4;padding:2px 4px;border-radius:3px}.markdown pre,.raw{white-space:pre-wrap;word-break:break-word;background:#111827;color:#e5e7eb;padding:16px;border-radius:5px;overflow:auto}.markdown table{width:100%;border-collapse:collapse;margin:1em 0}.markdown th,.markdown td{padding:7px 9px;border-bottom:1px solid var(--line);text-align:left}.markdown th{font-weight:700;background:#f7f8f9}.toolbar{display:flex;gap:8px;align-items:center}.segmented{display:inline-flex;border:1px solid var(--line);background:var(--surface);border-radius:5px;padding:2px}.segmented button{border:0;background:transparent;padding:5px 10px;border-radius:3px;color:var(--muted)}.segmented button.active{background:var(--text);color:#fff}.trace-summary{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin-bottom:18px}.metric{padding:12px;border:1px solid var(--line);border-radius:5px}.metric small{display:block;color:var(--muted);margin-bottom:4px}.metric strong{font-size:20px}.timeline{border-left:2px solid var(--line);margin-left:8px}.event{position:relative;padding:0 0 18px 22px}.event:before{content:"";position:absolute;left:-6px;top:4px;width:9px;height:9px;border-radius:50%;background:var(--accent);box-shadow:0 0 0 3px #fff}.event.error:before{background:var(--bad)}.event-head{display:flex;justify-content:space-between;gap:12px;font-weight:650}.event-meta{margin-top:3px;color:var(--muted);font-size:12px}.controls{display:flex;gap:8px;flex-wrap:wrap}.controls input,.controls select,.form input,.form select{border:1px solid var(--line);border-radius:5px;background:var(--surface);padding:7px 9px;color:var(--text)}.table-wrap{overflow:auto;background:var(--surface);border:1px solid var(--line);border-radius:var(--radius)}table.data{width:100%;border-collapse:collapse;min-width:760px}table.data th,table.data td{padding:9px 11px;border-bottom:1px solid var(--line);text-align:left;vertical-align:top}table.data th{position:sticky;top:0;background:#f7f8f9;color:var(--muted);font-size:12px;font-weight:700}table.data tr:last-child td{border-bottom:0}.name{font-weight:650}.subtle{color:var(--muted);font-size:12px;margin-top:2px}.tag{display:inline-block;padding:2px 6px;border-radius:999px;font-size:11px;background:#eef1f3;color:var(--muted)}.tag.ok{background:#eaf7ee;color:var(--ok)}.tag.warn{background:#fff7df;color:var(--warn)}.provider-grid{display:grid;grid-template-columns:minmax(300px,420px) minmax(0,1fr);gap:16px}.section{padding:16px}.section h2{font-size:15px;margin:0 0 14px}.form{display:grid;gap:12px}.field{display:grid;gap:5px}.field label{font-size:12px;color:var(--muted)}.feedback{font-size:12px;color:var(--muted);min-height:18px}.cost{margin-top:16px}.cost-metrics{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin-bottom:12px}.cost-note{font-size:12px;color:var(--muted);margin:0 0 12px}.hidden{display:none!important}
10
+ @media(max-width:850px){.layout{grid-template-columns:1fr}.nav{display:flex;border-right:0;border-bottom:1px solid var(--line);padding:8px;overflow:auto}.nav button{width:auto;white-space:nowrap;margin:0 4px 0 0}.main{padding:12px}.workspace{grid-template-columns:1fr;height:auto}.tree{max-height:280px;border-right:0;border-bottom:1px solid var(--line)}.viewer{min-height:520px}.provider-grid{grid-template-columns:1fr}.trace-summary,.cost-metrics{grid-template-columns:1fr}.topbar{padding:0 14px}}
11
+ </style>
1116
12
  </head>
1117
13
  <body>
1118
-
1119
- <!-- Header Top Bar -->
1120
- <header class="header">
1121
- <div class="header-inner">
1122
- <a href="#" class="brand">
1123
- <div class="brand-logo">ψ</div>
1124
- <div>
1125
- <div class="brand-title">PsyClaw</div>
1126
- </div>
1127
- <span class="brand-tag">工作台 v0.24.0</span>
1128
- </a>
1129
-
1130
- <!-- Main Navigation Tabs -->
1131
- <nav class="nav-tabs" aria-label="工作台导航">
1132
- <button class="nav-tab active" data-tab="tab-chat">💬 交互助手</button>
1133
- <button class="nav-tab" data-tab="tab-editor">📝 手稿编辑与核验</button>
1134
- <button class="nav-tab" data-tab="tab-overview">📊 运行看板</button>
1135
- <button class="nav-tab" data-tab="tab-artifacts">📁 产物证据</button>
1136
- <button class="nav-tab" data-tab="tab-hitl">⚖️ 人工确认</button>
1137
- <button class="nav-tab" data-tab="tab-skills">🧩 技能生态</button>
1138
- <button class="nav-tab" data-tab="tab-rules">🛡️ 规则约束</button>
1139
- <button class="nav-tab" data-tab="tab-tokens">📈 成本分析</button>
1140
- <button class="nav-tab" data-tab="tab-settings">⚙️ Provider</button>
14
+ <div class="shell">
15
+ <header class="topbar"><div class="brand"><span class="brand-mark">ψ</span>PsyClaw Panel</div><div class="local">Local only</div></header>
16
+ <div class="layout">
17
+ <nav class="nav" aria-label="Panel 导航">
18
+ <button class="active" data-view="project">项目文件</button>
19
+ <button data-view="ecosystem">Skill / MCP</button>
20
+ <button data-view="provider">Provider 与成本</button>
1141
21
  </nav>
1142
-
1143
- <!-- Theme Switcher & Trust Indicator -->
1144
- <div class="header-status">
1145
- <div class="theme-switcher" role="group" aria-label="主题配色切换">
1146
- <button class="theme-btn active" data-set-theme="dark" title="曜石暗夜 (Dark)">🌙</button>
1147
- <button class="theme-btn" data-set-theme="light" title="晨曦纯白 (Light)">☀️</button>
1148
- <button class="theme-btn" data-set-theme="paper" title="暖阳古籍 (Sepia / Paper)">📜</button>
1149
- <button class="theme-btn" data-set-theme="nord" title="极光深黛 (Nord / Ocean)">🌌</button>
1150
- </div>
1151
-
1152
- <div class="pulse-indicator">
1153
- <span class="pulse-dot"></span>
1154
- <span id="heartbeat-text">Local</span>
1155
- </div>
1156
- </div>
1157
- </div>
1158
- </header>
1159
-
1160
- <main>
1161
-
1162
- <!-- ==============================================================
1163
- TAB 1: 交互助手工作台 (Interactive Chat & Console)
1164
- ============================================================== -->
1165
- <section id="tab-chat" class="tab-pane active">
1166
- <div class="chat-workspace">
1167
- <!-- Main Chat Area -->
1168
- <div class="chat-main">
1169
- <div class="chat-header">
1170
- <div class="chat-header-info">
1171
- <span class="pill accent"><span class="dot"></span> 研究智能体</span>
1172
- <span id="chat-sub-badge" class="pill neutral">社科范式就绪</span>
1173
- </div>
1174
- <button class="btn btn-secondary" style="padding:0.25rem 0.6rem;font-size:0.75rem;" id="btn-clear-chat">清屏</button>
1175
- </div>
1176
-
1177
- <!-- Quick Prompt Chips -->
1178
- <div class="chips-container">
1179
- <button class="prompt-chip" data-prompt="检查当前文献综述的证据覆盖率和支持链条">📖 查证据覆盖率</button>
1180
- <button class="prompt-chip" data-prompt="交叉核验所有引用的 DOI、作者与出版年真实性">🔍 核验 DOI 真实性</button>
1181
- <button class="prompt-chip" data-prompt="审查统计分析方案:是否报告效应量、置信区间与缺失值处理?">📊 审查统计与效应量</button>
1182
- <button class="prompt-chip" data-prompt="准备符合 APA 7 规范的标准交付物与图表清单">📝 准备 APA 7 交付</button>
1183
- <button class="prompt-chip" data-prompt="列出当前所有需要人工确认的待办项或阻断点">⚖️ 检查 HITL 待办</button>
1184
- </div>
1185
-
1186
- <!-- Conversation Message Stream -->
1187
- <div id="chat-stream" class="chat-stream">
1188
- <div class="msg-bubble assistant">
1189
- <div class="msg-avatar">ψ</div>
1190
- <div class="msg-content">
1191
- <strong>您好,我是 PsyClaw 科研智能助手。</strong><br>
1192
- 我基于严密的证据链(Claim-Evidence Ledger)工作。您可以直接向我提问、委托文献综述核验、审查统计方案,或点击上方的快速快捷指令开始。
1193
- </div>
1194
- </div>
1195
- </div>
1196
-
1197
- <!-- Composer Form -->
1198
- <div class="chat-composer-box">
1199
- <div id="chat-status" class="detail-key" style="display:none;font-size:0.72rem;padding:0.1rem 0.2rem 0.4rem;">🧠 助手思考中…(只读智能体,最长等待 120 秒)</div>
1200
- <form id="chat-form" class="chat-composer-form">
1201
- <textarea id="chat-input" class="chat-textarea" placeholder="输入研究指令或提问(Enter 发送,Shift+Enter 换行)…" rows="1"></textarea>
1202
- <button type="submit" id="btn-send-msg" class="btn-send">
1203
- <span>发送</span>
1204
- <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z"/></svg>
1205
- </button>
1206
- </form>
1207
- </div>
1208
- </div>
1209
-
1210
- <!-- Chat Right Sidebar: Status Quick Snapshot -->
1211
- <div class="chat-side-cards">
1212
- <div class="card-bento">
1213
- <div class="kicker">当前运行状态</div>
1214
- <div style="display:flex;align-items:center;justify-content:space-between;margin:0.4rem 0 0.8rem;">
1215
- <span id="quick-phase-pill" class="pill neutral">读取中</span>
1216
- <span id="quick-run-id" class="detail-key">--</span>
1217
- </div>
1218
- <div class="detail-row">
1219
- <span class="detail-key">范式</span>
1220
- <span id="quick-paradigm" style="font-weight:600">--</span>
1221
- </div>
1222
- <div class="detail-row">
1223
- <span class="detail-key">证据覆盖</span>
1224
- <span id="quick-evidence" style="font-weight:600">0 / 0</span>
1225
- </div>
1226
- <div class="detail-row">
1227
- <span class="detail-key">待决事项</span>
1228
- <span id="quick-hitl-count" class="pill ok">0 项待办</span>
1229
- </div>
1230
- </div>
1231
-
1232
- <div class="card-bento">
1233
- <div class="kicker">快捷操作</div>
1234
- <div style="display:grid;gap:0.5rem;margin-top:0.4rem;">
1235
- <button class="btn btn-secondary" style="width:100%;justify-content:flex-start;" onclick="switchTab('tab-overview')">📊 查看全景看板</button>
1236
- <button class="btn btn-secondary" style="width:100%;justify-content:flex-start;" onclick="switchTab('tab-artifacts')">📁 浏览研究交付物</button>
1237
- <button class="btn btn-secondary" style="width:100%;justify-content:flex-start;" onclick="switchTab('tab-hitl')">⚖️ 记录人工决定</button>
1238
- </div>
1239
- </div>
1240
- </div>
1241
- </div>
1242
- </section>
1243
-
1244
- <!-- ==============================================================
1245
- TAB: 交互式手稿编辑器与证据核验 (Interactive Manuscript & Word/Markdown Editor)
1246
- ============================================================== -->
1247
- <section id="tab-editor" class="tab-pane">
1248
- <div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:0.5rem;flex-wrap:wrap;gap:0.8rem;">
1249
- <div>
1250
- <h2>交互式手稿编辑与证据核验</h2>
1251
- <p class="lead">直接在此修改手稿内容、划词关联新 Claim、实时联网核验 DOI,并一键导出 Word (.doc) 或 Markdown。</p>
1252
- </div>
1253
- <div style="display:flex;gap:0.5rem;align-items:center;flex-wrap:wrap;">
1254
- <span class="pill accent" id="manuscript-version-badge" title="当前手稿版本">v—</span>
1255
- <select id="version-select" style="font-size:0.75rem;padding:0.2rem 0.4rem;border-radius:6px;border:1px solid var(--line);background:var(--bg);color:var(--ink);" title="切换历史版本(存档于 paper/archive/)">
1256
- <option value="">版本…</option>
1257
- </select>
1258
- <span class="pill accent" id="editor-stats-badge"><span class="dot"></span> 3 个 Claim · 67% 覆盖率</span>
1259
- <button class="btn btn-secondary" style="font-size:0.8rem;" id="btn-export-word">📄 发布 Word (APA7)</button>
1260
- <button class="btn btn-secondary" style="font-size:0.8rem;" id="btn-export-md">📝 发布 Markdown</button>
1261
- <button class="btn btn-primary" style="font-size:0.8rem;" id="btn-save-manuscript">💾 保存手稿</button>
1262
- </div>
1263
- </div>
1264
-
1265
- <!-- Editor Toolbar -->
1266
- <div class="editor-toolbar">
1267
- <div class="toolbar-group">
1268
- <button class="tool-btn" onclick="formatDoc('bold')" title="加粗"><b>B</b></button>
1269
- <button class="tool-btn" onclick="formatDoc('italic')" title="斜体"><i>I</i></button>
1270
- <button class="tool-btn" onclick="formatDoc('formatBlock', '<h2>')" title="二级标题">H2</button>
1271
- <button class="tool-btn" onclick="formatDoc('formatBlock', '<h3>')" title="三级标题">H3</button>
1272
- <button class="tool-btn" onclick="formatDoc('formatBlock', '<blockquote>')" title="引用块">引用</button>
1273
- <button class="tool-btn" onclick="formatDoc('insertUnorderedList')" title="无序列表">• 列表</button>
1274
- </div>
1275
-
1276
- <div class="toolbar-group">
1277
- <button class="tool-btn" id="btn-mark-selected-claim" title="将选中文本标记为新论断并校验">🏷️ 选词关联 Claim</button>
1278
- <button class="tool-btn" id="btn-mark-selected-uncertain" title="将选中文本标记为证据缺口">⚠️ 标为 Uncertain</button>
1279
- <button class="tool-btn" id="btn-insert-image" title="从产物列表插入图片(SVG 可点击拖拽编辑)">🖼️ 插入图片</button>
1280
- </div>
1281
-
1282
- <div class="toolbar-group">
1283
- <button class="tool-btn active" id="btn-mode-wysiwyg">📄 富文本视图</button>
1284
- <button class="tool-btn" id="btn-mode-markdown">💻 Markdown 源码</button>
1285
- </div>
1286
- </div>
1287
-
1288
- <!-- Workspace Split -->
1289
- <div class="editor-workspace">
1290
- <!-- Left: Editor Canvas -->
1291
- <div>
1292
- <div id="editor-canvas" class="editor-paper" contenteditable="true" spellcheck="false">
1293
- <p class="detail-key">正在加载项目手稿…</p>
1294
- </div>
1295
-
1296
- <textarea id="editor-markdown" class="editor-markdown-raw"></textarea>
1297
- </div>
1298
-
1299
- <!-- Right: Live Evidence Auditor & Registry -->
1300
- <div class="auditor-card">
1301
- <div style="display:flex;justify-content:space-between;align-items:center;">
1302
- <div class="kicker" style="margin:0;">证据核验审查器 (Evidence Auditor)</div>
1303
- <span class="pill neutral" id="auditor-status-pill">待选择论断</span>
1304
- </div>
1305
-
1306
- <div style="display:grid;gap:0.6rem;">
1307
- <div class="form-group" style="margin:0;">
1308
- <label class="form-label" style="font-size:0.75rem;">论断标识 (Claim ID)</label>
1309
- <input id="audit-input-id" value="—" style="font-family:var(--mono);font-size:0.8rem;" readonly>
1310
- </div>
1311
-
1312
- <div class="form-group" style="margin:0;">
1313
- <label class="form-label" style="font-size:0.75rem;">文献来源标题 / 作者 (Citation)</label>
1314
- <input id="audit-input-title" placeholder="如:Smith et al. (2021). Title…">
1315
- </div>
1316
-
1317
- <div class="form-group" style="margin:0;">
1318
- <label class="form-label" style="font-size:0.75rem;">DOI 唯一标识符</label>
1319
- <div style="display:flex;gap:0.4rem;">
1320
- <input id="audit-input-doi" placeholder="10.xxxx/xxxx">
1321
- <button class="btn btn-secondary" style="font-size:0.75rem;padding:0.2rem 0.6rem;" id="btn-verify-doi">🔍 实时核验</button>
1322
- </div>
1323
- </div>
1324
-
1325
- <div class="form-group" style="margin:0;">
1326
- <label class="form-label" style="font-size:0.75rem;">原始论文引文摘录 (Evidence Quote)</label>
1327
- <textarea id="audit-input-quote" rows="3" style="font-size:0.8rem;line-height:1.5;" placeholder="粘贴论文中的原句摘录…"></textarea>
1328
- </div>
1329
-
1330
- <div class="form-group" style="margin:0;">
1331
- <label class="form-label" style="font-size:0.75rem;">核验状态判定</label>
1332
- <select id="audit-select-status" style="font-size:0.82rem;">
1333
- <option value="verified">✅ 双源核验通过 (Verified)</option>
1334
- <option value="uncertain">⚠️ 证据缺口标定 (Uncertain)</option>
1335
- <option value="rejected">❌ 证据不足/拒绝 (Rejected)</option>
1336
- </select>
1337
- </div>
1338
-
1339
- <div style="display:flex;gap:0.4rem;margin-top:0.4rem;">
1340
- <button class="btn btn-primary" style="flex:1;font-size:0.8rem;" id="btn-save-claim-edit">💾 更新证据账本</button>
1341
- <button class="btn btn-secondary" style="font-size:0.8rem;" id="btn-ask-ai-checker">🤖 委托 AI 核验</button>
1342
- </div>
1343
- <div id="audit-feedback" class="feedback-text" style="font-size:0.78rem;"></div>
1344
- </div>
1345
-
1346
- <div style="border-top:1px solid var(--line);padding-top:0.8rem;margin-top:0.4rem;">
1347
- <div class="kicker" style="margin-bottom:0.5rem;">手稿内全部 Claim 索引</div>
1348
- <div id="editor-claims-list"></div>
1349
- </div>
1350
- </div>
1351
- </div>
1352
- </section>
1353
-
1354
- <!-- ==============================================================
1355
- TAB 2: 运行看板 (Overview Dashboard Tab)
1356
- ============================================================== -->
1357
- <section id="tab-overview" class="tab-pane">
1358
- <div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:0.5rem;">
1359
- <div>
1360
- <h2>运行监控与研究进度</h2>
1361
- <p class="lead">实时展示当前智能体正在执行的子任务、门禁检查状态与证据覆盖度。</p>
1362
- </div>
1363
- <span id="overview-phase-pill" class="pill neutral">读取中</span>
1364
- </div>
1365
-
1366
- <div class="overview-grid">
1367
- <!-- Current Goal Hero Card -->
1368
- <div class="card-bento bento-8">
1369
- <div class="kicker">当前研究目标 (Research Goal)</div>
1370
- <div id="goal-display" class="goal-box">暂无活跃研究任务。回到终端或交互助手描述目标即可开始。</div>
1371
- </div>
1372
-
1373
- <!-- Evidence Coverage Card -->
1374
- <div class="card-bento bento-4">
1375
- <div class="kicker">证据链覆盖率 (Evidence Ledger)</div>
1376
- <div id="evidence-progress-container">
1377
- <div class="progress-track"><div id="evidence-bar" class="progress-fill" style="width:0%"></div></div>
1378
- <div style="display:flex;justify-content:space-between;font-size:0.82rem;color:var(--ink-muted);">
1379
- <span id="evidence-ratio">0 / 0 条 Claim 已验证</span>
1380
- <span id="evidence-pct">0%</span>
1381
- </div>
1382
- </div>
1383
- </div>
1384
-
1385
- <!-- Active Tasks Card -->
1386
- <div class="card-bento bento-6">
1387
- <div class="kicker">智能体调度任务 (Active Tasks)</div>
1388
- <div id="tasks-list" style="display:grid;gap:0.4rem;margin-top:0.4rem;">
1389
- <div class="detail-key">暂无任务</div>
1390
- </div>
1391
- </div>
1392
-
1393
- <!-- Gates Checklist Card -->
1394
- <div class="card-bento bento-6">
1395
- <div class="kicker">质量与安全门禁 (Gates)</div>
1396
- <ul id="gates-list" style="list-style:none;display:grid;gap:0.4rem;margin-top:0.4rem;">
1397
- <li class="detail-key">等待读取…</li>
1398
- </ul>
1399
- </div>
1400
-
1401
- <!-- Action & Control Snapshot -->
1402
- <div class="card-bento bento-12">
1403
- <div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:0.8rem;">
1404
- <div>
1405
- <div class="kicker">运行控制与快照</div>
1406
- <div id="snapshot-summary" class="detail-key">Run ID: --</div>
1407
- <div id="next-step" class="pill accent" style="margin-top:0.45rem;">读取中…</div>
1408
- </div>
1409
- <div id="run-actions-holder" style="display:flex;gap:0.5rem;align-items:center;"></div>
1410
- </div>
1411
- </div>
1412
- </div>
1413
- </section>
1414
-
1415
- <!-- ==============================================================
1416
- TAB 3: 产物证据与探针 (Artifacts & Claim-Evidence Probe Tab)
1417
- ============================================================== -->
1418
- <section id="tab-artifacts" class="tab-pane">
1419
- <div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:0.5rem;">
1420
- <div>
1421
- <h2>研究产物与证据探针 (Claim-Evidence Probe)</h2>
1422
- <p class="lead">点击手稿中的高亮论断(Claim),即刻透视背后的双向核验证据链、DOI 与 SHA-256 指纹。</p>
1423
- </div>
1424
- <button class="btn btn-secondary" style="font-size:0.78rem;" id="btn-export-ris">📋 导出全量 RIS / BibTeX</button>
1425
- </div>
1426
-
1427
- <!-- Live Claim-Evidence Probe Container -->
1428
- <div class="probe-grid">
1429
- <!-- Left: Real Manuscript Preview (loaded from the project) -->
1430
- <div class="manuscript-box">
1431
- <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.8rem;">
1432
- <span class="kicker" style="margin:0;">MANUSCRIPT · <span id="manuscript-preview-path" class="mono" style="font-size:0.7rem;">读取中…</span></span>
1433
- <span class="pill neutral" id="manuscript-preview-count" style="font-size:0.7rem;">— 个论断</span>
1434
- </div>
1435
- <div id="manuscript-preview" class="manuscript-preview" style="font-size:0.85rem;color:var(--ink);line-height:1.65;">
1436
- <p class="detail-key">正在读取手稿…</p>
1437
- </div>
1438
- </div>
1439
-
1440
- <!-- Right: Interactive Evidence Ledger Card (populated from the real ledger) -->
1441
- <div class="probe-drawer" id="probe-drawer">
1442
- <div style="display:flex;justify-content:space-between;align-items:center;">
1443
- <span class="pill neutral" id="probe-status-pill"><span class="dot"></span> 尚无论断</span>
1444
- <span class="detail-key mono" id="probe-claim-id">—</span>
1445
- </div>
1446
-
1447
- <div>
1448
- <h4 style="font-size:1.02rem;color:var(--ink);margin-bottom:0.3rem;" id="probe-source-title">(从真实证据账本加载)</h4>
1449
- <div class="mono" style="font-size:0.78rem;color:var(--ink-muted);" id="probe-doi-link">DOI: —</div>
1450
- </div>
1451
-
1452
- <div style="background:var(--bg-inner);border:1px solid var(--line);border-radius:8px;padding:0.75rem;font-size:0.82rem;color:var(--ink-muted);line-height:1.6;" id="probe-quote-box">
1453
- 点击左侧手稿中的高亮论断,查看其在 .psyclaw 账本中的真实证据链。
1454
- </div>
1455
-
1456
- <div style="display:grid;gap:0.3rem;font-size:0.75rem;color:var(--ink-faint);" class="mono">
1457
- <div style="display:flex;justify-content:space-between;">
1458
- <span>SHA-256:</span>
1459
- <span id="probe-hash-val" style="color:var(--ink);">—</span>
1460
- </div>
1461
- <div style="display:flex;justify-content:space-between;">
1462
- <span>Cross-Check:</span>
1463
- <span id="probe-source-match" style="color:var(--ink-muted);">—</span>
1464
- </div>
1465
- </div>
1466
-
1467
- <div style="display:flex;gap:0.4rem;margin-top:0.4rem;">
1468
- <button class="btn btn-secondary" style="flex:1;font-size:0.78rem;" id="btn-probe-ask-chat">💬 追问此论断</button>
1469
- <button class="btn btn-secondary" style="font-size:0.78rem;" id="btn-probe-copy-cite">📋 复制引文</button>
1470
- </div>
1471
- </div>
1472
- </div>
1473
-
1474
- <!-- Project Document Registry / Import (standard locations: paper/ docs/ data/ outputs/) -->
1475
- <div class="card-bento">
1476
- <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.8rem;">
1477
- <span class="kicker" style="margin:0;">📥 项目文档清单 (Documents · paper/ docs/ data/)</span>
1478
- <span class="pill neutral" style="font-size:0.7rem;" id="documents-count">读取中…</span>
1479
- </div>
1480
- <div class="table-responsive">
1481
- <table>
1482
- <thead>
1483
- <tr>
1484
- <th>文档路径</th>
1485
- <th>类型</th>
1486
- <th>大小</th>
1487
- <th>状态</th>
1488
- <th>操作</th>
1489
- </tr>
1490
- </thead>
1491
- <tbody id="documents-table-body">
1492
- <tr><td colspan="5" class="detail-key">正在扫描项目文档…</td></tr>
1493
- </tbody>
1494
- </table>
1495
- </div>
1496
- </div>
1497
-
1498
- <!-- Reference Archive & In-text Citation Check -->
1499
- <div class="card-bento">
1500
- <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.8rem;flex-wrap:wrap;gap:.5rem;">
1501
- <span class="kicker" style="margin:0;">🔖 参考文献存档与核验 (References · .psyclaw/references.jsonl)</span>
1502
- <div style="display:flex;gap:.4rem;align-items:center;flex-wrap:wrap;">
1503
- <input id="ref-doi-input" placeholder="10.xxxx/xxxx" style="font-size:0.75rem;padding:0.25rem 0.5rem;border-radius:6px;border:1px solid var(--line);background:var(--bg);color:var(--ink);width:180px;">
1504
- <button class="btn btn-secondary" style="font-size:0.72rem;padding:0.2rem 0.5rem;" id="btn-ref-verify">🔍 核验并存档</button>
1505
- <button class="btn btn-secondary" style="font-size:0.72rem;padding:0.2rem 0.5rem;" id="btn-ref-check-text">🧾 核验正文引用</button>
1506
- </div>
1507
- </div>
1508
- <div id="ref-check-result" class="feedback-text" style="margin-bottom:0.6rem;font-size:0.78rem;white-space:pre-wrap;"></div>
1509
- <div class="table-responsive">
1510
- <table>
1511
- <thead>
1512
- <tr>
1513
- <th>DOI</th>
1514
- <th>文献</th>
1515
- <th>年份</th>
1516
- <th>状态</th>
1517
- <th>操作</th>
1518
- </tr>
1519
- </thead>
1520
- <tbody id="references-table-body">
1521
- <tr><td colspan="5" class="detail-key">正在读取参考文献存档…</td></tr>
1522
- </tbody>
1523
- </table>
1524
- </div>
1525
- <div style="border-top:1px solid var(--line);padding-top:0.8rem;margin-top:0.8rem;display:grid;gap:0.5rem;">
1526
- <div class="kicker" style="margin:0;">📑 登记引用用途(证据 + 引用原因 → .psyclaw/citations.jsonl)</div>
1527
- <div style="display:flex;gap:.4rem;flex-wrap:wrap;align-items:center;">
1528
- <input id="cite-doi-input" placeholder="DOI" style="font-size:0.75rem;padding:0.25rem 0.5rem;border-radius:6px;border:1px solid var(--line);background:var(--bg);color:var(--ink);width:150px;">
1529
- <input id="cite-reason-input" placeholder="引用原因(一句,如:支持社会支持缓冲压力假说)" style="font-size:0.75rem;padding:0.25rem 0.5rem;border-radius:6px;border:1px solid var(--line);background:var(--bg);color:var(--ink);flex:1;min-width:220px;">
1530
- <input id="cite-context-input" placeholder="被引句上下文(可空)" style="font-size:0.75rem;padding:0.25rem 0.5rem;border-radius:6px;border:1px solid var(--line);background:var(--bg);color:var(--ink);flex:1;min-width:160px;">
1531
- <button class="btn btn-secondary" style="font-size:0.72rem;padding:0.2rem 0.5rem;" id="btn-record-cite">📝 登记引用</button>
1532
- </div>
1533
- <div id="citation-uses" style="display:grid;gap:0.35rem;font-size:0.78rem;"></div>
1534
- </div>
1535
- </div>
1536
-
1537
- <!-- Artifacts Table Below -->
1538
- <div class="card-bento">
1539
- <div class="kicker" style="margin-bottom:0.8rem;">已固化交付物清单 (Artifacts Registry)</div>
1540
- <div class="table-responsive">
1541
- <table>
1542
- <thead>
1543
- <tr>
1544
- <th>产物路径</th>
1545
- <th>格式</th>
1546
- <th>状态</th>
1547
- <th>SHA-256 指纹</th>
1548
- <th>操作</th>
1549
- </tr>
1550
- </thead>
1551
- <tbody id="artifacts-table-body">
1552
- <tr><td colspan="5" class="detail-key">正在读取产物列表…</td></tr>
1553
- </tbody>
1554
- </table>
1555
- </div>
1556
- </div>
1557
- </section>
1558
-
1559
- <!-- ==============================================================
1560
- TAB 4: 人工确认 (HITL Decisions Tab)
1561
- ============================================================== -->
1562
- <section id="tab-hitl" class="tab-pane">
1563
- <h2>人类参与与决策记录 (HITL)</h2>
1564
- <p class="lead">任何重要假设、设计调整或计划推进均在此明确记录,并存入不可篡改的结构化 Receipt。</p>
1565
-
1566
- <div class="overview-grid">
1567
- <div class="card-bento bento-6">
1568
- <div class="kicker">待确认与检查点列表</div>
1569
- <div id="hitl-files-list" style="display:grid;gap:0.5rem;margin-top:0.5rem;">
1570
- <p class="detail-key">正在读取…</p>
1571
- </div>
1572
- </div>
1573
-
1574
- <div class="card-bento bento-6">
1575
- <div class="kicker">记录人类裁决</div>
1576
- <form id="decision-form" style="margin-top:0.6rem;">
1577
- <div class="form-group">
1578
- <label class="form-label">裁决结果</label>
1579
- <select id="decision-val">
1580
- <option value="approved">✅ 批准继续 (Approved)</option>
1581
- <option value="needs-changes">⚠️ 要求修改补充 (Needs Changes)</option>
1582
- <option value="denied">🚫 暂不通过 (Denied)</option>
1583
- </select>
1584
- </div>
1585
- <div class="form-group">
1586
- <label class="form-label">裁决理由 / 补充要求(至少 3 字)</label>
1587
- <textarea id="decision-rationale" rows="3" placeholder="例如:批准进入下一阶段;需补充跨学科文献..." required></textarea>
1588
- </div>
1589
- <button type="submit" class="btn btn-primary" style="width:100%;">提交裁决记录</button>
1590
- <div id="decision-feedback" class="feedback-text"></div>
1591
- </form>
1592
- </div>
1593
- </div>
1594
- </section>
1595
-
1596
- <!-- ==============================================================
1597
- TAB 5: 技能与 MCP (Skills & Ecosystem Tab)
1598
- ============================================================== -->
1599
- <section id="tab-skills" class="tab-pane">
1600
- <h2>推荐学术能力与扩展 (Skills & MCP)</h2>
1601
- <p class="lead">一键安装领域技能与受控数据服务。安装后在终端输入 <code>/reload</code> 即可生效。</p>
1602
-
1603
- <div style="margin-bottom:1.5rem;">
1604
- <h3 style="font-size:1.1rem;margin-bottom:0.8rem;">🌟 推荐科研 Skill</h3>
1605
- <div id="recommended-skills-shelf" class="capability-grid"><p class="detail-key">读取中…</p></div>
1606
- </div>
1607
-
1608
- <div style="margin-bottom:1.5rem;">
1609
- <h3 style="font-size:1.1rem;margin-bottom:0.8rem;">🔌 推荐 MCP 扩展</h3>
1610
- <div id="recommended-mcps-shelf" class="capability-grid"><p class="detail-key">读取中…</p></div>
1611
- </div>
1612
-
1613
- <div>
1614
- <h3 style="font-size:1.1rem;margin-bottom:0.8rem;">⚙️ 已启用能力管理</h3>
1615
- <div id="enabled-shelf" class="overview-grid"><p class="detail-key">读取中…</p></div>
1616
- </div>
1617
- </section>
1618
-
1619
- <!-- ==============================================================
1620
- TAB 6: 规则约束 (Rules & Hooks Tab)
1621
- ============================================================== -->
1622
- <section id="tab-rules" class="tab-pane">
1623
- <h2>研究约束与禁止事项 (Rules & Hooks)</h2>
1624
- <p class="lead">系统提示词只增不改,内置规则保障数据安全,用户可按需添加分析前/中的检查 Hook。</p>
1625
-
1626
- <div class="overview-grid">
1627
- <div class="card-bento bento-6">
1628
- <div class="kicker">系统提示词补充 (System Prompt)</div>
1629
- <form id="system-prompt-form" style="margin-top:0.5rem;">
1630
- <details style="margin-bottom:0.7rem;">
1631
- <summary style="cursor:pointer;color:var(--ink-muted);font-size:0.82rem;">查看基础提示词(只读固定)</summary>
1632
- <pre id="system-prompt-base" style="background:var(--bg-inner);padding:0.6rem;border-radius:6px;font-family:var(--mono);font-size:0.78rem;color:var(--ink-faint);margin-top:0.4rem;max-height:140px;overflow:auto;"></pre>
1633
- </details>
1634
- <div class="form-group">
1635
- <label class="form-label">补充个人研究要求(追加在基础后)</label>
1636
- <textarea id="system-prompt-supplement" rows="5" placeholder="例如:重点关注心理测量学信效度分析..."></textarea>
1637
- </div>
1638
- <button type="submit" class="btn btn-primary">保存补充提示词</button>
1639
- <div id="system-prompt-feedback" class="feedback-text"></div>
1640
- </form>
1641
- </div>
1642
-
1643
- <div class="card-bento bento-6">
1644
- <div class="kicker">自定义禁止事项 Hooks</div>
1645
- <div style="margin-top:0.5rem;">
1646
- <div style="font-size:0.82rem;font-weight:600;color:var(--ink-muted);margin-bottom:0.3rem;">研究前禁止事项</div>
1647
- <ul id="hook-before-list" style="list-style:none;display:grid;gap:0.3rem;margin-bottom:0.6rem;"></ul>
1648
- <div style="display:flex;gap:0.4rem;">
1649
- <input id="hook-before-input" placeholder="例如:禁止修改 raw 目录">
1650
- <select id="hook-before-sev" style="width:90px;"><option value="warn">警告</option><option value="block">阻断</option></select>
1651
- <button class="btn btn-secondary" onclick="addHook('before')">添加</button>
1652
- </div>
1653
- </div>
1654
-
1655
- <div style="margin-top:1.1rem;padding-top:0.8rem;border-top:1px solid var(--line);">
1656
- <div style="font-size:0.82rem;font-weight:600;color:var(--ink-muted);margin-bottom:0.3rem;">研究中禁止事项</div>
1657
- <ul id="hook-during-list" style="list-style:none;display:grid;gap:0.3rem;margin-bottom:0.6rem;"></ul>
1658
- <div style="display:flex;gap:0.4rem;">
1659
- <input id="hook-during-input" placeholder="例如:禁止只报 p 值不报效应量">
1660
- <select id="hook-during-sev" style="width:90px;"><option value="warn">警告</option><option value="block">阻断</option></select>
1661
- <button class="btn btn-secondary" onclick="addHook('during')">添加</button>
1662
- </div>
1663
- </div>
1664
- </div>
1665
- </div>
1666
- </section>
1667
-
1668
- <!-- ==============================================================
1669
- TAB 7: 成本分析 (Token Analytics Tab)
1670
- ============================================================== -->
1671
- <section id="tab-tokens" class="tab-pane">
1672
- <h2>Token 用量与成本分析</h2>
1673
- <p class="lead">按模型与日期详细统计 Token 消耗及实际/预估费用,数据源于本地会话,绝不上报外部。</p>
1674
- <div id="token-usage-content">正在读取…</div>
1675
- </section>
1676
-
1677
- <!-- ==============================================================
1678
- TAB 8: Provider 设置 (Settings Tab)
1679
- ============================================================== -->
1680
- <section id="tab-settings" class="tab-pane">
1681
- <h2>模型与 Provider 设置</h2>
1682
- <p class="lead">管理模型提供商、连接地址与默认模型;API Key 始终由环境变量或 Pi 独立存储管理。</p>
1683
-
1684
- <div class="overview-grid">
1685
- <div class="card-bento bento-6">
1686
- <div class="kicker">快捷切换当前模型</div>
1687
- <div style="display:grid;gap:0.8rem;margin-top:0.6rem;">
1688
- <div class="form-group">
1689
- <label class="form-label">Provider</label>
1690
- <select id="quick-prov-select"></select>
1691
- </div>
1692
- <div class="form-group">
1693
- <label class="form-label">Model</label>
1694
- <select id="quick-model-select"></select>
1695
- </div>
1696
- <button id="btn-save-active-provider" class="btn btn-primary">应用并保存切换</button>
1697
- <div id="quick-prov-feedback" class="feedback-text"></div>
1698
- </div>
1699
- </div>
1700
-
1701
- <div class="card-bento bento-6">
1702
- <div class="kicker">项目文件与统计</div>
1703
- <div id="project-stats-container" style="margin-top:0.6rem;">
1704
- <div class="detail-key">读取中…</div>
1705
- </div>
1706
- </div>
1707
- </div>
1708
- </section>
1709
-
1710
- </main>
1711
-
1712
- <!-- Modal: Artifact Preview Drawer -->
1713
- <div id="preview-modal" class="modal-overlay">
1714
- <div class="modal-content">
1715
- <div class="modal-header">
1716
- <strong id="modal-title" style="font-size:0.95rem;">产物预览</strong>
1717
- <button class="btn btn-secondary" style="padding:0.2rem 0.5rem;" onclick="closeModal()">关闭 ✕</button>
1718
- </div>
1719
- <pre id="modal-body" class="modal-body"></pre>
22
+ <main class="main">
23
+ <section id="view-project" class="view active">
24
+ <div class="page-head"><div><h1>项目文件</h1><p>通过研究文档参与工作流;内容只读,Trace 已脱敏。</p></div><button id="refresh-project" class="button">刷新</button></div>
25
+ <div class="panel workspace">
26
+ <aside id="file-tree" class="tree"><div class="empty">读取中...</div></aside>
27
+ <section class="viewer">
28
+ <div class="viewer-head"><div id="viewer-path" class="viewer-path">选择左侧文件</div><span id="viewer-kind" class="tag">只读</span></div>
29
+ <div id="viewer-body" class="viewer-body empty">选择 Markdown 文件或 Trace 查看</div>
30
+ </section>
31
+ </div>
32
+ </section>
33
+
34
+ <section id="view-ecosystem" class="view">
35
+ <div class="page-head"><div><h1>Skill / MCP 生态</h1><p>安装、启用和停用研究能力。配置变更后使用 <code>/reload</code>。</p></div><button id="refresh-ecosystem" class="button">刷新</button></div>
36
+ <div class="controls" style="margin-bottom:12px"><input id="eco-search" placeholder="搜索名称或描述"><select id="eco-kind"><option value="all">全部类型</option><option value="skill">Skill</option><option value="mcp">MCP</option><option value="external">外部工具</option></select><select id="eco-status"><option value="all">全部状态</option><option value="enabled">已启用</option><option value="available">未启用</option></select></div>
37
+ <div class="table-wrap"><table class="data"><thead><tr><th>名称</th><th>类型</th><th>来源</th><th>状态</th><th>操作</th></tr></thead><tbody id="ecosystem-body"><tr><td colspan="5">读取中...</td></tr></tbody></table></div>
38
+ </section>
39
+
40
+ <section id="view-provider" class="view">
41
+ <div class="page-head"><div><h1>Provider 与成本</h1><p>管理默认模型;成本统计来自全部本地 PsyClaw 会话,不会上传。</p></div><button id="refresh-provider" class="button">刷新</button></div>
42
+ <div class="provider-grid">
43
+ <div class="panel section"><h2>当前 Provider</h2><div class="form"><div class="field"><label for="provider-select">Provider</label><select id="provider-select"></select></div><div class="field"><label for="model-select">Model</label><select id="model-select"></select></div><button id="save-provider" class="button primary">设为默认模型</button><div id="provider-feedback" class="feedback"></div></div></div>
44
+ <div class="panel section"><h2>配置 API Key</h2><div class="form"><div class="field"><label for="provider-key">API Key(留空不会覆盖已有凭据)</label><input id="provider-key" type="password" autocomplete="off" placeholder="输入当前 Provider 的 Key"></div><button id="configure-provider" class="button">保存当前 Provider 配置</button><div id="config-feedback" class="feedback"></div></div></div>
45
+ </div>
46
+ <div class="cost panel section"><div style="display:flex;justify-content:space-between;align-items:center;gap:12px"><h2>Token 与成本</h2><div class="segmented"><button class="active" data-currency="cny">CNY</button><button data-currency="usd">USD</button></div></div><div id="cost-content">读取中...</div></div>
47
+ </section>
48
+ </main>
1720
49
  </div>
1721
50
  </div>
1722
-
1723
51
  <script>
1724
- /* ==============================================================
1725
- psyclaw Web Platform Core Script
1726
- ============================================================== */
1727
-
1728
- let allRuns = [];
1729
- let latestSnapshot = null;
1730
- let allProjectRoot = "";
1731
- let catalogCache = null;
1732
- let enabledSet = { skill: [], mcp: [] };
1733
- let recommendCache = { skill: [], mcp: [] };
1734
- let userHooks = [];
1735
-
1736
- // Theme Management
1737
- function setTheme(name) {
1738
- document.documentElement.setAttribute("data-theme", name);
1739
- localStorage.setItem("psyclaw_panel_theme", name);
1740
- document.querySelectorAll("[data-set-theme]").forEach(btn => {
1741
- btn.classList.toggle("active", btn.dataset.setTheme === name);
1742
- });
1743
- }
1744
- document.querySelectorAll("[data-set-theme]").forEach(btn => {
1745
- btn.addEventListener("click", () => setTheme(btn.dataset.setTheme));
1746
- });
1747
- const savedTheme = localStorage.getItem("psyclaw_panel_theme") || "dark";
1748
- setTheme(savedTheme);
1749
-
1750
- async function fetchJson(url) {
1751
- const res = await fetch(url);
1752
- if (!res.ok) throw new Error(await res.text());
1753
- return res.json();
1754
- }
1755
-
1756
- function esc(value) {
1757
- return String(value ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
1758
- }
1759
-
1760
- function pill(text, tone, dot = false) {
1761
- return `<span class="pill ${tone}">${dot ? '<span class="dot"></span>' : ""}${esc(text)}</span>`;
1762
- }
1763
-
1764
- const PHASE_MAP = {
1765
- executing: ["执行中", "accent", true],
1766
- verifying: ["核验中", "accent", true],
1767
- planned: ["已规划", "neutral", false],
1768
- completed: ["已完成", "ok", false],
1769
- paused: ["已暂停", "warn", false],
1770
- blocked: ["需处理", "warn", false],
1771
- unknown: ["等待中", "neutral", false],
1772
- };
1773
-
1774
- function phaseBadge(phase) {
1775
- const [text, tone, dot] = PHASE_MAP[phase] || PHASE_MAP.unknown;
1776
- return pill(text, tone, dot);
1777
- }
1778
-
1779
- function taskBadge(status) {
1780
- const map = {
1781
- running: ["执行中", "accent", true],
1782
- pending: ["待执行", "neutral", false],
1783
- succeeded: ["已完成", "ok", false],
1784
- failed: ["未通过", "crit", false],
1785
- blocked: ["受阻", "warn", false]
1786
- };
1787
- const [text, tone, dot] = map[status] || map.pending;
1788
- return pill(text, tone, dot);
1789
- }
1790
-
1791
- /* Tabs Switching */
1792
- function switchTab(tabId) {
1793
- document.querySelectorAll(".nav-tab").forEach(btn => {
1794
- btn.classList.toggle("active", btn.dataset.tab === tabId);
1795
- });
1796
- document.querySelectorAll(".tab-pane").forEach(pane => {
1797
- pane.classList.toggle("active", pane.id === tabId);
1798
- });
1799
- }
1800
- document.querySelectorAll(".nav-tab").forEach(btn => {
1801
- btn.addEventListener("click", () => switchTab(btn.dataset.tab));
1802
- });
1803
-
1804
- /* Modal controls */
1805
- function openModal(title, content) {
1806
- document.getElementById("modal-title").textContent = title;
1807
- document.getElementById("modal-body").textContent = content;
1808
- document.getElementById("preview-modal").classList.add("active");
1809
- }
1810
- function closeModal() {
1811
- document.getElementById("preview-modal").classList.remove("active");
1812
- }
1813
- document.getElementById("preview-modal").addEventListener("click", (e) => {
1814
- if (e.target.id === "preview-modal") closeModal();
1815
- });
1816
-
1817
- /* ==============================================================
1818
- Chat & Assistant System
1819
- ============================================================== */
1820
- function addChatMessage(role, text) {
1821
- const stream = document.getElementById("chat-stream");
1822
- const bubble = document.createElement("div");
1823
- bubble.className = `msg-bubble ${role}`;
1824
- bubble.innerHTML = `
1825
- <div class="msg-avatar">${role === "user" ? "You" : "ψ"}</div>
1826
- <div class="msg-content">${formatMessageText(text)}</div>
1827
- `;
1828
- stream.appendChild(bubble);
1829
- stream.scrollTop = stream.scrollHeight;
1830
- }
1831
-
1832
- function formatMessageText(text) {
1833
- let formatted = esc(text)
1834
- .replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>")
1835
- .replace(/\*(.*?)\*/g, "<em>$1</em>")
1836
- .replace(/`([^`]+)`/g, "<code>$1</code>")
1837
- .replace(/\n/g, "<br>");
1838
- return formatted;
1839
- }
1840
-
1841
- document.getElementById("chat-form").addEventListener("submit", async (e) => {
1842
- e.preventDefault();
1843
- const input = document.getElementById("chat-input");
1844
- const msg = input.value.trim();
1845
- if (!msg) return;
1846
-
1847
- addChatMessage("user", msg);
1848
- input.value = "";
1849
- const sendBtn = document.getElementById("btn-send-msg");
1850
- sendBtn.disabled = true;
1851
- const statusEl = document.getElementById("chat-status");
1852
- if (statusEl) statusEl.style.display = "block";
1853
-
1854
- // Client-side timeout so a stalled spawned assistant can never hang silently.
1855
- const controller = new AbortController();
1856
- const abortTimer = setTimeout(() => controller.abort(), 125_000);
1857
- try {
1858
- const res = await fetch("/api/assistant", {
1859
- method: "POST",
1860
- headers: { "content-type": "application/json" },
1861
- body: JSON.stringify({ message: msg }),
1862
- signal: controller.signal,
1863
- });
1864
- const data = await res.json().catch(() => ({}));
1865
- if (!res.ok) throw new Error(data.error || "助手暂时不可用");
1866
- addChatMessage("assistant", data.text || "已处理完毕。");
1867
- } catch (err) {
1868
- if (err.name === "AbortError") {
1869
- addChatMessage("assistant", "⚠️ 助手响应超时(>120 秒)。面板聊天会单独启动一个只读智能体;超时通常是模型通道未配置或该任务需要完整工具链。请在 psyclaw 对话中直接提问(可获得完整读写/统计工具),或在项目里为论断先登记证据/下载全文后再追问。");
1870
- } else {
1871
- addChatMessage("assistant", `⚠️ 暂时无法处理:${err.message}`);
1872
- }
1873
- } finally {
1874
- clearTimeout(abortTimer);
1875
- if (statusEl) statusEl.style.display = "none";
1876
- sendBtn.disabled = false;
1877
- input.focus();
1878
- }
1879
- });
1880
-
1881
- /* Prompt Chips Handler */
1882
- document.querySelectorAll(".prompt-chip").forEach(chip => {
1883
- chip.addEventListener("click", () => {
1884
- const prompt = chip.dataset.prompt;
1885
- const input = document.getElementById("chat-input");
1886
- input.value = prompt;
1887
- document.getElementById("btn-send-msg").click();
1888
- });
1889
- });
1890
-
1891
- document.getElementById("btn-clear-chat").addEventListener("click", () => {
1892
- document.getElementById("chat-stream").innerHTML = `
1893
- <div class="msg-bubble assistant">
1894
- <div class="msg-avatar">ψ</div>
1895
- <div class="msg-content">已清空会话记录。随时向我提出新的研究课题。</div>
1896
- </div>
1897
- `;
1898
- });
1899
-
1900
- /* ==============================================================
1901
- Live Polling & Renderers
1902
- ============================================================== */
1903
- async function pollActivity() {
1904
- try {
1905
- const { runs, projectRoot } = await fetchJson("/api/runs");
1906
- allRuns = runs || [];
1907
- allProjectRoot = projectRoot || "";
1908
- if (allRuns.length > 0) {
1909
- const runId = allRuns[allRuns.length - 1].runId;
1910
- latestSnapshot = await fetchJson("/api/snapshot?runId=" + encodeURIComponent(runId));
1911
- renderOverview(latestSnapshot);
1912
- } else {
1913
- renderEmptyOverview();
1914
- }
1915
- } catch (e) {
1916
- // Keep last rendered state on transient network error
1917
- }
1918
- }
1919
-
1920
- function renderOverview(snap) {
1921
- if (!snap) return;
1922
-
1923
- // Phase pills
1924
- const badgeHtml = phaseBadge(snap.phase);
1925
- document.getElementById("quick-phase-pill").innerHTML = badgeHtml;
1926
- document.getElementById("overview-phase-pill").innerHTML = badgeHtml;
1927
- document.getElementById("quick-run-id").textContent = snap.runId;
1928
- document.getElementById("quick-paradigm").textContent = snap.paradigm || "未指定";
1929
-
1930
- // Goal
1931
- document.getElementById("goal-display").textContent = snap.goal || "(未指定目标)";
1932
- document.getElementById("snapshot-summary").textContent = `工作目录: ${allProjectRoot || "—"} · 事件数: ${snap.eventCount}`;
1933
- document.getElementById("snapshot-summary").title = snap.nextStep || "";
1934
- const nextStepEl = document.getElementById("next-step");
1935
- if (nextStepEl) nextStepEl.textContent = snap.nextStep || "";
1936
-
1937
- // Evidence
1938
- const cov = snap.evidenceCoverage;
1939
- if (cov && cov.totalClaims > 0) {
1940
- const pct = Math.round((cov.supportedClaims / cov.totalClaims) * 100);
1941
- document.getElementById("evidence-bar").style.width = `${pct}%`;
1942
- document.getElementById("evidence-ratio").textContent = `${cov.supportedClaims} / ${cov.totalClaims} 条 Claim 已获证据支持`;
1943
- document.getElementById("evidence-pct").textContent = `${pct}%`;
1944
- document.getElementById("quick-evidence").textContent = `${cov.supportedClaims} / ${cov.totalClaims}`;
1945
- } else {
1946
- document.getElementById("evidence-bar").style.width = "0%";
1947
- document.getElementById("evidence-ratio").textContent = "尚无证据账本数据";
1948
- document.getElementById("evidence-pct").textContent = "0%";
1949
- document.getElementById("quick-evidence").textContent = "0/0";
1950
- }
1951
-
1952
- // Tasks
1953
- const tasks = snap.tasks || [];
1954
- document.getElementById("tasks-list").innerHTML = tasks.length
1955
- ? tasks.map(t => `<div class="detail-row"><span>${esc(t.id)} <span class="detail-key">${esc(t.summary || "")}</span></span>${taskBadge(t.status)}</div>`).join("")
1956
- : `<div class="detail-key">暂无子任务执行</div>`;
1957
-
1958
- // Gates
1959
- const gates = snap.gates || [];
1960
- document.getElementById("gates-list").innerHTML = gates.length
1961
- ? gates.map(g => `<li class="detail-row"><span>${esc(g.gateId)} <span class="detail-key">${esc(g.reason)}</span></span>${g.ok ? pill("通过", "ok") : pill("需处理", "warn")}</li>`).join("")
1962
- : `<li class="detail-key">门禁尚未触发</li>`;
1963
-
1964
- // Actions
1965
- const actionBtn = snap.phase === "executing"
1966
- ? `<button class="btn btn-danger" onclick="triggerRunAction('pause', '${esc(snap.runId)}')">请求暂停 (Pause)</button>`
1967
- : snap.phase === "paused"
1968
- ? `<button class="btn btn-primary" onclick="triggerRunAction('resume', '${esc(snap.runId)}')">恢复执行 (Resume)</button>`
1969
- : "";
1970
- document.getElementById("run-actions-holder").innerHTML = actionBtn;
1971
-
1972
- // HITL Count
1973
- const waiting = (snap.waitingOnHuman || []).length;
1974
- document.getElementById("quick-hitl-count").textContent = `${waiting} 项待办`;
1975
- document.getElementById("quick-hitl-count").className = waiting > 0 ? "pill warn" : "pill ok";
1976
- }
1977
-
1978
- function renderEmptyOverview() {
1979
- document.getElementById("goal-display").textContent = "还没有研究任务。请在终端运行 psyclaw init 或在交互助手中输入研究课题。";
1980
- document.getElementById("quick-phase-pill").innerHTML = pill("就绪", "neutral");
1981
- document.getElementById("overview-phase-pill").innerHTML = pill("就绪", "neutral");
1982
- }
1983
-
1984
- async function triggerRunAction(action, runId) {
1985
- const reason = prompt(action === "pause" ? "请输入暂停理由:" : "请输入恢复理由:", "研究者指令");
1986
- if (!reason || reason.trim().length < 3) return;
1987
- try {
1988
- const res = await fetch(`/api/runs/${encodeURIComponent(runId)}/${action}`, {
1989
- method: "POST",
1990
- headers: { "content-type": "application/json" },
1991
- body: JSON.stringify({ approved: true, actor: "researcher", reason: reason.trim() })
1992
- });
1993
- const data = await res.json();
1994
- if (!res.ok) throw new Error(data.error || "操作失败");
1995
- alert(`操作成功:${data.status}`);
1996
- pollActivity();
1997
- } catch (e) { alert(e.message); }
1998
- }
1999
-
2000
- /* ==============================================================
2001
- Artifacts, HITL, Skills, Settings
2002
- ============================================================== */
2003
- function isImage(fmt) { return ["svg", "png", "jpg", "jpeg", "gif"].includes(String(fmt || "").toLowerCase()); }
2004
-
2005
- async function loadArtifacts() {
2006
- const data = await fetchJson("/api/artifacts");
2007
- const items = data.artifacts || [];
2008
- document.getElementById("artifacts-table-body").innerHTML = items.length
2009
- ? items.map(item => `
2010
- <tr>
2011
- <td><strong>${esc(item.path)}</strong></td>
2012
- <td><code>${esc(item.format || "file")}</code></td>
2013
- <td>${pill(item.status || "ready", "ok")}</td>
2014
- <td><code style="font-size:0.75rem;">${esc(item.sha256 ? item.sha256.slice(0, 12) + "…" : "—")}</code></td>
2015
- <td>${isImage(item.format)
2016
- ? `<img src="/api/artifact?path=${encodeURIComponent(item.path)}" loading="lazy" style="max-width:140px;max-height:84px;border-radius:6px;border:1px solid var(--line);cursor:zoom-in;" onclick="viewArtifact('${esc(item.path)}','${esc(item.format)}')" alt="${esc(item.path)}" title="点击放大">`
2017
- : `<button class="btn btn-secondary" style="padding:0.25rem 0.6rem;font-size:0.75rem;" onclick="viewArtifact('${esc(item.path)}','${esc(item.format)}')">预览</button>`}</td>
2018
- </tr>
2019
- `).join("")
2020
- : `<tr><td colspan="5" class="detail-key">还没有经过工作流登记的产物文件。</td></tr>`;
2021
- }
2022
-
2023
- function openImageModal(title, path) {
2024
- document.getElementById("modal-title").textContent = title;
2025
- const body = document.getElementById("modal-body");
2026
- body.style.whiteSpace = "normal";
2027
- body.innerHTML = `<img src="/api/artifact?path=${encodeURIComponent(path)}" style="max-width:100%;height:auto;display:block;border-radius:6px;">`;
2028
- document.getElementById("preview-modal").classList.add("active");
2029
- }
2030
-
2031
- async function viewArtifact(path, format) {
2032
- if (String(format).toLowerCase() === "svg") { openSvgEditor(`产物预览:${path}`, path); return; }
2033
- if (isImage(format)) { openImageModal(`产物预览:${path}`, path); return; }
2034
- try {
2035
- const res = await fetch(`/api/artifact?path=${encodeURIComponent(path)}`);
2036
- const text = await res.text();
2037
- openModal(`产物预览:${path}`, text);
2038
- } catch (e) { alert("读取产物失败:" + e.message); }
2039
- }
2040
-
2041
- /* ---- Lightweight inline SVG editor: drag text + double-click to edit ---- */
2042
- let editingSvgPath = "";
2043
- let svgDragState = null;
2044
-
2045
- async function openSvgEditor(title, path) {
2046
- editingSvgPath = path;
2047
- document.getElementById("modal-title").textContent = title + " · 拖拽文字移动 · 双击改字";
2048
- const body = document.getElementById("modal-body");
2049
- body.style.whiteSpace = "normal";
2050
- body.innerHTML = `
2051
- <div style="margin-bottom:.6rem;display:flex;gap:.5rem;align-items:center;flex-wrap:wrap;">
2052
- <button class="btn" onclick="saveSvgEdit()">保存</button>
2053
- <button class="btn btn-secondary" onclick="loadArtifacts();closeModal();">返回列表</button>
2054
- <span id="svg-edit-status" class="detail-key" style="font-size:0.78rem;"></span>
2055
- </div>
2056
- <div id="svg-edit-host" style="background:#fff;border-radius:8px;overflow:auto;max-height:72vh;padding:.5rem;"></div>`;
2057
- document.getElementById("preview-modal").classList.add("active");
2058
- try {
2059
- const res = await fetch("/api/artifact?path=" + encodeURIComponent(path));
2060
- let text = await res.text();
2061
- text = text.replace(/<script[\s\S]*?<\/script>/gi, "");
2062
- const host = document.getElementById("svg-edit-host");
2063
- host.innerHTML = text;
2064
- enableSvgTextEditing(host);
2065
- } catch (e) {
2066
- document.getElementById("svg-edit-status").textContent = "读取失败:" + e.message;
2067
- }
2068
- }
2069
-
2070
- function enableSvgTextEditing(host) {
2071
- const svg = host.querySelector("svg");
2072
- if (!svg) { document.getElementById("svg-edit-status").textContent = "未找到可编辑的 SVG"; return; }
2073
- svg.querySelectorAll("text").forEach((el) => {
2074
- el.style.cursor = "move";
2075
- el.addEventListener("pointerdown", (ev) => startSvgTextDrag(el, ev));
2076
- el.addEventListener("dblclick", (ev) => { ev.stopPropagation(); editSvgText(el); });
2077
- });
2078
- }
2079
-
2080
- function startSvgTextDrag(el, ev) {
2081
- ev.preventDefault();
2082
- ev.stopPropagation();
2083
- const base = el.getAttribute("transform") || "";
2084
- svgDragState = { el, base, startX: ev.clientX, startY: ev.clientY };
2085
- const onMove = (e) => {
2086
- if (!svgDragState || svgDragState.el !== el) return;
2087
- const dx = e.clientX - svgDragState.startX;
2088
- const dy = e.clientY - svgDragState.startY;
2089
- el.setAttribute("transform", (svgDragState.base ? svgDragState.base + " " : "") + `translate(${dx.toFixed(1)},${dy.toFixed(1)})`);
2090
- };
2091
- const onUp = () => {
2092
- svgDragState = null;
2093
- window.removeEventListener("pointermove", onMove);
2094
- window.removeEventListener("pointerup", onUp);
2095
- };
2096
- window.addEventListener("pointermove", onMove);
2097
- window.addEventListener("pointerup", onUp);
2098
- }
2099
-
2100
- function editSvgText(el) {
2101
- const current = el.textContent || "";
2102
- const next = window.prompt("编辑文字", current);
2103
- if (next !== null) el.textContent = next;
2104
- }
2105
-
2106
- async function saveSvgEdit() {
2107
- const host = document.getElementById("svg-edit-host");
2108
- const svg = host ? host.querySelector("svg") : null;
2109
- const status = document.getElementById("svg-edit-status");
2110
- if (!svg || !editingSvgPath) { status.textContent = "无可保存的 SVG"; return; }
2111
- status.textContent = "正在保存…";
2112
- try {
2113
- const content = svg.outerHTML;
2114
- const res = await fetch("/api/artifact/save", {
2115
- method: "POST",
2116
- headers: { "content-type": "application/json" },
2117
- body: JSON.stringify({ path: editingSvgPath, content })
2118
- });
2119
- const data = await res.json();
2120
- if (!res.ok) throw new Error(data.error || "保存失败");
2121
- status.textContent = `已保存(${data.bytes} 字节),可到产物列表重新预览`;
2122
- } catch (e) { status.textContent = e.message; }
2123
- }
2124
-
2125
- async function loadHitl() {
2126
- const data = await fetchJson("/api/hitl?contents=1");
2127
- const files = data.files || [];
2128
- document.getElementById("hitl-files-list").innerHTML = files.length
2129
- ? files.map(f => `
2130
- <div class="detail-row">
2131
- <span><strong>${esc(f.label)}</strong><br><code style="font-size:0.75rem;">${esc(f.path)}</code></span>
2132
- ${pill(f.status !== "missing" ? "已就绪" : "待创建", f.status !== "missing" ? "ok" : "neutral")}
2133
- </div>
2134
- `).join("")
2135
- : `<div class="detail-key">没有需要人工处理的事项。</div>`;
2136
- }
2137
-
2138
- document.getElementById("decision-form").addEventListener("submit", async (e) => {
2139
- e.preventDefault();
2140
- const decision = document.getElementById("decision-val").value;
2141
- const rationale = document.getElementById("decision-rationale").value.trim();
2142
- const fb = document.getElementById("decision-feedback");
2143
- fb.textContent = "正在提交…"; fb.className = "feedback-text";
2144
- try {
2145
- const res = await fetch("/api/hitl/decision", {
2146
- method: "POST",
2147
- headers: { "content-type": "application/json" },
2148
- body: JSON.stringify({ decision, rationale, actor: "researcher" })
2149
- });
2150
- const data = await res.json();
2151
- if (!res.ok) throw new Error(data.error || "记录失败");
2152
- fb.textContent = `✅ 裁决已记录 (${data.decision}),Receipt 已落盘`;
2153
- fb.className = "feedback-text ok";
2154
- document.getElementById("decision-rationale").value = "";
2155
- loadHitl();
2156
- } catch (err) { fb.textContent = err.message; }
2157
- });
2158
-
2159
- const DEFAULT_RECOMMENDED_SKILLS = [
2160
- { id: "markitdown", name: "MarkItDown", stage: "统一", description: "把 PDF、Word、PPT、Excel、HTML 等转换为结构化 Markdown。", sourceRef: "https://github.com/microsoft/markitdown" },
2161
- { id: "markitdown-pro", name: "MarkItDown Pro", stage: "统一", description: "字幕优先、转录回退,把 B 站课程和讲座转成带来源状态的 Markdown。", sourceRef: "https://github.com/LHT666-hub/markItdown-pro" },
2162
- { id: "session-handoff", name: "Session Handoff", stage: "接力", description: "把长任务的目标、进度、阻塞和下一步写成可核验的交接状态。", sourceRef: "https://github.com/LHT666-hub/session-handoff" },
2163
- { id: "zotero-translators", name: "Zotero Translators", stage: "题录", description: "从出版社和数据库页面提取规范题录、DOI、PDF 和补充材料。", sourceRef: "https://github.com/zotero/translators" },
2164
- { id: "scansci-pdf", name: "ScanSci PDF", stage: "获取", description: "按开放来源、官方接口和用户授权会话尝试获取论文全文。", sourceRef: "https://github.com/Rimagination/scansci-pdf" },
2165
- { id: "scholarbridge", name: "ScholarBridge", stage: "质控", description: "连接文献清单、PDF 完整性校验、哈希去重和 Zotero 交接。", sourceRef: "https://github.com/LHT666-hub/ScholarBridge" },
2166
- { id: "mentorforge", name: "MentorForge", stage: "蒸馏", description: "从学者主页、论文和全文信号卡中提炼可追问的科研方法论。", sourceRef: "https://github.com/qwqalice/MentorForge" },
2167
- { id: "supervisor-skills", name: "Supervisor-Skills", stage: "写作", description: "覆盖 idea、调研、写作、图表、评审和投稿前检查的科研规范。", sourceRef: "https://github.com/HKUSTDial/Supervisor-Skills" },
2168
- { id: "openkb", name: "OpenKB", stage: "知识库", description: "把异构资料编译成可链接、可检索、可持续更新的 Wiki。", sourceRef: "https://github.com/VectifyAI/OpenKB" },
2169
- { id: "smartplot", name: "SmartPlot", stage: "绘图", description: "科研绘图、样式统一、多面板组图和可见确认的 MCP 控制。", sourceRef: "https://smartplot.app" }
2170
- ];
2171
-
2172
- const DEFAULT_RECOMMENDED_MCPS = [
2173
- { id: "paper-search-mcp", name: "Paper Search MCP", stage: "检索", description: "统一 arXiv、PubMed、OpenAlex、Crossref 等开放来源的论文检索和去重。", sourceRef: "https://github.com/openags/paper-search-mcp" },
2174
- { id: "playwright-mcp", name: "Microsoft Playwright MCP", stage: "Web Bridge", description: "结构化浏览器操作;可使用独立浏览器,也可通过 Chrome 扩展连接已有标签页和登录状态。", sourceRef: "https://github.com/microsoft/playwright-mcp" },
2175
- { id: "computer-use-mcp", name: "Computer Use MCP", stage: "Computer Use", description: "Windows、macOS、Linux 原生窗口、截图、鼠标键盘和应用控制;必须单独审批权限。", sourceRef: "https://github.com/zavora-ai/computer-use-mcp" }
2176
- ];
2177
-
2178
- async function loadSkillsAndEcosystem() {
2179
- try {
2180
- const [skills, mcps, enabled] = await Promise.all([
2181
- fetchJson("/api/recommended-skills").catch(() => ({ items: [] })),
2182
- fetchJson("/api/recommended-mcps").catch(() => ({ items: [] })),
2183
- fetchJson("/api/enabled-capabilities").catch(() => ({ skills: [], mcp: [] }))
2184
- ]);
2185
-
2186
- const skillItems = (skills && skills.items && skills.items.length) ? skills.items : DEFAULT_RECOMMENDED_SKILLS;
2187
- const mcpItems = (mcps && mcps.items && mcps.items.length) ? mcps.items : DEFAULT_RECOMMENDED_MCPS;
2188
-
2189
- recommendCache = { skill: skillItems, mcp: mcpItems };
2190
- enabledSet = { skill: ((enabled && enabled.skills) || []).map(s => s.id), mcp: ((enabled && enabled.mcp) || []).map(s => s.id) };
2191
-
2192
- renderCapabilityShelf("recommended-skills-shelf", "skill", recommendCache.skill);
2193
- renderCapabilityShelf("recommended-mcps-shelf", "mcp", recommendCache.mcp);
2194
- renderEnabledList(enabled || { skills: [], mcp: [] });
2195
- } catch (err) {
2196
- recommendCache = { skill: DEFAULT_RECOMMENDED_SKILLS, mcp: DEFAULT_RECOMMENDED_MCPS };
2197
- renderCapabilityShelf("recommended-skills-shelf", "skill", recommendCache.skill);
2198
- renderCapabilityShelf("recommended-mcps-shelf", "mcp", recommendCache.mcp);
2199
- }
2200
- }
2201
-
2202
- function renderCapabilityShelf(targetId, kind, items) {
2203
- document.getElementById(targetId).innerHTML = items.length
2204
- ? items.map(item => {
2205
- const isEnabled = enabledSet[kind].includes(item.id);
2206
- return `
2207
- <div class="capability-card" tabindex="0" data-kind="${kind}" data-id="${esc(item.id)}" style="cursor:pointer;outline:none;" onkeydown="handleCardKey(event, '${kind}', '${esc(item.id)}')">
2208
- <div>
2209
- <div class="capability-header">
2210
- <span class="capability-title">${esc(item.name)}</span>
2211
- <span class="pill accent">${esc(item.stage || "nature")}</span>
2212
- </div>
2213
- <p class="capability-desc">${esc(item.description)}</p>
2214
- </div>
2215
- <div style="display:flex;justify-content:space-between;align-items:center;margin-top:0.8rem;border-top:1px solid var(--line);padding-top:0.6rem;">
2216
- <a href="${esc(item.sourceRef)}" target="_blank" rel="noreferrer" style="font-size:0.78rem;">来源仓库 ↗</a>
2217
- ${isEnabled
2218
- ? pill("已启用", "ok")
2219
- : `<button class="btn btn-primary" style="padding:0.25rem 0.65rem;font-size:0.78rem;" onclick="installCapability('${kind}', '${esc(item.id)}')">安装</button>`}
2220
- </div>
2221
- </div>
2222
- `;
2223
- }).join("")
2224
- : `<p class="detail-key">没有推荐项</p>`;
2225
- }
2226
-
2227
- function handleCardKey(e, kind, id) {
2228
- if (e.key === "Enter" || e.key === " ") {
2229
- e.preventDefault();
2230
- installCapability(kind, id);
2231
- return;
2232
- }
2233
- const cards = Array.from(document.querySelectorAll(".capability-card"));
2234
- const idx = cards.indexOf(e.currentTarget);
2235
- if (idx < 0) return;
2236
- if (e.key === "ArrowDown" || e.key === "ArrowRight") {
2237
- e.preventDefault();
2238
- const next = cards[(idx + 1) % cards.length];
2239
- next?.focus();
2240
- } else if (e.key === "ArrowUp" || e.key === "ArrowLeft") {
2241
- e.preventDefault();
2242
- const prev = cards[(idx - 1 + cards.length) % cards.length];
2243
- prev?.focus();
2244
- }
2245
- }
2246
-
2247
- async function installCapability(kind, id) {
2248
- const item = (recommendCache[kind] || []).find(c => c.id === id);
2249
- const prep = (item?.installPrep || [])[0];
2250
- const cmd = prep?.command || "";
2251
- if (!confirm(`将执行安装命令:\n\n${cmd}\n\n确认安装吗?`)) return;
2252
-
2253
- try {
2254
- const res = await fetch("/api/install/execute", {
2255
- method: "POST",
2256
- headers: { "content-type": "application/json" },
2257
- body: JSON.stringify({ kind, id, approved: true, actor: "researcher" })
2258
- });
2259
- const data = await res.json();
2260
- if (!res.ok) throw new Error(data.error || "安装失败");
2261
- alert(data.ok ? `安装成功!在终端输入 /reload 重新加载。` : `安装退出码:${data.exitCode}\n${data.output || ""}`);
2262
- loadSkillsAndEcosystem();
2263
- } catch (e) { alert(e.message); }
2264
- }
2265
-
2266
- function renderEnabledList(enabled) {
2267
- const core = enabled.coreSkills || [];
2268
- const skills = enabled.skills || [];
2269
- const mcps = enabled.mcp || [];
2270
- document.getElementById("enabled-shelf").innerHTML = `
2271
- <div class="card-bento bento-6">
2272
- <div class="kicker">核心 Skill(内置)</div>
2273
- <div style="display:grid;gap:0.4rem;margin-top:0.4rem;">
2274
- ${core.map(s => `<div class="detail-row"><span>${esc(s.name)}</span>${s.enabled ? `<button class="btn btn-secondary" style="padding:0.2rem 0.5rem;font-size:0.75rem;" onclick="toggleCapability('core-skill','${esc(s.id)}',false)">停用</button>` : `<button class="btn" style="padding:0.2rem 0.5rem;font-size:0.75rem;" onclick="toggleCapability('core-skill','${esc(s.id)}',true)">启用</button>`}</div>`).join("") || '<div class="detail-key">暂无</div>'}
2275
- </div>
2276
- </div>
2277
- <div class="card-bento bento-6">
2278
- <div class="kicker">已启用 Skills</div>
2279
- <div style="display:grid;gap:0.4rem;margin-top:0.4rem;">
2280
- ${skills.map(s => `<div class="detail-row"><span>${esc(s.name)}</span><button class="btn btn-secondary" style="padding:0.2rem 0.5rem;font-size:0.75rem;" onclick="toggleCapability('skills', '${esc(s.id)}', false)">停用</button></div>`).join("") || '<div class="detail-key">暂无</div>'}
2281
- </div>
2282
- </div>
2283
- <div class="card-bento bento-6">
2284
- <div class="kicker">已启用 MCP</div>
2285
- <div style="display:grid;gap:0.4rem;margin-top:0.4rem;">
2286
- ${mcps.map(m => `<div class="detail-row"><span>${esc(m.name)}</span><button class="btn btn-secondary" style="padding:0.2rem 0.5rem;font-size:0.75rem;" onclick="toggleCapability('mcp', '${esc(m.id)}', false)">停用</button></div>`).join("") || '<div class="detail-key">暂无</div>'}
2287
- </div>
2288
- </div>
2289
- `;
2290
- }
2291
-
2292
- async function toggleCapability(kind, id, enabled) {
2293
- try {
2294
- const res = await fetch("/api/recommendation-state", {
2295
- method: "POST",
2296
- headers: { "content-type": "application/json" },
2297
- body: JSON.stringify({ kind, id, enabled })
2298
- });
2299
- if (!res.ok) throw new Error("设置失败");
2300
- loadSkillsAndEcosystem();
2301
- } catch (e) { alert(e.message); }
2302
- }
2303
-
2304
- /* ==============================================================
2305
- Rules & Hooks
2306
- ============================================================== */
2307
- async function loadConfig() {
2308
- const config = await fetchJson("/api/config");
2309
- document.getElementById("system-prompt-base").textContent = config.systemPromptBase || "";
2310
- document.getElementById("system-prompt-supplement").value = config.systemPromptSupplement || "";
2311
- userHooks = config.hooks?.hooks || [];
2312
- renderHooks();
2313
- }
2314
-
2315
- function renderHooks() {
2316
- const before = userHooks.filter(h => h.event === "before-analysis");
2317
- const during = userHooks.filter(h => ["before-write", "after-analysis"].includes(h.event));
2318
-
2319
- document.getElementById("hook-before-list").innerHTML = before.map(h => `
2320
- <li class="detail-row">
2321
- <span>${pill(h.severity === "block" ? "阻断" : "警告", h.severity === "block" ? "crit" : "warn")} ${esc(h.message)}</span>
2322
- <button class="btn btn-secondary" style="padding:0.15rem 0.4rem;font-size:0.75rem;" onclick="removeHook('${esc(h.id)}')">✕</button>
2323
- </li>
2324
- `).join("") || `<li class="detail-key">无前置禁止事项</li>`;
2325
-
2326
- document.getElementById("hook-during-list").innerHTML = during.map(h => `
2327
- <li class="detail-row">
2328
- <span>${pill(h.severity === "block" ? "阻断" : "警告", h.severity === "block" ? "crit" : "warn")} ${esc(h.message)}</span>
2329
- <button class="btn btn-secondary" style="padding:0.15rem 0.4rem;font-size:0.75rem;" onclick="removeHook('${esc(h.id)}')">✕</button>
2330
- </li>
2331
- `).join("") || `<li class="detail-key">无过程禁止事项</li>`;
2332
- }
2333
-
2334
- function addHook(type) {
2335
- const input = document.getElementById(type === "before" ? "hook-before-input" : "hook-during-input");
2336
- const msg = input.value.trim();
2337
- if (!msg) return;
2338
- const sev = document.getElementById(type === "before" ? "hook-before-sev" : "hook-during-sev").value;
2339
- userHooks.push({ id: "u-" + Date.now(), event: type === "before" ? "before-analysis" : "before-write", severity: sev, message: msg });
2340
- input.value = "";
2341
- renderHooks();
2342
- saveHooks();
2343
- }
2344
-
2345
- async function removeHook(id) {
2346
- userHooks = userHooks.filter(h => h.id !== id);
2347
- renderHooks();
2348
- saveHooks();
2349
- }
2350
-
2351
- async function saveHooks() {
2352
- await fetch("/api/hooks", {
2353
- method: "POST",
2354
- headers: { "content-type": "application/json" },
2355
- body: JSON.stringify({ schemaVersion: "psyclaw/user-analysis-hooks/v1", hooks: userHooks })
2356
- });
2357
- }
2358
-
2359
- document.getElementById("system-prompt-form").addEventListener("submit", async (e) => {
2360
- e.preventDefault();
2361
- const text = document.getElementById("system-prompt-supplement").value;
2362
- const fb = document.getElementById("system-prompt-feedback");
2363
- fb.textContent = "正在保存…";
2364
- try {
2365
- const res = await fetch("/api/system-prompt", {
2366
- method: "POST",
2367
- headers: { "content-type": "application/json" },
2368
- body: JSON.stringify({ systemPrompt: text })
2369
- });
2370
- if (!res.ok) throw new Error("保存失败");
2371
- fb.textContent = "✅ 已保存补充。重启 Pi 会话后生效。"; fb.className = "feedback-text ok";
2372
- } catch (err) { fb.textContent = err.message; }
2373
- });
2374
-
2375
- /* ==============================================================
2376
- Token Analytics & Provider Settings
2377
- ============================================================== */
2378
- function fmt(n) { return Number(n || 0).toLocaleString(); }
2379
- function usd(n) { return `$${Number(n || 0).toFixed(4)}`; }
2380
- function cny(n) { return `¥${Number(n || 0).toFixed(4)}`; }
2381
- let tokenCurrency = "cny"; // "usd" | "cny"
2382
-
2383
- function tokenView(t, m, mode) {
2384
- const money = (usdVal, cnyVal) => (mode === "usd" ? usd(usdVal) : cny(cnyVal));
2385
- return `
2386
- <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.8rem;flex-wrap:wrap;gap:.5rem;">
2387
- <div class="kicker" style="margin:0;">Token 消耗与实际/预估费用(数据源于本地会话,绝不上报外部)</div>
2388
- <div style="display:flex;gap:.3rem;">
2389
- <button class="btn ${mode === "usd" ? "btn-primary" : "btn-secondary"}" style="font-size:0.72rem;padding:0.15rem 0.5rem;" onclick="tokenCurrency='usd';loadTokens()">USD $</button>
2390
- <button class="btn ${mode === "cny" ? "btn-primary" : "btn-secondary"}" style="font-size:0.72rem;padding:0.15rem 0.5rem;" onclick="tokenCurrency='cny';loadTokens()">人民币 ¥</button>
2391
- </div>
2392
- </div>
2393
- <div class="overview-grid" style="margin-bottom:1.5rem;">
2394
- <div class="card-bento bento-4"><div class="kicker">总 Token 消耗</div><div class="goal-box">${fmt(t.tokens)}</div></div>
2395
- <div class="card-bento bento-4"><div class="kicker">实际报告费用</div><div class="goal-box">${money(t.cost, t.costCny)}</div></div>
2396
- <div class="card-bento bento-4"><div class="kicker">预估总费用</div><div class="goal-box" style="color:var(--accent);">${money(t.estimatedCost, t.estimatedCostCny)}</div></div>
2397
- </div>
2398
- ${mode === "cny" ? `<p class="detail-key" style="font-size:0.72rem;margin-bottom:1rem;">人民币:国内厂商(DeepSeek/通义/智谱/Kimi)按官方人民币价估算;实际费用按汇率 ¥1≈$${ (1 / 7.2).toFixed(4) } 折算(Pi 记录为 USD)。</p>` : ""}
2399
-
2400
- <div class="card-bento" style="margin-bottom:1.5rem;">
2401
- <div class="kicker" style="margin-bottom:0.6rem;">按模型统计明细</div>
2402
- <div class="table-responsive">
2403
- <table>
2404
- <thead><tr><th>模型</th><th>输入</th><th>输出</th><th>缓存</th><th>合计 Token</th><th>实际费用</th><th>预估费用</th></tr></thead>
2405
- <tbody>
2406
- ${m.map(row => `<tr><td><strong>${esc(row.model)}</strong><br><span class="detail-key">${esc(row.provider)}</span></td><td>${fmt(row.input)}</td><td>${fmt(row.output)}</td><td>${fmt(row.cacheRead + row.cacheWrite)}</td><td><strong>${fmt(row.tokens)}</strong></td><td>${money(row.cost, row.costCny)}</td><td><strong style="color:var(--accent);">${money(row.estimatedCost, row.estimatedCostCny)}</strong></td></tr>`).join("") || '<tr><td colspan="7" class="detail-key">无用量记录</td></tr>'}
2407
- </tbody>
2408
- </table>
2409
- </div>
2410
- </div>
2411
- `;
2412
- }
2413
-
2414
- async function loadTokens() {
2415
- const rep = await fetchJson("/api/token-usage");
2416
- const t = rep.totals || { tokens: 0, cost: 0, estimatedCost: 0, costCny: 0, estimatedCostCny: 0 };
2417
- const models = rep.byModel || [];
2418
- document.getElementById("token-usage-content").innerHTML = tokenView(t, models, tokenCurrency);
2419
- }
2420
-
2421
- async function loadProvidersAndStats() {
2422
- const [catalog, active, stats, files] = await Promise.all([
2423
- fetchJson("/api/catalog"),
2424
- fetchJson("/api/active-provider"),
2425
- fetchJson("/api/stats"),
2426
- fetchJson("/api/files")
2427
- ]);
2428
-
2429
- catalogCache = catalog;
2430
- const provSelect = document.getElementById("quick-prov-select");
2431
- const modelSelect = document.getElementById("quick-model-select");
2432
-
2433
- const provs = [...new Set(catalog.models.map(m => m.provider))].sort();
2434
- provSelect.innerHTML = provs.map(p => `<option value="${esc(p)}">${esc(p)}</option>`).join("");
2435
-
2436
- const updateModels = () => {
2437
- const cur = provSelect.value;
2438
- const list = catalog.models.filter(m => m.provider === cur);
2439
- modelSelect.innerHTML = list.map(m => `<option value="${esc(m.id.split("/").slice(1).join("/"))}">${esc(m.name)}</option>`).join("");
2440
- };
2441
- provSelect.onchange = updateModels;
2442
- updateModels();
2443
-
2444
- if (active.provider) provSelect.value = active.provider;
2445
- updateModels();
2446
- if (active.model) modelSelect.value = active.model;
2447
-
2448
- // Stats
2449
- document.getElementById("project-stats-container").innerHTML = `
2450
- <div class="detail-row"><span class="detail-key">运行次数</span><strong>${stats.runs || 0}</strong></div>
2451
- <div class="detail-row"><span class="detail-key">收录证据</span><strong>${stats.evidence || 0}</strong></div>
2452
- <div class="detail-row"><span class="detail-key">Claim 计数</span><strong>${stats.claims || 0}</strong></div>
2453
- <div class="detail-row"><span class="detail-key">审计事件</span><strong>${stats.auditEvents || 0}</strong></div>
2454
- <div class="detail-row"><span class="detail-key">产物交付</span><strong>${stats.outputs || 0}</strong></div>
2455
- <div class="detail-row"><span class="detail-key">追踪文件</span><strong>${(files.files || []).length}</strong></div>
2456
- `;
2457
- }
2458
-
2459
- document.getElementById("btn-save-active-provider").addEventListener("click", async () => {
2460
- const provider = document.getElementById("quick-prov-select").value;
2461
- const model = document.getElementById("quick-model-select").value;
2462
- const fb = document.getElementById("quick-prov-feedback");
2463
- fb.textContent = "正在保存…";
2464
- try {
2465
- const res = await fetch("/api/active-provider", {
2466
- method: "POST",
2467
- headers: { "content-type": "application/json" },
2468
- body: JSON.stringify({ provider, model })
2469
- });
2470
- const data = await res.json();
2471
- if (!res.ok) throw new Error(data.error || "保存失败");
2472
- fb.textContent = `✅ 已切换为 ${provider} / ${model},重启会话后生效。`;
2473
- fb.className = "feedback-text ok";
2474
- } catch (e) { fb.textContent = e.message; }
2475
- });
2476
-
2477
- /* ==============================================================
2478
- Claim-Evidence Live Probe Interactive Logic
2479
- ============================================================== */
2480
- // Claims are loaded from the real append-only ledger (.psyclaw/claims.jsonl),
2481
- // never from hardcoded demo data. The panel only ever displays what the
2482
- // research engine actually recorded.
2483
- let PANEL_CLAIMS = [];
2484
-
2485
- const CLAIM_STATUS_LABEL = {
2486
- supported: { status: "✔ 已获证据支持", statusClass: "pill ok" },
2487
- uncertain: { status: "⚠️ 证据缺口标定 (Uncertain)", statusClass: "pill warn" },
2488
- unsupported: { status: "❌ 证据不足 (Unsupported)", statusClass: "pill crit" },
2489
- };
2490
-
2491
- function escapeHtml(s) {
2492
- return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
2493
- }
2494
-
2495
- function inlineMd(src) {
2496
- let s = escapeHtml(src);
2497
- // images first: ![alt](path)
2498
- s = s.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (m, alt, path) =>
2499
- `<img src="/api/artifact?path=${encodeURIComponent(path)}" data-md-src="${escAttr(path)}" alt="${escAttr(alt)}" class="md-image" title="点击预览/编辑" loading="lazy">`);
2500
- // claim markers
2501
- s = s.replace(/\[([^\]]*)\]\(claim:(\d+)\)/g, '<span class="claim-interactive-tag" data-claim-idx="$2">$1</span>');
2502
- // links
2503
- s = s.replace(/\[([^\]]*)\]\(([^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer">$1</a>');
2504
- // emphasis
2505
- s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
2506
- s = s.replace(/(^|[^*\w])\*([^*\n]+)\*/g, "$1<em>$2</em>");
2507
- s = s.replace(/~~([^~]+)~~/g, "<del>$1</del>");
2508
- s = s.replace(/`([^`]+)`/g, "<code>$1</code>");
2509
- return s;
2510
- }
2511
-
2512
- const isMdListLine = (line) => /^\s*([-*+]|\d+\.)\s+/.test(line);
2513
- const isMdTableLine = (line) => /^\s*\|.*\|/.test(line);
2514
- const isMdTableSep = (line) => /^\s*\|?[\s:|-]+\|[\s:|-]*$/.test(line) && line.includes("-");
2515
-
2516
- function markdownToEditorHtml(md) {
2517
- const lines = md.replace(/\r\n/g, "\n").split("\n");
2518
- const out = [];
2519
- let i = 0;
2520
- while (i < lines.length) {
2521
- const line = lines[i];
2522
- const trimmed = line.trim();
2523
- if (!trimmed) { i++; continue; }
2524
- const heading = trimmed.match(/^(#{1,6})\s+(.*)$/);
2525
- if (heading) { const level = heading[1].length; out.push(`<h${level}>${inlineMd(heading[2])}</h${level}>`); i++; continue; }
2526
- if (/^(-{3,}|\*{3,}|_{3,})$/.test(trimmed)) { out.push("<hr>"); i++; continue; }
2527
- if (trimmed.startsWith(">")) {
2528
- const quote = [];
2529
- while (i < lines.length && lines[i].trim().startsWith(">")) { quote.push(lines[i].trim().replace(/^>\s?/, "")); i++; }
2530
- out.push(`<blockquote>${inlineMd(quote.join(" "))}</blockquote>`);
2531
- continue;
2532
- }
2533
- if (isMdTableLine(trimmed) && i + 1 < lines.length && isMdTableSep(lines[i + 1].trim())) {
2534
- const cellsOf = (l) => l.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim());
2535
- const header = cellsOf(trimmed);
2536
- i += 2;
2537
- const rows = [];
2538
- while (i < lines.length && isMdTableLine(lines[i].trim())) { rows.push(cellsOf(lines[i].trim())); i++; }
2539
- out.push(`<table class="md-table"><thead><tr>${header.map((c) => `<th>${inlineMd(c)}</th>`).join("")}</tr></thead><tbody>${rows.map((r) => `<tr>${r.map((c) => `<td>${inlineMd(c)}</td>`).join("")}</tr>`).join("")}</tbody></table>`);
2540
- continue;
2541
- }
2542
- if (isMdListLine(trimmed)) {
2543
- const items = [];
2544
- while (i < lines.length && lines[i].trim() && (isMdListLine(lines[i].trim()) || /^\s{2,}\S/.test(lines[i]))) {
2545
- const t = lines[i].trim();
2546
- const li = t.replace(/^([-*+]|\d+\.)\s+/, "");
2547
- if (li) items.push(li);
2548
- i++;
2549
- }
2550
- out.push(`<ul class="md-list">${items.map((li) => `<li>${inlineMd(li)}</li>`).join("")}</ul>`);
2551
- continue;
2552
- }
2553
- const para = [];
2554
- while (i < lines.length && lines[i].trim() && !/^(#{1,6})\s/.test(lines[i].trim()) && !isMdListLine(lines[i].trim()) && !lines[i].trim().startsWith(">") && !isMdTableLine(lines[i].trim()) && !/^(-{3,}|\*{3,})$/.test(lines[i].trim())) {
2555
- para.push(lines[i].trim());
2556
- i++;
2557
- }
2558
- if (para.length) {
2559
- out.push(`<p>${inlineMd(para.join(" "))}</p>`);
2560
- } else {
2561
- // table-looking line without a separator after it → ordinary paragraph
2562
- out.push(`<p>${inlineMd(trimmed)}</p>`);
2563
- i++;
2564
- }
2565
- }
2566
- return out.join("\n");
2567
- }
2568
-
2569
- function editorHtmlToMarkdown() {
2570
- return editorCanvas.innerHTML
2571
- .replace(/<img[^>]*data-md-src="([^"]*)"[^>]*>/gi, (m, p) => `![图](${decodeURIComponent(p)})`)
2572
- .replace(/<img[^>]*src="\/api\/artifact\?path=([^"&]*)[^>]*>/gi, (m, p) => `![图](${decodeURIComponent(p)})`)
2573
- .replace(/<a href="([^"]*)"[^>]*>(.*?)<\/a>/gi, "[$2]($1)")
2574
- .replace(/<span class="claim-interactive-tag[^"]*" data-claim-idx="(\d+)">(.*?)<\/span>/gi, "[$2](claim:$1)")
2575
- .replace(/<h([1-6])>(.*?)<\/h\1>/gi, (m, lv, content) => `${"#".repeat(Number(lv))} ${content}`)
2576
- .replace(/<blockquote>(.*?)<\/blockquote>/gi, "> $1")
2577
- .replace(/<table[^>]*>([\s\S]*?)<\/table>/gi, (m, inner) => {
2578
- const rows = [...inner.matchAll(/<tr[^>]*>([\s\S]*?)<\/tr>/gi)].map((r) => [...r[1].matchAll(/<t[hd][^>]*>([\s\S]*?)<\/t[hd]>/gi)].map((c) => c[1].trim()));
2579
- const header = rows[0] || [];
2580
- return ["", header.join(" | "), header.map(() => "---").join(" | "), ...rows.slice(1).map((r) => r.join(" | ")), ""].join("\n");
2581
- })
2582
- .replace(/<ul[^>]*>([\s\S]*?)<\/ul>/gi, (m, inner) => [...inner.matchAll(/<li[^>]*>([\s\S]*?)<\/li>/gi)].map((li) => `- ${li[1].trim()}`).join("\n"))
2583
- .replace(/<ol[^>]*>([\s\S]*?)<\/ol>/gi, (m, inner) => [...inner.matchAll(/<li[^>]*>([\s\S]*?)<\/li>/gi)].map((li, idx) => `${idx + 1}. ${li[1].trim()}`).join("\n"))
2584
- .replace(/<strong>(.*?)<\/strong>/gi, "**$1**")
2585
- .replace(/<em>(.*?)<\/em>/gi, "*$1*")
2586
- .replace(/<del>(.*?)<\/del>/gi, "~~$1~~")
2587
- .replace(/<code>(.*?)<\/code>/gi, "`$1`")
2588
- .replace(/<hr[^>]*>/gi, "\n---\n")
2589
- .replace(/<p[^>]*>(.*?)<\/p>/gi, "$1\n\n")
2590
- .replace(/<br\s*[\/]?>/gi, "\n")
2591
- .replace(/<[^>]+>/g, "")
2592
- .replace(/\n{3,}/g, "\n\n")
2593
- .trim();
2594
- }
2595
-
2596
- /** Reload only the claim/evidence side from the real ledger (keeps unsaved editor text). */
2597
- async function loadLedgerData() {
2598
- const res = await fetch("/api/ledger");
2599
- if (!res.ok) throw new Error(`ledger ${res.status}`);
2600
- const ledger = await res.json();
2601
- PANEL_CLAIMS = (ledger.claims || []).map((claim) => {
2602
- const evidenceFor = (ledger.links || [])
2603
- .filter((l) => l.claimId === claim.id)
2604
- .map((l) => (ledger.evidence || []).find((e) => e.id === l.evidenceId))
2605
- .filter(Boolean);
2606
- const first = evidenceFor[0];
2607
- const doi = first?.locators?.find((l) => l.kind === "doi")?.value ?? "";
2608
- const label = CLAIM_STATUS_LABEL[claim.status] || CLAIM_STATUS_LABEL.uncertain;
2609
- return {
2610
- id: claim.id,
2611
- status: label.status,
2612
- statusClass: label.statusClass,
2613
- title: first?.source?.title || first?.source?.locator || claim.text.slice(0, 60),
2614
- doi: doi || "UNRESOLVED",
2615
- doiUrl: doi ? `https://doi.org/${doi}` : "#",
2616
- quote: first?.quote || claim.text,
2617
- hash: first?.sha256 || "(待登记)",
2618
- sourceMatch: evidenceFor.length >= 2 ? `${evidenceFor.length} 个来源已登记` : evidenceFor.length === 1 ? "1 个来源已登记" : "尚无证据来源",
2619
- claimId: claim.id,
2620
- kind: claim.kind,
2621
- text: claim.text,
2622
- };
2623
- });
2624
- renderEditorClaimsList();
2625
- updateEditorStatsBadge();
2626
- const countEl = document.getElementById("manuscript-preview-count");
2627
- if (countEl) countEl.textContent = `${PANEL_CLAIMS.length} 个论断`;
2628
- return ledger;
2629
- }
2630
-
2631
- /** Render the real manuscript file into the editor canvas. */
2632
- async function renderManuscriptFromServer() {
2633
- const res = await fetch("/api/manuscript");
2634
- if (!res.ok) throw new Error(`manuscript ${res.status}`);
2635
- const manuscript = await res.json();
2636
- if (manuscript.exists && manuscript.markdown.trim()) {
2637
- editorCanvas.innerHTML = markdownToEditorHtml(manuscript.markdown);
2638
- } else {
2639
- editorCanvas.innerHTML = `<p class="detail-key">尚无手稿文件。点击「💾 保存手稿」把内容写入 notes/manuscript.md;或先运行文献综述 / 元分析工作流生成真实报告。</p>`;
2640
- }
2641
- bindEditorClaimClicks();
2642
- const preview = document.getElementById("manuscript-preview");
2643
- const previewPath = document.getElementById("manuscript-preview-path");
2644
- if (preview) {
2645
- if (manuscript.exists && manuscript.markdown.trim()) {
2646
- if (previewPath) previewPath.textContent = manuscript.path;
2647
- const md = manuscript.markdown.slice(0, 2200);
2648
- preview.innerHTML = markdownToEditorHtml(md);
2649
- preview.querySelectorAll(".claim-interactive-tag").forEach((tag) => {
2650
- tag.addEventListener("click", () => applyClaimProbe(parseInt(tag.dataset.claimIdx, 10)));
2651
- });
2652
- bindEditorImages(preview);
2653
- } else {
2654
- if (previewPath) previewPath.textContent = "";
2655
- preview.innerHTML = `<p class="detail-key">尚无手稿文件 — 在「手稿编辑与核验」页编辑并保存,或先运行工作流生成真实报告。</p>`;
2656
- }
2657
- }
2658
- return manuscript;
2659
- }
2660
-
2661
- async function loadManuscriptData() {
2662
- try {
2663
- await Promise.all([renderManuscriptFromServer(), loadLedgerData()]);
2664
- if (PANEL_CLAIMS.length) applyClaimProbe(0);
2665
- } catch (error) {
2666
- console.error("loadManuscriptData failed:", error);
2667
- }
2668
- }
2669
-
2670
- let activeClaimIdx = 0;
2671
- function applyClaimProbe(idx) {
2672
- activeClaimIdx = idx;
2673
- document.querySelectorAll(".claim-interactive-tag").forEach(tag => {
2674
- tag.classList.toggle("active", parseInt(tag.dataset.claimIdx, 10) === idx);
2675
- });
2676
- const data = PANEL_CLAIMS[idx];
2677
- document.getElementById("probe-claim-id").textContent = data.id;
2678
-
2679
- const statusPill = document.getElementById("probe-status-pill");
2680
- statusPill.className = data.statusClass;
2681
- statusPill.innerHTML = `<span class="dot"></span> ${data.status}`;
2682
-
2683
- document.getElementById("probe-source-title").textContent = data.title;
2684
- const doiEl = document.getElementById("probe-doi-link");
2685
- if (data.doiUrl !== "#") {
2686
- doiEl.innerHTML = `DOI: <a href="${data.doiUrl}" target="_blank" rel="noreferrer">${data.doi} ↗</a>`;
2687
- } else {
2688
- doiEl.textContent = `DOI: ${data.doi}`;
2689
- }
2690
- document.getElementById("probe-quote-box").textContent = data.quote;
2691
- document.getElementById("probe-hash-val").textContent = data.hash;
2692
- document.getElementById("probe-source-match").textContent = data.sourceMatch;
2693
- }
2694
-
2695
- document.querySelectorAll(".claim-interactive-tag").forEach(tag => {
2696
- tag.addEventListener("click", () => {
2697
- applyClaimProbe(parseInt(tag.dataset.claimIdx, 10));
2698
- });
2699
- });
2700
-
2701
- // "Ask in Chat" integration
2702
- document.getElementById("btn-probe-ask-chat")?.addEventListener("click", () => {
2703
- const data = PANEL_CLAIMS[activeClaimIdx];
2704
- switchTab("tab-chat");
2705
- const input = document.getElementById("chat-input");
2706
- input.value = `针对手稿中的论断 [${data.id}](文献:${data.title}),请深入分析其证据覆盖度与潜在的边界条件:`;
2707
- input.focus();
2708
- });
2709
-
2710
- // Copy Citation
2711
- document.getElementById("btn-probe-copy-cite")?.addEventListener("click", () => {
2712
- const data = PANEL_CLAIMS[activeClaimIdx];
2713
- const citeText = `@article{psyclaw_claim_${activeClaimIdx + 1},\n title={${data.title}},\n doi={${data.doi}},\n note={Verified by psyclaw Claim-Evidence Ledger SHA-256: ${data.hash}}\n}`;
2714
- navigator.clipboard.writeText(citeText).then(() => {
2715
- const btn = document.getElementById("btn-probe-copy-cite");
2716
- btn.textContent = "✅ 已复制 BibTeX";
2717
- setTimeout(() => { btn.textContent = "📋 复制引文"; }, 1800);
2718
- });
2719
- });
2720
-
2721
- // Export RIS — built from the real ledger, persisted to outputs/evidence.ris
2722
- document.getElementById("btn-export-ris")?.addEventListener("click", async () => {
2723
- const withDoi = PANEL_CLAIMS.filter((c) => c.doi && c.doi !== "UNRESOLVED");
2724
- const risText = (withDoi.length
2725
- ? withDoi.map((c) => `TY - JOUR\nTI - ${c.title}\nDO - ${c.doi}\nN1 - psyclaw claim ${c.id}\nER - `).join("\n\n")
2726
- : "TY - GEN\nTI - (暂无已登记 DOI 的论断)\nER - "
2727
- );
2728
- try {
2729
- const res = await fetch("/api/manuscript", {
2730
- method: "POST",
2731
- headers: { "content-type": "application/json" },
2732
- body: JSON.stringify({ content: risText, path: "outputs/evidence.ris" }),
2733
- });
2734
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
2735
- } catch (error) {
2736
- alert("导出到项目 outputs/evidence.ris 失败:" + error.message);
2737
- }
2738
- const blob = new Blob([risText], { type: "text/plain;charset=utf-8" });
2739
- const a = document.createElement("a");
2740
- a.href = URL.createObjectURL(blob);
2741
- a.download = "psyclaw_evidence_ledger.ris";
2742
- a.click();
2743
- });
2744
-
2745
- /* ==============================================================
2746
- Interactive Manuscript Editor & Evidence Auditor Logic
2747
- ============================================================== */
2748
- function formatDoc(cmd, value = null) {
2749
- document.execCommand(cmd, false, value);
2750
- document.getElementById("editor-canvas")?.focus();
2751
- }
2752
-
2753
- // WYSIWYG <-> Markdown Source Sync
2754
- const btnModeWysiwyg = document.getElementById("btn-mode-wysiwyg");
2755
- const btnModeMarkdown = document.getElementById("btn-mode-markdown");
2756
- const editorCanvas = document.getElementById("editor-canvas");
2757
- const editorMarkdown = document.getElementById("editor-markdown");
2758
-
2759
- btnModeWysiwyg?.addEventListener("click", () => {
2760
- btnModeWysiwyg.classList.add("active");
2761
- btnModeMarkdown.classList.remove("active");
2762
- editorCanvas.style.display = "block";
2763
- editorMarkdown.style.display = "none";
2764
- });
2765
-
2766
- btnModeMarkdown?.addEventListener("click", () => {
2767
- btnModeMarkdown.classList.add("active");
2768
- btnModeWysiwyg.classList.remove("active");
2769
- editorMarkdown.value = editorHtmlToMarkdown();
2770
- editorCanvas.style.display = "none";
2771
- editorMarkdown.style.display = "block";
2772
- });
2773
-
2774
- editorMarkdown?.addEventListener("input", () => {
2775
- editorCanvas.innerHTML = markdownToEditorHtml(editorMarkdown.value);
2776
- bindEditorClaimClicks();
2777
- });
2778
-
2779
- function bindEditorImages(container) {
2780
- const host = container || editorCanvas;
2781
- host?.querySelectorAll("img.md-image").forEach((img) => {
2782
- img.onclick = (e) => {
2783
- e.stopPropagation();
2784
- const path = img.dataset.mdSrc;
2785
- if (!path) return;
2786
- if (/\.svg$/i.test(path)) openSvgEditor(`SVG 编辑:${path}`, path);
2787
- else openImageModal(`图片预览:${path}`, path);
2788
- };
2789
- });
2790
- }
2791
-
2792
- function bindEditorClaimClicks() {
2793
- editorCanvas?.querySelectorAll(".claim-interactive-tag").forEach(tag => {
2794
- tag.onclick = (e) => {
2795
- e.stopPropagation();
2796
- const idx = parseInt(tag.dataset.claimIdx, 10);
2797
- selectAuditorClaim(idx);
2798
- };
2799
- });
2800
- bindEditorImages(editorCanvas);
2801
- }
2802
-
2803
- let auditorSelectedIdx = 0;
2804
- function selectAuditorClaim(idx) {
2805
- if (!PANEL_CLAIMS[idx]) return;
2806
- auditorSelectedIdx = idx;
2807
- const d = PANEL_CLAIMS[idx];
2808
-
2809
- document.getElementById("audit-input-id").value = d.id;
2810
- document.getElementById("audit-input-title").value = d.title;
2811
- document.getElementById("audit-input-doi").value = d.doi;
2812
- document.getElementById("audit-input-quote").value = d.quote;
2813
- document.getElementById("audit-select-status").value = d.status.includes("缺口") || d.status.includes("不足") ? "uncertain" : "verified";
2814
-
2815
- const pill = document.getElementById("auditor-status-pill");
2816
- pill.className = d.statusClass;
2817
- pill.textContent = d.status;
2818
-
2819
- renderEditorClaimsList();
2820
- applyClaimProbe(idx);
2821
- }
2822
-
2823
- function renderEditorClaimsList() {
2824
- const listEl = document.getElementById("editor-claims-list");
2825
- if (!listEl) return;
2826
- listEl.innerHTML = PANEL_CLAIMS.map((c, idx) => `
2827
- <div class="claim-list-item ${idx === auditorSelectedIdx ? 'selected' : ''}" onclick="selectAuditorClaim(${idx})">
2828
- <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.2rem;">
2829
- <span class="mono" style="font-size:0.75rem;font-weight:700;color:var(--accent-ink);">${esc(c.id)}</span>
2830
- <span class="pill ${c.statusClass.split(' ')[1] || 'neutral'}" style="font-size:0.68rem;padding:0.1rem 0.4rem;">${esc(c.status)}</span>
2831
- </div>
2832
- <div style="font-size:0.82rem;color:var(--ink);">${esc(c.title)}</div>
2833
- <div class="mono" style="font-size:0.72rem;color:var(--ink-muted);">DOI: ${esc(c.doi)}</div>
2834
- </div>
2835
- `).join("");
2836
- }
2837
-
2838
- // Mark Selection as Claim — persisted to the real ledger (append-only).
2839
- async function createClaimFromSelection(kind) {
2840
- const sel = window.getSelection();
2841
- if (!sel.rangeCount || sel.isCollapsed) {
2842
- alert("请先用鼠标在左侧手稿中划选一段要作为论断(Claim)的文字。");
2843
- return;
2844
- }
2845
- const selectedText = sel.toString().trim();
2846
- if (!selectedText) return;
2847
- try {
2848
- const res = await fetch("/api/claim", {
2849
- method: "POST",
2850
- headers: { "content-type": "application/json" },
2851
- body: JSON.stringify({ text: selectedText, kind, status: "uncertain" }),
2852
- });
2853
- if (!res.ok) {
2854
- const err = await res.json().catch(() => ({}));
2855
- throw new Error(err.error || `HTTP ${res.status}`);
2856
- }
2857
- const data = await res.json();
2858
- const newIdx = PANEL_CLAIMS.length;
2859
- const label = CLAIM_STATUS_LABEL.uncertain;
2860
- PANEL_CLAIMS.push({
2861
- id: data.claim.id,
2862
- status: label.status,
2863
- statusClass: label.statusClass,
2864
- title: `新论断: "${selectedText.slice(0, 30)}${selectedText.length > 30 ? "…" : ""}"`,
2865
- doi: "UNRESOLVED",
2866
- doiUrl: "#",
2867
- quote: selectedText,
2868
- hash: "(待登记)",
2869
- sourceMatch: "已写入 .psyclaw/claims.jsonl",
2870
- claimId: data.claim.id,
2871
- kind,
2872
- text: selectedText,
2873
- });
2874
-
2875
- const span = document.createElement("span");
2876
- span.className = "claim-interactive-tag active";
2877
- span.dataset.claimIdx = newIdx;
2878
- span.textContent = selectedText;
2879
-
2880
- const range = sel.getRangeAt(0);
2881
- range.deleteContents();
2882
- range.insertNode(span);
2883
-
2884
- bindEditorClaimClicks();
2885
- selectAuditorClaim(newIdx);
2886
- updateEditorStatsBadge();
2887
- } catch (error) {
2888
- alert("创建论断失败:" + error.message);
2889
- }
2890
- }
2891
-
2892
- document.getElementById("btn-mark-selected-claim")?.addEventListener("click", () => createClaimFromSelection("result"));
2893
-
2894
- document.getElementById("btn-mark-selected-uncertain")?.addEventListener("click", () => createClaimFromSelection("interpretation"));
2895
-
2896
- // Real-Time Verify DOI — real Crossref + OpenAlex cross-check, never faked.
2897
- document.getElementById("btn-verify-doi")?.addEventListener("click", async () => {
2898
- const doi = document.getElementById("audit-input-doi").value.trim();
2899
- const fb = document.getElementById("audit-feedback");
2900
- if (!doi) {
2901
- fb.textContent = "请输入要核验的 DOI。";
2902
- return;
2903
- }
2904
- fb.innerHTML = `🔍 正在通过 Crossref 与 OpenAlex 实时检索解析: ${esc(doi)}…`;
2905
- fb.className = "feedback-text";
2906
- try {
2907
- const res = await fetch("/api/doi/verify", {
2908
- method: "POST",
2909
- headers: { "content-type": "application/json" },
2910
- body: JSON.stringify({ doi }),
2911
- });
2912
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
2913
- const data = await res.json();
2914
- const statusSelect = document.getElementById("audit-select-status");
2915
- if (data.status === "verified") {
2916
- const cr = data.crossref || {};
2917
- const authorLine = cr.authors?.length ? ` — ${cr.authors.slice(0, 3).join(", ")}${cr.authors.length > 3 ? " 等" : ""}` : "";
2918
- fb.innerHTML = `✅ <span style="color:var(--ok-ink);">双源核验通过</span>: ${esc(cr.title || doi)}${authorLine}${cr.year ? ` (${cr.year})` : ""}`;
2919
- fb.className = "feedback-text ok";
2920
- statusSelect.value = "verified";
2921
- const titleInput = document.getElementById("audit-input-title");
2922
- if (cr.title && !titleInput.value.trim()) titleInput.value = cr.title;
2923
- } else if (data.status === "unverified") {
2924
- fb.innerHTML = `⚠️ <span style="color:var(--warn-ink);">来源间不一致</span>: ${esc(data.mismatch || "Crossref 与 OpenAlex 结果冲突")}`;
2925
- fb.className = "feedback-text warn";
2926
- statusSelect.value = "uncertain";
2927
- } else {
2928
- fb.innerHTML = `❌ <span style="color:var(--crit-ink);">核验失败</span>: ${esc(data.error || "未知错误")}`;
2929
- fb.className = "feedback-text crit";
2930
- statusSelect.value = "uncertain";
2931
- }
2932
- } catch (error) {
2933
- fb.innerHTML = `❌ <span style="color:var(--crit-ink);">核验失败</span>: ${esc(error.message)}`;
2934
- fb.className = "feedback-text crit";
2935
- }
2936
- });
2937
-
2938
- // Save Claim Edits — attaches the auditor form as REAL evidence to the claim
2939
- // in the append-only ledger (.psyclaw/evidence.jsonl), with a supports link.
2940
- document.getElementById("btn-save-claim-edit")?.addEventListener("click", async () => {
2941
- const cur = PANEL_CLAIMS[auditorSelectedIdx];
2942
- if (!cur) return;
2943
- const title = document.getElementById("audit-input-title").value.trim();
2944
- const doi = document.getElementById("audit-input-doi").value.trim();
2945
- const quote = document.getElementById("audit-input-quote").value.trim();
2946
- if (!title && !doi && !quote) {
2947
- alert("请至少填写文献标题、DOI 或引文摘录之一(账本为追加式写入,登记的是该论断的证据来源)。");
2948
- return;
2949
- }
2950
- try {
2951
- const res = await fetch("/api/evidence", {
2952
- method: "POST",
2953
- headers: { "content-type": "application/json" },
2954
- body: JSON.stringify({ claimId: cur.claimId, title, doi, quote }),
2955
- });
2956
- if (!res.ok) {
2957
- const err = await res.json().catch(() => ({}));
2958
- throw new Error(err.error || `HTTP ${res.status}`);
2959
- }
2960
- const data = await res.json();
2961
- const fb = document.getElementById("audit-feedback");
2962
- fb.textContent = `✅ 已登记证据 ${data.evidence.id} 至 .psyclaw/evidence.jsonl,并与论断 ${cur.claimId} 建立「支持」链接。accessStatus=partial:需独立核验后才能提升为 verified。`;
2963
- fb.className = "feedback-text ok";
2964
- await loadLedgerData();
2965
- const refreshedIdx = Math.max(0, PANEL_CLAIMS.findIndex((c) => c.claimId === cur.claimId));
2966
- selectAuditorClaim(refreshedIdx);
2967
- } catch (error) {
2968
- alert("登记证据失败:" + error.message);
2969
- }
2970
- });
2971
-
2972
- // Ask AI Checker
2973
- document.getElementById("btn-ask-ai-checker")?.addEventListener("click", () => {
2974
- const cur = PANEL_CLAIMS[auditorSelectedIdx];
2975
- switchTab("tab-chat");
2976
- const input = document.getElementById("chat-input");
2977
- input.value = `请对论断 [${cur.id}](文献:${cur.title},DOI:${cur.doi})进行深度方法学交叉复核,检查样本量、效应量计算是否存在偏倚:`;
2978
- input.focus();
2979
- });
2980
-
2981
- function updateEditorStatsBadge() {
2982
- const total = PANEL_CLAIMS.length;
2983
- // 证据覆盖率 = 有证据支持的论断(supported + unsupported),uncertain 视为缺口
2984
- const covered = PANEL_CLAIMS.filter(c => c.status === CLAIM_STATUS_LABEL.supported.status || c.status === CLAIM_STATUS_LABEL.unsupported.status).length;
2985
- const pct = total ? Math.round((covered / total) * 100) : 0;
2986
- const badge = document.getElementById("editor-stats-badge");
2987
- if (badge) badge.innerHTML = `<span class="dot"></span> ${total} 个 Claim · ${pct}% 证据覆盖率`;
2988
- }
2989
-
2990
- // 发布手稿(约定位置 paper/):Word 导出 = paper/<name>_APA7.docx(服务端 pandoc),
2991
- // 同时落 paper/<name>.md 可编辑源。生成时即满足文档规范,panel 自动识别。
2992
- async function publishManuscriptFromEditor(exportDocx) {
2993
- const md = editorHtmlToMarkdown();
2994
- if (!md.trim()) {
2995
- alert("手稿内容为空,无法发布。");
2996
- return;
2997
- }
2998
- let full = md;
2999
- if (PANEL_CLAIMS.length) {
3000
- full += "\n\n## References\n\n";
3001
- PANEL_CLAIMS.forEach((c, i) => {
3002
- full += `[^${i}]: **${c.title}** DOI: [${c.doi}](${c.doiUrl}) (SHA-256: \`${c.hash}\`)\n`;
3003
- });
3004
- }
3005
- try {
3006
- const res = await fetch("/api/publish", {
3007
- method: "POST",
3008
- headers: { "content-type": "application/json" },
3009
- body: JSON.stringify({ content: full, name: "论文初稿", exportDocx }),
3010
- });
3011
- if (!res.ok) {
3012
- const err = await res.json().catch(() => ({}));
3013
- throw new Error(err.error || `HTTP ${res.status}`);
3014
- }
3015
- const data = await res.json();
3016
- const targets = [data.markdownPath, data.docxPath].filter(Boolean);
3017
- alert(`✅ 已发布到约定位置:\n\n${targets.map((p) => `• ${p}`).join("\n")}\n\n证据已登记:${data.evidenceIds.length} 条(含 SHA-256 指纹)。panel 文档清单会自动识别。`);
3018
- await loadDocuments();
3019
- await loadArtifacts();
3020
- await loadManuscriptVersions();
3021
- } catch (error) {
3022
- alert("发布失败:" + error.message);
3023
- }
3024
- }
3025
-
3026
- document.getElementById("btn-export-word")?.addEventListener("click", () => publishManuscriptFromEditor(true));
3027
-
3028
- document.getElementById("btn-export-md")?.addEventListener("click", () => publishManuscriptFromEditor(false));
3029
-
3030
- // Save Manuscript to the real project file (notes/manuscript.md) with a receipt.
3031
- document.getElementById("btn-save-manuscript")?.addEventListener("click", async () => {
3032
- const btn = document.getElementById("btn-save-manuscript");
3033
- const md = editorHtmlToMarkdown();
3034
- if (!md.trim()) {
3035
- alert("手稿内容为空,未保存。");
3036
- return;
3037
- }
3038
- try {
3039
- const res = await fetch("/api/manuscript", {
3040
- method: "POST",
3041
- headers: { "content-type": "application/json" },
3042
- body: JSON.stringify({ content: md, path: "notes/manuscript.md" }),
3043
- });
3044
- if (!res.ok) {
3045
- const err = await res.json().catch(() => ({}));
3046
- throw new Error(err.error || `HTTP ${res.status}`);
3047
- }
3048
- const data = await res.json();
3049
- btn.textContent = `✅ 已保存 ${data.path} (${data.bytes} B)`;
3050
- setTimeout(() => { btn.textContent = "💾 保存手稿"; }, 2500);
3051
- } catch (error) {
3052
- alert("保存手稿失败:" + error.message);
3053
- }
3054
- });
3055
-
3056
- // Initialize Editor Bindings
3057
- bindEditorClaimClicks();
3058
- renderEditorClaimsList();
3059
- updateEditorStatsBadge();
3060
-
3061
- /* ==============================================================
3062
- Project Documents (standardized locations: paper/ docs/ data/ outputs/)
3063
- ============================================================== */
3064
- const DOC_KIND_LABEL = {
3065
- manuscript: "手稿", reference: "参考文献", data: "数据", report: "报告", document: "文档",
3066
- };
3067
-
3068
- function formatBytes(n) {
3069
- if (n < 1024) return `${n} B`;
3070
- if (n < 1048576) return `${(n / 1024).toFixed(1)} KB`;
3071
- return `${(n / 1048576).toFixed(1)} MB`;
3072
- }
3073
-
3074
- function escAttr(s) {
3075
- return String(s).replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
3076
- }
3077
-
3078
- async function loadDocuments() {
3079
- const body = document.getElementById("documents-table-body");
3080
- const countEl = document.getElementById("documents-count");
3081
- if (!body) return;
3082
- try {
3083
- const res = await fetch("/api/documents");
3084
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
3085
- const data = await res.json();
3086
- const docs = data.documents || [];
3087
- if (countEl) countEl.textContent = `${docs.length} 个文档`;
3088
- if (!docs.length) {
3089
- body.innerHTML = `<tr><td colspan="5" class="detail-key">未发现文档。约定位置:paper/(手稿 .md 源 + .docx 导出)、docs/(资料)、data/(数据)、outputs/(报告)。</td></tr>`;
3090
- return;
3091
- }
3092
- body.innerHTML = docs.map((doc) => `
3093
- <tr>
3094
- <td style="font-family:var(--mono);font-size:0.75rem;">${esc(doc.path)}</td>
3095
- <td>${esc(DOC_KIND_LABEL[doc.kind] || doc.kind)}</td>
3096
- <td class="mono" style="font-size:0.72rem;">${formatBytes(doc.bytes)}</td>
3097
- <td>${doc.imported ? '<span class="pill ok">已导入</span>' : doc.isManuscript ? '<span class="pill accent">当前手稿</span>' : '<span class="pill neutral">未导入</span>'}</td>
3098
- <td>${doc.imported
3099
- ? '<span class="pill neutral" style="font-size:0.7rem;">已登记账本</span>'
3100
- : `<button class="btn btn-secondary" style="font-size:0.72rem;padding:0.2rem 0.5rem;" onclick="importDocument('${escAttr(doc.path)}')">📥 导入</button>`}</td>
3101
- </tr>`).join("");
3102
- } catch (error) {
3103
- body.innerHTML = `<tr><td colspan="5" class="detail-key">扫描失败:${esc(error.message)}</td></tr>`;
3104
- }
3105
- }
3106
-
3107
- async function importDocument(path) {
3108
- try {
3109
- const res = await fetch("/api/documents/import", {
3110
- method: "POST",
3111
- headers: { "content-type": "application/json" },
3112
- body: JSON.stringify({ path }),
3113
- });
3114
- if (!res.ok) {
3115
- const err = await res.json().catch(() => ({}));
3116
- throw new Error(err.error || `HTTP ${res.status}`);
3117
- }
3118
- const data = await res.json();
3119
- if (data.markdown) {
3120
- editorCanvas.innerHTML = markdownToEditorHtml(data.markdown);
3121
- bindEditorClaimClicks();
3122
- switchTab("tab-editor");
3123
- alert(`已导入 ${data.path}(证据 ${data.evidenceId} 已登记,accessStatus=partial)。内容已载入手稿编辑器,编辑后点「💾 保存手稿」持久化到 notes/manuscript.md。`);
3124
- } else {
3125
- alert(`已导入 ${data.path}(证据 ${data.evidenceId},${data.format} 文档已登记,不转换文本)。`);
3126
- }
3127
- await loadDocuments();
3128
- } catch (error) {
3129
- alert("导入失败:" + error.message);
3130
- }
3131
- }
3132
-
3133
- /* ==============================================================
3134
- Manuscript versions (paper/archive/ + .psyclaw/versions.jsonl)
3135
- ============================================================== */
3136
- let manuscriptVersions = [];
3137
-
3138
- async function loadManuscriptVersions() {
3139
- const badge = document.getElementById("manuscript-version-badge");
3140
- const select = document.getElementById("version-select");
3141
- try {
3142
- const res = await fetch("/api/versions");
3143
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
3144
- const data = await res.json();
3145
- manuscriptVersions = data.versions || [];
3146
- if (badge) badge.textContent = data.current ? `v${data.current.version}` : "v—";
3147
- if (select) {
3148
- const previous = select.value;
3149
- select.innerHTML = (manuscriptVersions.length
3150
- ? manuscriptVersions.map((v) => `<option value="${v.version}">v${v.version} · ${new Date(v.publishedAt).toLocaleString("zh-CN", { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" })}</option>`).join("")
3151
- : `<option value="">暂无版本</option>`) + `<option value="">查看版本…</option>`;
3152
- if (previous) select.value = previous;
3153
- }
3154
- } catch (error) {
3155
- if (badge) badge.textContent = "v—";
3156
- }
3157
- }
3158
-
3159
- async function loadVersionMarkdown(version) {
3160
- const record = manuscriptVersions.find((v) => v.version === Number(version));
3161
- if (!record) return;
3162
- try {
3163
- const res = await fetch(`/api/artifact?path=${encodeURIComponent(record.markdownLoadPath)}`);
3164
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
3165
- const text = await res.text();
3166
- editorCanvas.innerHTML = markdownToEditorHtml(text);
3167
- bindEditorClaimClicks();
3168
- const badge = document.getElementById("manuscript-version-badge");
3169
- if (badge) badge.innerHTML = `v${record.version} <span style="font-weight:400;">历史</span>`;
3170
- } catch (error) {
3171
- alert("读取版本失败:" + error.message);
3172
- }
3173
- }
3174
-
3175
- document.getElementById("version-select")?.addEventListener("change", (e) => {
3176
- const value = e.target.value;
3177
- if (value === "") { loadManuscriptData(); return; }
3178
- loadVersionMarkdown(value);
3179
- });
3180
-
3181
- /* ==============================================================
3182
- 插入图片(从产物列表选取,SVG 可点击进入拖拽编辑)
3183
- ============================================================== */
3184
- document.getElementById("btn-insert-image")?.addEventListener("click", async () => {
3185
- try {
3186
- const res = await fetch("/api/artifacts");
3187
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
3188
- const data = await res.json();
3189
- const figures = (data.artifacts || []).filter((a) => /\.(svg|png|jpg|jpeg|gif)$/i.test(a.path));
3190
- if (!figures.length) {
3191
- alert("尚无图片产物——先运行分析/图表工作流生成 figure(SVG+PNG 双格式)再插入。");
3192
- return;
3193
- }
3194
- const list = figures.map((f, idx) => `${idx + 1}. ${f.path}`).join("\n");
3195
- const raw = prompt(`选择要插入的图片(输入序号):\n\n${list}`, "1");
3196
- const idx = Number(raw) - 1;
3197
- const path = figures[idx]?.path;
3198
- if (!path) return;
3199
- const imgHtml = `<img src="/api/artifact?path=${encodeURIComponent(path)}" data-md-src="${escAttr(path)}" alt="图" class="md-image" style="max-width:80%;border-radius:6px;border:1px solid var(--line);">`;
3200
- editorCanvas.focus();
3201
- document.execCommand("insertHTML", false, imgHtml);
3202
- bindEditorImages();
3203
- } catch (error) {
3204
- alert("插入图片失败:" + error.message);
3205
- }
3206
- });
3207
-
3208
- /* ==============================================================
3209
- 参考文献存档与核验(.psyclaw/references.jsonl)
3210
- ============================================================== */
3211
- const REF_STATUS = (r) => {
3212
- const parts = [];
3213
- if (r.verified) parts.push('<span class="pill ok">双源已核验</span>');
3214
- else parts.push('<span class="pill warn">未核验</span>');
3215
- if (r.oaPdfUrl) parts.push('<span class="pill accent">开放获取</span>');
3216
- if (r.downloadedPath) parts.push('<span class="pill neutral">已下载</span>');
3217
- return parts.join(" ") || '<span class="pill neutral">存档</span>';
3218
- };
3219
-
3220
- async function loadReferences() {
3221
- const body = document.getElementById("references-table-body");
3222
- if (!body) return;
3223
- try {
3224
- const [refsRes, citesRes] = await Promise.all([fetch("/api/references"), fetch("/api/citations")]);
3225
- if (!refsRes.ok) throw new Error(`HTTP ${refsRes.status}`);
3226
- const data = await refsRes.json();
3227
- const cites = citesRes.ok ? (await citesRes.json()).citations || [] : [];
3228
- const citeCount = new Map();
3229
- cites.forEach((u) => citeCount.set(u.doi, (citeCount.get(u.doi) || 0) + 1));
3230
- const refs = data.references || [];
3231
- body.innerHTML = refs.length
3232
- ? refs.map((r) => `
3233
- <tr>
3234
- <td class="mono" style="font-size:0.72rem;">${esc(r.doi)}</td>
3235
- <td style="font-size:0.78rem;">${esc(r.title)}<div class="mono" style="font-size:0.7rem;color:var(--ink-muted);">${esc(r.authors.slice(0, 3).join(", "))}${r.authors.length > 3 ? " 等" : ""}</div></td>
3236
- <td class="mono" style="font-size:0.75rem;">${r.year ?? "—"}</td>
3237
- <td>${REF_STATUS(r)}${citeCount.get(r.doi) ? ` <span class="pill accent" style="font-size:0.65rem;">已引用 ${citeCount.get(r.doi)} 次</span>` : ""}</td>
3238
- <td style="white-space:nowrap;">
3239
- ${r.downloadedPath
3240
- ? `<span class="mono" style="font-size:0.7rem;color:var(--ink-muted);">${esc(r.downloadedPath)}</span>`
3241
- : r.oaPdfUrl
3242
- ? `<button class="btn btn-secondary" style="font-size:0.7rem;padding:0.15rem 0.45rem;" onclick="downloadReference('${escAttr(r.doi)}')">⬇ 下载 PDF</button>`
3243
- : `<button class="btn btn-secondary" style="font-size:0.7rem;padding:0.15rem 0.45rem;" onclick="verifyReference('${escAttr(r.doi)}')">🔍 核验</button>`}
3244
- </td>
3245
- </tr>`).join("")
3246
- : `<tr><td colspan="5" class="detail-key">存档为空 — 输入 DOI 点击「核验并存档」,或运行「核验正文引用」自动列出缺口。</td></tr>`;
3247
- } catch (error) {
3248
- body.innerHTML = `<tr><td colspan="5" class="detail-key">读取失败:${esc(error.message)}</td></tr>`;
3249
- }
3250
- }
3251
-
3252
- async function loadCitationUses() {
3253
- const el = document.getElementById("citation-uses");
3254
- if (!el) return;
3255
- try {
3256
- const res = await fetch("/api/citations");
3257
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
3258
- const data = await res.json();
3259
- const uses = data.citations || [];
3260
- el.innerHTML = uses.length
3261
- ? uses.slice().reverse().slice(0, 30).map((u) => `
3262
- <div style="display:flex;gap:.4rem;align-items:baseline;border-bottom:1px dashed var(--line);padding-bottom:0.3rem;">
3263
- <span class="mono" style="font-size:0.7rem;color:var(--ink-muted);">${esc(u.surname)} ${u.year ?? ""}</span>
3264
- <span style="flex:1;">${esc(u.reason)}<span class="mono" style="color:var(--ink-faint);font-size:0.7rem;"> · ${esc(u.doi)}</span></span>
3265
- ${u.verified ? '<span class="pill ok" style="font-size:0.65rem;">已核验</span>' : '<span class="pill warn" style="font-size:0.65rem;">未核验</span>'}
3266
- </div>`).join("")
3267
- : `<span class="detail-key">尚无引用用途登记 — 写作时智能体会经 psyclaw_cite 工具自动登记(DOI + 原因 + 上下文),也可在此手动登记。</span>`;
3268
- } catch (error) {
3269
- el.innerHTML = `<span class="detail-key">读取失败:${esc(error.message)}</span>`;
3270
- }
3271
- }
3272
-
3273
- document.getElementById("btn-record-cite")?.addEventListener("click", async () => {
3274
- const doi = document.getElementById("cite-doi-input").value.trim();
3275
- const reason = document.getElementById("cite-reason-input").value.trim();
3276
- const context = document.getElementById("cite-context-input").value.trim() || "(未提供上下文)";
3277
- const fb = document.getElementById("ref-check-result");
3278
- if (!doi || !reason) { alert("请填写 DOI 与引用原因。"); return; }
3279
- try {
3280
- const res = await fetch("/api/citations", {
3281
- method: "POST",
3282
- headers: { "content-type": "application/json" },
3283
- body: JSON.stringify({ doi, reason, context }),
3284
- });
3285
- if (!res.ok) {
3286
- const err = await res.json().catch(() => ({}));
3287
- throw new Error(err.error || `HTTP ${res.status}`);
3288
- }
3289
- const data = await res.json();
3290
- if (fb) fb.textContent = `📝 已登记引用 ${data.record.citationId}:${data.record.surname} ${data.record.year ?? ""} — ${data.record.reason}${data.record.verified ? "(双源已核验,并已同步存档)" : "(未核验,如实记录,请补核验)"}`;
3291
- document.getElementById("cite-reason-input").value = "";
3292
- document.getElementById("cite-context-input").value = "";
3293
- await loadCitationUses();
3294
- await loadReferences();
3295
- } catch (error) {
3296
- if (fb) fb.textContent = `❌ 登记失败:${error.message}`;
3297
- }
3298
- });
3299
-
3300
- async function verifyReference(doi) {
3301
- const feedback = document.getElementById("ref-check-result");
3302
- if (feedback) feedback.textContent = `🔍 正在核验 ${doi}(Crossref + OpenAlex + OA 查询)…`;
3303
- try {
3304
- const res = await fetch("/api/references/verify", {
3305
- method: "POST",
3306
- headers: { "content-type": "application/json" },
3307
- body: JSON.stringify({ doi }),
3308
- });
3309
- if (!res.ok) {
3310
- const err = await res.json().catch(() => ({}));
3311
- throw new Error(err.error || `HTTP ${res.status}`);
3312
- }
3313
- const data = await res.json();
3314
- if (!data.ok) {
3315
- if (feedback) feedback.textContent = `❌ ${doi} 无法核验:${data.verification?.error || "无元数据"}`;
3316
- return;
3317
- }
3318
- if (feedback) feedback.textContent = `✅ ${data.appended ? "已存档" : "已存在"}:${data.record.title}(${data.record.authors.slice(0, 3).join(", ")}${data.record.year ? `, ${data.record.year}` : ""})${data.record.oaPdfUrl ? " · 开放获取" : ""}`;
3319
- await loadReferences();
3320
- } catch (error) {
3321
- if (feedback) feedback.textContent = `❌ 核验失败:${error.message}`;
3322
- }
3323
- }
3324
-
3325
- async function downloadReference(doi) {
3326
- const feedback = document.getElementById("ref-check-result");
3327
- if (feedback) feedback.textContent = `⬇ 正在下载 ${doi}(OpenAlex OA 链接)…`;
3328
- try {
3329
- const res = await fetch("/api/references/download", {
3330
- method: "POST",
3331
- headers: { "content-type": "application/json" },
3332
- body: JSON.stringify({ doi }),
3333
- });
3334
- if (!res.ok) {
3335
- const err = await res.json().catch(() => ({}));
3336
- throw new Error(err.error || `HTTP ${res.status}`);
3337
- }
3338
- const data = await res.json();
3339
- if (!data.ok) {
3340
- if (feedback) feedback.textContent = data.reason === "not-open-access"
3341
- ? `⛔ ${doi} 非开放获取:在对话中让智能体走机构全文下载(psyclaw_workbench),需人工浏览器登录。`
3342
- : `⛔ 下载失败(${data.reason})。`;
3343
- return;
3344
- }
3345
- if (feedback) feedback.textContent = `✅ 已下载 ${data.path}(${data.bytes} B,证据 ${data.evidenceId})。`;
3346
- await loadReferences();
3347
- await loadDocuments();
3348
- } catch (error) {
3349
- if (feedback) feedback.textContent = `❌ 下载失败:${error.message}`;
3350
- }
3351
- }
3352
-
3353
- async function checkManuscriptCitations() {
3354
- const feedback = document.getElementById("ref-check-result");
3355
- if (feedback) feedback.textContent = "🧾 正在提取正文引用并对照存档…";
3356
- const md = editorHtmlToMarkdown();
3357
- try {
3358
- const res = await fetch("/api/references/check", {
3359
- method: "POST",
3360
- headers: { "content-type": "application/json" },
3361
- body: JSON.stringify({ text: md }),
3362
- });
3363
- if (!res.ok) {
3364
- const err = await res.json().catch(() => ({}));
3365
- throw new Error(err.error || `HTTP ${res.status}`);
3366
- }
3367
- const data = await res.json();
3368
- const lines = [];
3369
- lines.push(`🧾 正文引用 ${data.citations.length} 处:已存档匹配 ${data.matched},缺口 ${data.unmatched}(存档 ${data.archiveSize} 条)`);
3370
- for (const cite of data.citations) {
3371
- if (cite.missing) {
3372
- lines.push(` ⚠️ ${cite.surname} ${cite.years.join(", ")} — 存档中无匹配(可输入 DOI 核验并存档)`);
3373
- } else {
3374
- const ref = cite.matched[0];
3375
- lines.push(` ✔ ${cite.surname} ${cite.years.join(", ")} → ${ref.title.slice(0, 50)}${ref.title.length > 50 ? "…" : ""}${ref.verified ? "(已核验)" : "(存档未核验)"}`);
3376
- }
3377
- }
3378
- if (feedback) feedback.textContent = lines.join("\n");
3379
- } catch (error) {
3380
- if (feedback) feedback.textContent = `❌ 核验失败:${error.message}`;
3381
- }
3382
- }
3383
-
3384
- document.getElementById("btn-ref-verify")?.addEventListener("click", () => {
3385
- const doi = document.getElementById("ref-doi-input").value.trim();
3386
- if (!doi) { alert("请输入 DOI。"); return; }
3387
- verifyReference(doi);
3388
- });
3389
-
3390
- document.getElementById("btn-ref-check-text")?.addEventListener("click", checkManuscriptCitations);
3391
-
3392
- /* ==============================================================
3393
- Master Init
3394
- ============================================================== */
3395
- async function initApp() {
3396
- await Promise.all([
3397
- pollActivity(),
3398
- loadArtifacts(),
3399
- loadHitl(),
3400
- loadSkillsAndEcosystem(),
3401
- loadConfig(),
3402
- loadTokens(),
3403
- loadProvidersAndStats(),
3404
- loadManuscriptData(),
3405
- loadDocuments(),
3406
- loadReferences(),
3407
- loadCitationUses(),
3408
- loadManuscriptVersions()
3409
- ]);
3410
- setInterval(pollActivity, 3500);
3411
- }
3412
-
3413
- initApp().catch(err => console.error("psyclaw init failed:", err));
52
+ const state={view:"project",files:[],traces:[],eco:[],models:[],active:{},usage:null,currency:"cny"};
53
+ const $=id=>document.getElementById(id);
54
+ const esc=value=>String(value??"").replace(/[&<>"']/g,ch=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[ch]));
55
+ async function json(url,options){const response=await fetch(url,options);const body=await response.json().catch(()=>({error:"请求失败"}));if(!response.ok)throw new Error(body.error||"请求失败");return body}
56
+ function setView(view){state.view=view;document.querySelectorAll(".nav button").forEach(button=>button.classList.toggle("active",button.dataset.view===view));document.querySelectorAll(".view").forEach(section=>section.classList.toggle("active",section.id===`view-${view}`));if(view==="ecosystem")loadEcosystem();if(view==="provider")loadProvider()}
57
+ document.querySelectorAll(".nav button").forEach(button=>button.addEventListener("click",()=>setView(button.dataset.view)));
58
+
59
+ function inlineMarkdown(text){let value=esc(text);value=value.replace(/`([^`]+)`/g,"<code>$1</code>").replace(/\*\*([^*]+)\*\*/g,"<strong>$1</strong>").replace(/\*([^*]+)\*/g,"<em>$1</em>");value=value.replace(/\[([^\]]+)\]\(([^)]+)\)/g,(_,label,target)=>/^https?:\/\//i.test(target)?`<a href="${esc(target)}" target="_blank" rel="noreferrer">${label}</a>`:label);return value}
60
+ function renderMarkdown(markdown){const lines=String(markdown).replace(/\r\n/g,"\n").split("\n"),out=[];let i=0,inCode=false,code=[];while(i<lines.length){const line=lines[i],trim=line.trim();if(trim.startsWith("```")){if(inCode){out.push(`<pre><code>${esc(code.join("\n"))}</code></pre>`);code=[];inCode=false}else inCode=true;i++;continue}if(inCode){code.push(line);i++;continue}const heading=trim.match(/^(#{1,3})\s+(.+)$/);if(heading){const level=heading[1].length;out.push(`<h${level}>${inlineMarkdown(heading[2])}</h${level}>`);i++;continue}if(trim.startsWith(">")){out.push(`<blockquote>${inlineMarkdown(trim.replace(/^>\s?/,""))}</blockquote>`);i++;continue}if(/^[-*+]\s+/.test(trim)){const items=[];while(i<lines.length&&/^[-*+]\s+/.test(lines[i].trim()))items.push(lines[i++].trim().replace(/^[-*+]\s+/,""));out.push(`<ul>${items.map(item=>`<li>${inlineMarkdown(item)}</li>`).join("")}</ul>`);continue}if(/^\d+\.\s+/.test(trim)){const items=[];while(i<lines.length&&/^\d+\.\s+/.test(lines[i].trim()))items.push(lines[i++].trim().replace(/^\d+\.\s+/,""));out.push(`<ol>${items.map(item=>`<li>${inlineMarkdown(item)}</li>`).join("")}</ol>`);continue}if(trim.startsWith("|")&&i+1<lines.length&&/^\|?[\s:|-]+\|/.test(lines[i+1].trim())){const cells=value=>value.replace(/^\||\|$/g,"").split("|").map(cell=>cell.trim());const head=cells(trim);i+=2;const rows=[];while(i<lines.length&&lines[i].trim().startsWith("|"))rows.push(cells(lines[i++]));out.push(`<table><thead><tr>${head.map(cell=>`<th>${inlineMarkdown(cell)}</th>`).join("")}</tr></thead><tbody>${rows.map(row=>`<tr>${row.map(cell=>`<td>${inlineMarkdown(cell)}</td>`).join("")}</tr>`).join("")}</tbody></table>`);continue}if(!trim){i++;continue}const para=[trim];i++;while(i<lines.length&&lines[i].trim()&&!/^(#{1,3})\s+/.test(lines[i].trim())&&!/^[-*+]\s+/.test(lines[i].trim())&&!/^\d+\.\s+/.test(lines[i].trim())&&!lines[i].trim().startsWith(">")&&!lines[i].trim().startsWith("```"))para.push(lines[i++].trim());out.push(`<p>${inlineMarkdown(para.join(" "))}</p>`)}if(inCode)out.push(`<pre><code>${esc(code.join("\n"))}</code></pre>`);return out.join("")}
61
+ function groupFiles(files){const priority=["notes/goal.md","notes/research-spec.md","notes/plan.md","notes/decisions.md","notes/HANDOFF.md"];const ordered=[...files].sort((a,b)=>{const ai=priority.indexOf(a),bi=priority.indexOf(b);if(ai>=0||bi>=0)return(ai<0?99:ai)-(bi<0?99:bi);return a.localeCompare(b,"zh-CN")});const groups={"核心文档":ordered.filter(path=>priority.includes(path)),"论文与报告":ordered.filter(path=>/^(paper|docs|outputs)\//.test(path)&&!priority.includes(path)),"研究记录":ordered.filter(path=>/^(notes|logs)\//.test(path)&&!priority.includes(path))};return groups}
62
+ function renderTree(){const tree=$("file-tree"),groups=groupFiles(state.files);let html="";for(const[name,files]of Object.entries(groups)){if(!files.length)continue;html+=`<div class="tree-section">${esc(name)}</div>${files.map(path=>`<button class="tree-item" data-file="${esc(path)}"><span>◇</span><span>${esc(path.split("/").at(-1))}</span></button>`).join("")}`}html+=`<div class="tree-section">Trace</div>${state.traces.map(trace=>`<button class="tree-item" data-trace="${esc(trace.id)}"><span>${trace.status==="error"?"!":"○"}</span><span>${esc(trace.label)}</span></button>`).join("")||'<div class="subtle" style="padding:7px 8px">暂无 Trace</div>'}`;tree.innerHTML=html||'<div class="empty">暂无项目文件</div>';tree.querySelectorAll("[data-file]").forEach(button=>button.addEventListener("click",()=>openFile(button.dataset.file,button)));tree.querySelectorAll("[data-trace]").forEach(button=>button.addEventListener("click",()=>openTrace(button.dataset.trace,button)))}
63
+ function selectTree(button){document.querySelectorAll(".tree-item").forEach(item=>item.classList.toggle("active",item===button))}
64
+ async function openFile(path,button){selectTree(button);$("viewer-path").textContent=path;$("viewer-kind").textContent="读取中";$("viewer-body").className="viewer-body empty";$("viewer-body").textContent="读取中...";try{const file=await json(`/api/project-file?path=${encodeURIComponent(path)}`);$("viewer-kind").textContent=file.format;$("viewer-body").className="viewer-body";$("viewer-body").innerHTML=file.format==="markdown"?`<article class="markdown">${renderMarkdown(file.content)}</article>`:`<pre class="raw">${esc(file.format==="json"?JSON.stringify(JSON.parse(file.content),null,2):file.content)}</pre>`}catch(error){$("viewer-body").className="viewer-body empty";$("viewer-body").textContent=error.message}}
65
+ function openTrace(id,button){selectTree(button);const trace=state.traces.find(item=>item.id===id);if(!trace)return;$("viewer-path").textContent=trace.label;$("viewer-kind").textContent="Trace";$("viewer-body").className="viewer-body";const duration=Math.max(0,new Date(trace.endedAt)-new Date(trace.startedAt));$("viewer-body").innerHTML=`<div class="trace-summary"><div class="metric"><small>来源</small><strong>${trace.source==="session"?"会话":"工作流"}</strong></div><div class="metric"><small>事件</small><strong>${trace.events.length}</strong></div><div class="metric"><small>跨度</small><strong>${duration<1000?"<1s":`${Math.round(duration/1000)}s`}</strong></div></div><div class="timeline">${trace.events.map(event=>`<div class="event ${event.status==="error"?"error":""}"><div class="event-head"><span>${esc(event.name)}</span><time>${esc(new Date(event.at).toLocaleString())}</time></div><div class="event-meta">${event.category?`工具类别:${esc(event.category)}`:"仅显示脱敏元数据"}</div></div>`).join("")}</div>`}
66
+ async function loadProject(){const[files,traces]=await Promise.all([json("/api/project-files"),json("/api/traces")]);state.files=files.files||[];state.traces=traces.traces||[];renderTree();const goal=document.querySelector('[data-file="notes/goal.md"]')||document.querySelector("[data-file]");if(goal)goal.click()}
67
+
68
+ async function loadEcosystem(){try{const[skills,mcps,enabled]=await Promise.all([json("/api/recommended-skills"),json("/api/recommended-mcps"),json("/api/enabled-capabilities")]);const skillSet=new Set((enabled.skills||[]).map(item=>item.id)),mcpSet=new Set((enabled.mcp||[]).map(item=>item.id));state.eco=[...(enabled.coreSkills||[]).map(item=>({...item,kind:"core",description:"PsyClaw 内置 Skill",sourceRef:"bundled",enabled:item.enabled,installed:true})),...(skills.items||[]).map(item=>({...item,kind:"skill",enabled:skillSet.has(item.id),installed:skillSet.has(item.id)})),...(mcps.items||[]).map(item=>({...item,kind:"mcp",enabled:mcpSet.has(item.id),installed:mcpSet.has(item.id)})),...(skills.externalTools||[]).map(item=>({...item,kind:"external",enabled:false,installed:false}))];renderEcosystem()}catch(error){$("ecosystem-body").innerHTML=`<tr><td colspan="5">${esc(error.message)}</td></tr>`}}
69
+ function renderEcosystem(){const query=$("eco-search").value.trim().toLowerCase(),kind=$("eco-kind").value,status=$("eco-status").value;const items=state.eco.filter(item=>(kind==="all"||item.kind===kind)&&(status==="all"||(status==="enabled"?item.enabled:!item.enabled))&&`${item.name} ${item.description||""}`.toLowerCase().includes(query));$("ecosystem-body").innerHTML=items.map(item=>`<tr><td><div class="name">${esc(item.name)}</div><div class="subtle">${esc(item.description||"")}</div></td><td>${esc(item.kind==="core"?"Core Skill":item.kind.toUpperCase())}</td><td>${/^https?:/.test(item.sourceRef||"")?`<a href="${esc(item.sourceRef)}" target="_blank" rel="noreferrer">来源</a>`:esc(item.sourceRef||"bundled")}</td><td><span class="tag ${item.enabled?"ok":""}">${item.enabled?"已启用":item.kind==="external"?"外部工具":"未启用"}</span></td><td>${item.kind==="external"?"—":item.enabled?`<button class="button small" onclick="toggleCapability('${item.kind}','${esc(item.id)}',false)">停用</button>`:`<button class="button small primary" onclick="installCapability('${item.kind}','${esc(item.id)}')">${item.kind==="core"?"启用":"安装"}</button>`}</td></tr>`).join("")||'<tr><td colspan="5">没有匹配项</td></tr>'}
70
+ async function toggleCapability(kind,id,enabled){await json("/api/recommendation-state",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({kind:kind==="core"?"core-skill":kind==="skill"?"skills":kind,id,enabled})});await loadEcosystem()}
71
+ async function installCapability(kind,id){if(kind==="core"){await toggleCapability(kind,id,true);return}const scope=kind==="skill"?(confirm("安装到系统目录?\n确定:所有项目使用;取消:仅当前项目使用。")?"user":"project"):undefined;if(!confirm(`确认安装 ${id}?安装完成后需要 /reload。`))return;const result=await json("/api/install/execute",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({kind,id,scope,approved:true,actor:"researcher"})});alert(result.message||(result.ok?"安装任务已处理,请执行 /reload。":"安装未完成"));await loadEcosystem()}
72
+ [$("eco-search"),$("eco-kind"),$("eco-status")].forEach(control=>control.addEventListener("input",renderEcosystem));
73
+
74
+ function providerModels(provider){return state.models.filter(model=>model.provider===provider)}
75
+ function updateModels(){const models=providerModels($("provider-select").value);$("model-select").innerHTML=models.map(model=>`<option value="${esc(model.version||model.id.split("/").slice(1).join("/"))}">${esc(model.name)}</option>`).join("");if(state.active.provider===$("provider-select").value&&state.active.model)$("model-select").value=state.active.model}
76
+ async function loadProvider(){try{const[catalog,active,usage]=await Promise.all([json("/api/catalog"),json("/api/active-provider"),json("/api/token-usage")]);state.models=catalog.models||[];state.active=active||{};state.usage=usage;const providers=[...new Set(state.models.map(model=>model.provider))].sort();$("provider-select").innerHTML=providers.map(provider=>`<option value="${esc(provider)}">${esc(provider)}</option>`).join("");if(active.provider&&providers.includes(active.provider))$("provider-select").value=active.provider;updateModels();renderCost()}catch(error){$("provider-feedback").textContent=error.message}}
77
+ function currentModel(){return providerModels($("provider-select").value).find(model=>(model.version||model.id.split("/").slice(1).join("/"))===$("model-select").value)}
78
+ $("provider-select").addEventListener("change",updateModels);
79
+ $("save-provider").addEventListener("click",async()=>{const feedback=$("provider-feedback");feedback.textContent="保存中...";try{await json("/api/active-provider",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({provider:$("provider-select").value,model:$("model-select").value})});feedback.textContent="已保存,重启会话后生效。"}catch(error){feedback.textContent=error.message}});
80
+ $("configure-provider").addEventListener("click",async()=>{const feedback=$("config-feedback"),model=currentModel(),key=$("provider-key").value;if(!model){feedback.textContent="请选择模型";return}feedback.textContent="保存中...";try{await json("/api/provider-config",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({id:model.provider,name:model.provider,baseUrl:model.endpoint,api:model.provider==="anthropic"?"anthropic-messages":"openai-completions",apiKeyEnv:model.apiKeyEnv,modelId:$("model-select").value,...(key?{apiKey:key}:{})})});$("provider-key").value="";feedback.textContent="配置已保存,重启会话后生效。"}catch(error){feedback.textContent=error.message}});
81
+ function money(usd,cny){return state.currency==="usd"?`$${Number(usd||0).toFixed(4)}`:`¥${Number(cny||0).toFixed(4)}`}
82
+ function renderCost(){const usage=state.usage||{},totals=usage.totals||{},rows=usage.byModel||[];$("cost-content").innerHTML=`<div class="cost-metrics"><div class="metric"><small>总 Token</small><strong>${Number(totals.tokens||0).toLocaleString()}</strong></div><div class="metric"><small>实际费用</small><strong>${money(totals.cost,totals.costCny)}</strong></div><div class="metric"><small>预估费用</small><strong>${money(totals.estimatedCost,totals.estimatedCostCny)}</strong></div></div><p class="cost-note">统计范围:全部本地 PsyClaw 会话。实际账单以 Provider 为准。</p><div class="table-wrap"><table class="data"><thead><tr><th>模型</th><th>输入</th><th>输出</th><th>缓存</th><th>Token</th><th>预估费用</th></tr></thead><tbody>${rows.map(row=>`<tr><td><div class="name">${esc(row.model)}</div><div class="subtle">${esc(row.provider)}</div></td><td>${Number(row.input||0).toLocaleString()}</td><td>${Number(row.output||0).toLocaleString()}</td><td>${Number((row.cacheRead||0)+(row.cacheWrite||0)).toLocaleString()}</td><td>${Number(row.tokens||0).toLocaleString()}</td><td>${money(row.estimatedCost,row.estimatedCostCny)}</td></tr>`).join("")||'<tr><td colspan="6">暂无用量记录</td></tr>'}</tbody></table></div>`}
83
+ document.querySelectorAll("[data-currency]").forEach(button=>button.addEventListener("click",()=>{state.currency=button.dataset.currency;document.querySelectorAll("[data-currency]").forEach(item=>item.classList.toggle("active",item===button));renderCost()}));
84
+ $("refresh-project").addEventListener("click",loadProject);$("refresh-ecosystem").addEventListener("click",loadEcosystem);$("refresh-provider").addEventListener("click",loadProvider);
85
+ loadProject().catch(error=>{$("viewer-body").textContent=error.message});
3414
86
  </script>
3415
87
  </body>
3416
88
  </html>