dsh-pictor 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +81 -0
- package/agents/00.renderer.md +117 -0
- package/agents/10.orchestrator.md +191 -0
- package/agents/11.extractor.md +132 -0
- package/agents/12.advisor.md +151 -0
- package/assets/empty-state.png +0 -0
- package/assets/previews/layouts/bento-grid.webp +0 -0
- package/assets/previews/layouts/binary-comparison.webp +0 -0
- package/assets/previews/layouts/bridge.webp +0 -0
- package/assets/previews/layouts/circular-flow.webp +0 -0
- package/assets/previews/layouts/comic-strip.webp +0 -0
- package/assets/previews/layouts/comparison-matrix.webp +0 -0
- package/assets/previews/layouts/dashboard.webp +0 -0
- package/assets/previews/layouts/funnel.webp +0 -0
- package/assets/previews/layouts/geo-map.webp +0 -0
- package/assets/previews/layouts/hierarchical-layers.webp +0 -0
- package/assets/previews/layouts/hub-spoke.webp +0 -0
- package/assets/previews/layouts/iceberg.webp +0 -0
- package/assets/previews/layouts/isometric-map.webp +0 -0
- package/assets/previews/layouts/jigsaw.webp +0 -0
- package/assets/previews/layouts/linear-progression.webp +0 -0
- package/assets/previews/layouts/network-graph.webp +0 -0
- package/assets/previews/layouts/parallel-tracks.webp +0 -0
- package/assets/previews/layouts/periodic-table.webp +0 -0
- package/assets/previews/layouts/quadrant-matrix.webp +0 -0
- package/assets/previews/layouts/sankey-flow.webp +0 -0
- package/assets/previews/layouts/story-mountain.webp +0 -0
- package/assets/previews/layouts/structural-breakdown.webp +0 -0
- package/assets/previews/layouts/toulmin-argument.webp +0 -0
- package/assets/previews/layouts/tree-branching.webp +0 -0
- package/assets/previews/layouts/venn-diagram.webp +0 -0
- package/assets/previews/layouts/winding-roadmap.webp +0 -0
- package/assets/previews/styles/aged-academia.webp +0 -0
- package/assets/previews/styles/bandung-circuit.webp +0 -0
- package/assets/previews/styles/bold-graphic.webp +0 -0
- package/assets/previews/styles/chalkboard.webp +0 -0
- package/assets/previews/styles/claymation.webp +0 -0
- package/assets/previews/styles/clean-analytics.webp +0 -0
- package/assets/previews/styles/corporate-memphis.webp +0 -0
- package/assets/previews/styles/craft-handmade.webp +0 -0
- package/assets/previews/styles/cyberpunk-neon.webp +0 -0
- package/assets/previews/styles/ikea-manual.webp +0 -0
- package/assets/previews/styles/kawaii.webp +0 -0
- package/assets/previews/styles/knolling.webp +0 -0
- package/assets/previews/styles/lego-brick.webp +0 -0
- package/assets/previews/styles/mckinsey-report.webp +0 -0
- package/assets/previews/styles/origami.webp +0 -0
- package/assets/previews/styles/pixel-art.webp +0 -0
- package/assets/previews/styles/storybook-watercolor.webp +0 -0
- package/assets/previews/styles/subway-map.webp +0 -0
- package/assets/previews/styles/technical-schematic.webp +0 -0
- package/assets/previews/styles/tricon-infographic.webp +0 -0
- package/assets/previews/styles/ui-wireframe.webp +0 -0
- package/cordis.patch.yml +8 -0
- package/docs/DESIGN.zh.md +278 -0
- package/docs/verification-checklist.zh.md +64 -0
- package/lib/client.js +1724 -0
- package/lib/image-providers.js +195 -0
- package/lib/index.js +739 -0
- package/lib/render.js +28 -0
- package/package.json +80 -0
- package/references/domain/base-prompt.md +44 -0
- package/references/domain/diagram-types/_catalog.yaml +78 -0
- package/references/domain/diagram-types/debate-map.md +33 -0
- package/references/domain/diagram-types/evidence-radar.md +33 -0
- package/references/domain/diagram-types/geographic-map.md +33 -0
- package/references/domain/diagram-types/greimas-square.md +33 -0
- package/references/domain/diagram-types/landscape.md +33 -0
- package/references/domain/diagram-types/network-graph.md +35 -0
- package/references/domain/diagram-types/parallel-timeline.md +33 -0
- package/references/domain/diagram-types/quadrant.md +33 -0
- package/references/domain/diagram-types/sankey-flow.md +33 -0
- package/references/domain/diagram-types/stakeholder-matrix.md +31 -0
- package/references/domain/diagram-types/toulmin-diagram.md +35 -0
- package/references/domain/layouts/bento-grid.md +41 -0
- package/references/domain/layouts/binary-comparison.md +48 -0
- package/references/domain/layouts/bridge.md +41 -0
- package/references/domain/layouts/circular-flow.md +41 -0
- package/references/domain/layouts/comic-strip.md +41 -0
- package/references/domain/layouts/comparison-matrix.md +41 -0
- package/references/domain/layouts/dashboard.md +41 -0
- package/references/domain/layouts/funnel.md +41 -0
- package/references/domain/layouts/geo-map.md +82 -0
- package/references/domain/layouts/hierarchical-layers.md +48 -0
- package/references/domain/layouts/hub-spoke.md +41 -0
- package/references/domain/layouts/iceberg.md +41 -0
- package/references/domain/layouts/isometric-map.md +41 -0
- package/references/domain/layouts/jigsaw.md +41 -0
- package/references/domain/layouts/linear-progression.md +48 -0
- package/references/domain/layouts/network-graph.md +42 -0
- package/references/domain/layouts/parallel-tracks.md +49 -0
- package/references/domain/layouts/periodic-table.md +41 -0
- package/references/domain/layouts/quadrant-matrix.md +93 -0
- package/references/domain/layouts/sankey-flow.md +97 -0
- package/references/domain/layouts/story-mountain.md +41 -0
- package/references/domain/layouts/structural-breakdown.md +48 -0
- package/references/domain/layouts/toulmin-argument.md +113 -0
- package/references/domain/layouts/tree-branching.md +41 -0
- package/references/domain/layouts/venn-diagram.md +41 -0
- package/references/domain/layouts/winding-roadmap.md +41 -0
- package/references/domain/styles/aged-academia.md +36 -0
- package/references/domain/styles/bandung-circuit.md +82 -0
- package/references/domain/styles/bold-graphic.md +36 -0
- package/references/domain/styles/chalkboard.md +61 -0
- package/references/domain/styles/claymation.md +29 -0
- package/references/domain/styles/clean-analytics.md +78 -0
- package/references/domain/styles/corporate-memphis.md +29 -0
- package/references/domain/styles/craft-handmade.md +44 -0
- package/references/domain/styles/cyberpunk-neon.md +29 -0
- package/references/domain/styles/ikea-manual.md +29 -0
- package/references/domain/styles/kawaii.md +29 -0
- package/references/domain/styles/knolling.md +29 -0
- package/references/domain/styles/lego-brick.md +29 -0
- package/references/domain/styles/mckinsey-report.md +83 -0
- package/references/domain/styles/origami.md +29 -0
- package/references/domain/styles/pixel-art.md +29 -0
- package/references/domain/styles/storybook-watercolor.md +29 -0
- package/references/domain/styles/subway-map.md +29 -0
- package/references/domain/styles/technical-schematic.md +36 -0
- package/references/domain/styles/tricon-infographic.md +54 -0
- package/references/domain/styles/ui-wireframe.md +29 -0
- package/references/domain/visual-principles.md +63 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,1724 @@
|
|
|
1
|
+
// dsh-pictor — Client half (browser)。由 scripts/build.mjs 生成,请修改 src/client/index.js。
|
|
2
|
+
window.__ModuleLoader__.load({
|
|
3
|
+
id: "dsh-pictor",
|
|
4
|
+
factory: (require) => {
|
|
5
|
+
// dsh-pictor — Client half (browser)。
|
|
6
|
+
//
|
|
7
|
+
// 由 scripts/build.mjs 包装进 window.__ModuleLoader__.load({ id, factory }),
|
|
8
|
+
// factory 的参数 require 提供 react;文件末尾 return { inject, apply }。
|
|
9
|
+
// 界面(docs/DESIGN.zh.md v1):
|
|
10
|
+
// - 唯一入口:dsh 左下角 footer 按钮「Pictor」开关 shell.overlay 有界面板,
|
|
11
|
+
// 形态照 pomasa-studio(有界面板、左部透空保持 dsh 侧栏可用、右侧为工作台);
|
|
12
|
+
// - 工作台分栏:左栏项目导航 + 右栏项目详情(信息条、阶段条、步骤内容、讨论、日志);
|
|
13
|
+
// - 项目 = 一份文档转一组信息图,步骤固定三步:提取、方案、渲染;每步有人工门控;
|
|
14
|
+
// - GUI 只做两件事:查看信息(文件事实投影)、规整输入(上传/粘贴/结构化指令)。
|
|
15
|
+
// 行为全部经 RPC(/pictor)交给宿主与项目会话。
|
|
16
|
+
const React = require('react')
|
|
17
|
+
const h = React.createElement
|
|
18
|
+
|
|
19
|
+
const inject = ['slots', 'connection', 'workspaces', 'sessions']
|
|
20
|
+
const RPC = '/pictor'
|
|
21
|
+
|
|
22
|
+
const STYLE = `
|
|
23
|
+
.pt-root { font-size: 16px; line-height: 1.6; color: var(--dsw-alias-label-primary, #1a1a1a); height: 100%; }
|
|
24
|
+
.pt-root * { box-sizing: border-box; }
|
|
25
|
+
.pt-root button { font: inherit; }
|
|
26
|
+
.pt-root input, .pt-root textarea, .pt-root select, .pt-root [contenteditable] { font: inherit; }
|
|
27
|
+
|
|
28
|
+
/* ---- footer 启动按钮 ---- */
|
|
29
|
+
.pt-footer-action { cursor: pointer; padding: 7px 12px; font-size: 14px; font-weight: 600;
|
|
30
|
+
color: var(--dsw-alias-label-primary); display: inline-flex; align-items: center; justify-content: center;
|
|
31
|
+
gap: 7px; transition: background 150ms, color 150ms, border-color 150ms; border-radius: 8px;
|
|
32
|
+
margin: 2px 8px; white-space: nowrap; background: var(--dsw-alias-button-floating-fill);
|
|
33
|
+
border: 1px solid var(--dsw-alias-border-l2); }
|
|
34
|
+
.pt-footer-action:hover { background: var(--dsw-alias-button-floating-hover); }
|
|
35
|
+
.pt-footer-action .glyph { color: var(--dsw-alias-state-business-primary); font-size: 12px; line-height: 1; opacity: 0.8; }
|
|
36
|
+
.pt-footer-action.on { background: var(--dsw-alias-state-business-primary); border-color: transparent;
|
|
37
|
+
color: var(--dsw-alias-brand-primary-invert, #fff); }
|
|
38
|
+
.pt-footer-action.on .glyph { color: inherit; opacity: 1; }
|
|
39
|
+
.pt-footer-action.on:hover { background: var(--dsw-alias-button-info-hover, var(--dsw-alias-state-business-primary));
|
|
40
|
+
filter: brightness(1.05); }
|
|
41
|
+
|
|
42
|
+
/* ---- shell overlay:有界面板(点击穿透底 + 左部空位 + 右侧交互区) ---- */
|
|
43
|
+
.pt-shell-root { position: absolute; inset: 0; z-index: 20; display: flex; align-items: stretch;
|
|
44
|
+
pointer-events: none; }
|
|
45
|
+
.pt-shell-nav { flex: none; }
|
|
46
|
+
.pt-shell-panel { flex: 1; min-width: 0; height: 100%; pointer-events: auto; display: flex;
|
|
47
|
+
flex-direction: column; background: var(--dsw-alias-bg-base, #f7f7f5); border-left: 1px solid
|
|
48
|
+
var(--dsw-alias-border-l2, rgba(128,128,128,.18)); }
|
|
49
|
+
|
|
50
|
+
/* ---- 工作台分栏 ---- */
|
|
51
|
+
.pt-workbench { display: flex; flex: 1; min-height: 0; }
|
|
52
|
+
.pt-nav { width: 264px; flex: none; border-right: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.16));
|
|
53
|
+
background: var(--dsw-alias-bg-layer-1, #fbfbfa); display: flex; flex-direction: column; overflow-y: auto; }
|
|
54
|
+
.pt-nav-head { padding: 18px 16px 12px; display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
|
55
|
+
.pt-nav-titles { flex: 1; min-width: 0; }
|
|
56
|
+
.pt-nav-title { font-size: 18px; font-weight: 700; letter-spacing: -0.01em; margin: 0; }
|
|
57
|
+
.pt-nav-sub { font-size: 13px; color: var(--dsw-alias-label-caption, #8a8a8a); margin: 2px 0 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
58
|
+
.pt-nav-new { border: 0; background: var(--dsw-alias-state-business-primary, #4f7cff); color: #fff;
|
|
59
|
+
border-radius: 10px; padding: 7px 12px; font-size: 13px; font-weight: 600; cursor: pointer; white-space: nowrap; flex: none; }
|
|
60
|
+
.pt-nav-new:hover { filter: brightness(.95); }
|
|
61
|
+
.pt-nav-list { flex: 1; overflow-y: auto; padding: 4px 8px 12px; min-height: 0; }
|
|
62
|
+
.pt-nav-item { display: flex; align-items: center; gap: 10px; width: 100%; text-align: left;
|
|
63
|
+
padding: 9px 10px; border-radius: 10px; border: 0; background: transparent; cursor: pointer;
|
|
64
|
+
color: var(--dsw-alias-label-primary, #1a1a1a); }
|
|
65
|
+
.pt-nav-item:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,.05)); }
|
|
66
|
+
.pt-nav-item.active { background: var(--dsw-alias-state-business-primary, #4f7cff); color: #fff; }
|
|
67
|
+
.pt-nav-item .name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; font-weight: 550; }
|
|
68
|
+
.pt-nav-item .thumb { width: 30px; height: 30px; border-radius: 7px; object-fit: cover; flex: none;
|
|
69
|
+
background: var(--dsw-alias-bg-layer-2, #eee); }
|
|
70
|
+
.pt-nav-item .thumb-empty { display: inline-flex; align-items: center; justify-content: center;
|
|
71
|
+
color: var(--dsw-alias-label-caption, #b5b5b5); background: var(--dsw-alias-bg-layer-0, #f4f4f2); }
|
|
72
|
+
.pt-nav-item .meta { font-size: 11.5px; opacity: .75; }
|
|
73
|
+
.pt-nav-foot { border-top: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.12)); padding: 10px; }
|
|
74
|
+
.pt-nav-settings { display: flex; align-items: center; gap: 10px; width: 100%; text-align: left;
|
|
75
|
+
padding: 8px 10px; border-radius: 10px; border: 1px solid var(--dsw-alias-border-l2, #d8d8d8);
|
|
76
|
+
background: var(--dsw-alias-bg-layer-2, #fff); cursor: pointer; font-size: 14px; font-weight: 600;
|
|
77
|
+
color: var(--dsw-alias-label-primary, #1a1a1a); }
|
|
78
|
+
.pt-nav-settings:hover { border-color: var(--dsw-alias-border-l1, #b0b0b0); filter: brightness(.98); }
|
|
79
|
+
.pt-nav-settings .glyph { color: var(--dsw-alias-state-business-primary, #4f7cff); font-size: 16px; }
|
|
80
|
+
.pt-nav-settings:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,.05)); }
|
|
81
|
+
.pt-lang { display: flex; align-items: center; gap: 8px; padding: 8px 10px 0; font-size: 13px;
|
|
82
|
+
color: var(--dsw-alias-label-caption, #888); }
|
|
83
|
+
.pt-lang-opts { display: inline-flex; border: 1px solid var(--dsw-alias-border-l2, #d8d8d8);
|
|
84
|
+
border-radius: 999px; overflow: hidden; }
|
|
85
|
+
.pt-lang-opt { border: 0; background: transparent; padding: 2px 10px; font-size: 12.5px; cursor: pointer;
|
|
86
|
+
color: var(--dsw-alias-label-secondary, #777); }
|
|
87
|
+
.pt-lang-opt.on { background: var(--dsw-alias-state-business-primary, #4f7cff); color: #fff; font-weight: 600; }
|
|
88
|
+
|
|
89
|
+
/* ---- 主区 ---- */
|
|
90
|
+
.pt-main { flex: 1; min-width: 0; overflow-y: auto; padding: 22px 28px 40px; }
|
|
91
|
+
.pt-wrap { max-width: 880px; margin: 0 auto; }
|
|
92
|
+
.pt-title { font-size: 28px; font-weight: 700; letter-spacing: -0.01em; margin: 0 0 4px; }
|
|
93
|
+
.pt-subtitle { margin: 0 0 24px; font-size: 15px; color: var(--dsw-alias-label-secondary, #666); }
|
|
94
|
+
.pt-btn { border: 1px solid var(--dsw-alias-border-l2, #d8d8d8); background: var(--dsw-alias-bg-layer-2, #fff);
|
|
95
|
+
color: var(--dsw-alias-label-primary, #1a1a1a); border-radius: 12px; padding: 9px 18px; font-size: 15px;
|
|
96
|
+
font-weight: 550; cursor: pointer; transition: filter 150ms, background 150ms; white-space: nowrap; }
|
|
97
|
+
.pt-btn:hover { filter: brightness(0.96); }
|
|
98
|
+
.pt-btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
|
99
|
+
.pt-btn-primary { background: var(--dsw-alias-accent, #4f7cff); border-color: var(--dsw-alias-accent, #4f7cff); color: #fff; }
|
|
100
|
+
.pt-btn-ghost { background: transparent; border-color: transparent; color: var(--dsw-alias-label-secondary, #666); }
|
|
101
|
+
.pt-card { background: var(--dsw-alias-bg-layer-1, #fff); border: 1px solid var(--dsw-alias-border-l2, #e2e2e2);
|
|
102
|
+
border-radius: 16px; padding: 18px 20px; }
|
|
103
|
+
.pt-card-row { display: flex; align-items: center; gap: 14px; justify-content: space-between; }
|
|
104
|
+
.pt-section { margin-top: 26px; }
|
|
105
|
+
.pt-section-title { font-size: 19px; font-weight: 650; margin: 0 0 12px; letter-spacing: -0.01em; }
|
|
106
|
+
.pt-meta { font-size: 13px; color: var(--dsw-alias-label-caption, #888); }
|
|
107
|
+
.pt-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 14px; margin-top: 14px; }
|
|
108
|
+
.pt-option { display: flex; gap: 12px; align-items: flex-start; padding: 16px 18px; cursor: pointer; }
|
|
109
|
+
.pt-option:hover { border-color: var(--dsw-alias-border-l1, #b8b8b8); }
|
|
110
|
+
.pt-check { width: 17px; height: 17px; margin-top: 3px; accent-color: var(--dsw-alias-accent, #4f7cff); flex: none; }
|
|
111
|
+
.pt-option-title { font-size: 15.5px; font-weight: 650; margin: 0 0 4px; }
|
|
112
|
+
.pt-option-desc { font-size: 14px; color: var(--dsw-alias-label-secondary, #666); margin: 0; line-height: 1.55; }
|
|
113
|
+
.pt-tag { display: inline-flex; align-items: center; border-radius: 999px; padding: 2px 10px; font-size: 12px;
|
|
114
|
+
font-weight: 600; color: var(--dsw-alias-label-secondary, #666); background: var(--dsw-alias-bg-layer-0, #f4f4f4);
|
|
115
|
+
border: 1px solid var(--dsw-alias-border-l2, #e2e2e2); margin-right: 6px; }
|
|
116
|
+
.pt-badge { display: inline-flex; align-items: center; gap: 6px; border-radius: 999px; padding: 3px 11px;
|
|
117
|
+
font-size: 12.5px; font-weight: 600; line-height: 1.4; border: 1px solid var(--dsw-alias-border-l2, #ddd); }
|
|
118
|
+
.pt-badge::before { content: ''; width: 7px; height: 7px; border-radius: 50%; background: currentColor; opacity: .85; }
|
|
119
|
+
.pt-badge-running { color: #b45309; }
|
|
120
|
+
.pt-badge-gated { color: #2563eb; }
|
|
121
|
+
.pt-badge-done { color: #15803d; }
|
|
122
|
+
.pt-badge-idle { color: var(--dsw-alias-label-secondary, #777); }
|
|
123
|
+
.pt-input { width: 100%; border: 1px solid var(--dsw-alias-border-l2, #d8d8d8); border-radius: 12px;
|
|
124
|
+
background: var(--dsw-alias-bg-layer-2, #fff); color: var(--dsw-alias-label-primary, #1a1a1a);
|
|
125
|
+
padding: 10px 14px; font-size: 15px; outline: none; }
|
|
126
|
+
.pt-input:focus, .pt-rich:focus { border-color: var(--dsw-alias-accent, #4f7cff); }
|
|
127
|
+
.pt-textarea { min-height: 90px; resize: vertical; }
|
|
128
|
+
.pt-rich { min-height: 180px; max-height: 320px; overflow-y: auto; padding: 12px 14px; }
|
|
129
|
+
.pt-rich:empty::before { content: attr(data-placeholder); color: var(--dsw-alias-label-caption, #999); }
|
|
130
|
+
.pt-actions { display: flex; gap: 10px; margin-top: 18px; justify-content: flex-end; }
|
|
131
|
+
|
|
132
|
+
.pt-credits { text-align: center; font-size: 12.5px; color: var(--dsw-alias-label-caption, #999); margin-top: 12px; }
|
|
133
|
+
.pt-credits a { color: var(--dsw-alias-label-secondary, #777); text-decoration: none; }
|
|
134
|
+
.pt-credits a:hover { text-decoration: underline; }
|
|
135
|
+
.pt-empty { border: 1.5px dashed var(--dsw-alias-border-l2, #d5d5d5); border-radius: 16px; padding: 48px 28px;
|
|
136
|
+
text-align: center; color: var(--dsw-alias-label-secondary, #777); }
|
|
137
|
+
.pt-empty-fig { width: 200px; height: 200px; margin: 0 auto 18px; overflow: hidden;
|
|
138
|
+
-webkit-mask-image: radial-gradient(ellipse closest-side, #000 52%, transparent 76%);
|
|
139
|
+
mask-image: radial-gradient(ellipse closest-side, #000 52%, transparent 76%); }
|
|
140
|
+
.pt-empty-img { width: 280px; height: 280px; display: block; object-fit: cover;
|
|
141
|
+
transform: translate(-40px, -20px); user-select: none; pointer-events: none; }
|
|
142
|
+
.pt-empty-title { font-size: 16px; font-weight: 650; color: var(--dsw-alias-label-primary, #1a1a1a); margin: 0 0 6px; }
|
|
143
|
+
.pt-empty-hint { font-size: 14px; margin: 0; }
|
|
144
|
+
.pt-file { display: flex; align-items: center; justify-content: center; border: 1.5px dashed
|
|
145
|
+
var(--dsw-alias-border-l1, #b8b8b8); border-radius: 14px; padding: 22px 16px; cursor: pointer; text-align: center;
|
|
146
|
+
transition: border-color 150ms; }
|
|
147
|
+
.pt-file:hover { border-color: var(--dsw-alias-accent, #4f7cff); }
|
|
148
|
+
.pt-file-name { font-weight: 550; margin: 6px 0 0; }
|
|
149
|
+
.pt-error { font-size: 13.5px; color: #b91c1c; background: #fee2e2; border: 1px solid #fecaca; border-radius: 12px;
|
|
150
|
+
padding: 9px 13px; margin: 12px 0 0; }
|
|
151
|
+
.pt-warn { font-size: 13.5px; color: #b45309; background: #fef3c7; border: 1px solid #fde68a; border-radius: 12px;
|
|
152
|
+
padding: 9px 13px; margin: 12px 0 0; }
|
|
153
|
+
.pt-code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; line-height: 1.55;
|
|
154
|
+
white-space: pre-wrap; word-break: break-word; background: var(--dsw-alias-bg-layer-0, #f4f4f4);
|
|
155
|
+
border: 1px solid var(--dsw-alias-border-l2, #e2e2e2); border-radius: 12px; padding: 12px 14px; margin: 10px 0 0; }
|
|
156
|
+
.pt-detail { font-size: 13.5px; color: var(--dsw-alias-label-secondary, #777); }
|
|
157
|
+
.pt-prompts { margin-top: 10px; }
|
|
158
|
+
|
|
159
|
+
/* ---- 阶段条 ---- */
|
|
160
|
+
.pt-stages { display: flex; gap: 8px; margin: 16px 0 4px; }
|
|
161
|
+
.pt-stage { flex: 1; border: 1px solid var(--dsw-alias-border-l2, #d8d8d8); background: var(--dsw-alias-bg-layer-2, #fff);
|
|
162
|
+
border-radius: 12px; padding: 10px 12px; cursor: pointer; font-size: 13.5px; font-weight: 600; text-align: left;
|
|
163
|
+
color: var(--dsw-alias-label-secondary, #666); transition: border-color 150ms; }
|
|
164
|
+
.pt-stage:hover { border-color: var(--dsw-alias-border-l1, #aaa); }
|
|
165
|
+
.pt-stage.active { border-color: var(--dsw-alias-accent, #4f7cff); color: var(--dsw-alias-label-primary, #1a1a1a); }
|
|
166
|
+
.pt-stage .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 7px;
|
|
167
|
+
background: var(--dsw-alias-border-l2, #ccc); }
|
|
168
|
+
.pt-stage.gated .dot { background: #2563eb; }
|
|
169
|
+
.pt-stage.done .dot { background: #15803d; }
|
|
170
|
+
.pt-stage.running .dot { background: #b45309; }
|
|
171
|
+
.pt-stage .cnt { float: right; font-size: 11.5px; opacity: .7; font-weight: 500; }
|
|
172
|
+
|
|
173
|
+
/* ---- 信息条 ---- */
|
|
174
|
+
.pt-infobar { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
|
|
175
|
+
.pt-infobar h1 { font-size: 22px; font-weight: 700; margin: 0; letter-spacing: -0.01em; }
|
|
176
|
+
.pt-rename-input { font-size: 20px; font-weight: 700; padding: 4px 10px; border-radius: 10px; }
|
|
177
|
+
|
|
178
|
+
/* ---- 折叠面板(讨论 / 日志) ---- */
|
|
179
|
+
.pt-fold { margin-top: 22px; border-top: 1px solid var(--dsw-alias-border-l2, #e2e2e2); padding-top: 14px; }
|
|
180
|
+
.pt-fold-head { display: flex; align-items: center; justify-content: space-between; width: 100%;
|
|
181
|
+
border: 0; background: transparent; cursor: pointer; padding: 0; font-size: 15.5px; font-weight: 650;
|
|
182
|
+
color: var(--dsw-alias-label-primary, #1a1a1a); }
|
|
183
|
+
.pt-fold-head .chev { opacity: .6; font-size: 12px; }
|
|
184
|
+
.pt-fold-body { margin-top: 12px; }
|
|
185
|
+
|
|
186
|
+
/* ---- 图片网格 ---- */
|
|
187
|
+
.pt-img-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 14px; margin-top: 14px; }
|
|
188
|
+
.pt-batch { }
|
|
189
|
+
.pt-batch-head { display: flex; align-items: center; justify-content: space-between; cursor: pointer;
|
|
190
|
+
margin: 18px 0 8px; }
|
|
191
|
+
.pt-batch-title { font-size: 15px; font-weight: 600; color: var(--dsw-alias-label-primary, #1a1a1a); }
|
|
192
|
+
.pt-batch-head .chev { opacity: .6; font-size: 12px; }
|
|
193
|
+
.pt-batch:first-of-type .pt-batch-head { margin-top: 4px; }
|
|
194
|
+
.pt-img-card { margin: 0; padding: 10px; background: var(--dsw-alias-bg-layer-1, #fff);
|
|
195
|
+
border: 1px solid var(--dsw-alias-border-l2, #e2e2e2); border-radius: 14px; position: relative; }
|
|
196
|
+
.pt-img { width: 100%; height: auto; border-radius: 9px; display: block; }
|
|
197
|
+
.pt-img-card figcaption { margin-top: 8px; display: flex; align-items: center; justify-content: space-between;
|
|
198
|
+
font-size: 12px; color: var(--dsw-alias-label-secondary, #666); }
|
|
199
|
+
.pt-dl { color: var(--dsw-alias-accent, #4f7cff); text-decoration: none; font-weight: 600; font-size: 12.5px; }
|
|
200
|
+
.pt-img-card figcaption { display: flex; flex-direction: column; gap: 6px; align-items: stretch; }
|
|
201
|
+
.pt-fig-title { font-size: 13px; font-weight: 600; line-height: 1.4; color: var(--dsw-alias-label-primary, #1a1a1a); overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
|
202
|
+
.pt-fig-row { display: flex; justify-content: flex-end; }
|
|
203
|
+
.pt-tabbar { display: flex; gap: 8px; margin-bottom: 14px; }
|
|
204
|
+
.pt-tab { padding: 7px 16px; border-radius: 999px; border: 1px solid var(--dsw-alias-border-l2, #d8d8d8);
|
|
205
|
+
background: transparent; cursor: pointer; font-size: 14px; font-weight: 550; color: var(--dsw-alias-label-secondary, #666); }
|
|
206
|
+
.pt-tab.active { background: var(--dsw-alias-bg-layer-2, #fff); color: var(--dsw-alias-label-primary, #1a1a1a);
|
|
207
|
+
border-color: var(--dsw-alias-accent, #4f7cff); }
|
|
208
|
+
.pt-field { margin-bottom: 14px; }
|
|
209
|
+
.pt-field-label { display: block; font-size: 13.5px; font-weight: 550; color: var(--dsw-alias-label-secondary, #666);
|
|
210
|
+
margin-bottom: 6px; }
|
|
211
|
+
.pt-modal-backdrop { position: fixed; inset: 0; z-index: 60; background: rgba(0,0,0,.38);
|
|
212
|
+
display: flex; align-items: center; justify-content: center; padding: 24px; }
|
|
213
|
+
.pt-modal { background: var(--dsw-alias-bg-layer-1, #fff); border: 1px solid var(--dsw-alias-border-l2, #ddd);
|
|
214
|
+
border-radius: 16px; width: min(600px, 100%); max-height: 80vh; display: flex; flex-direction: column;
|
|
215
|
+
box-shadow: 0 18px 50px rgba(0,0,0,.22); }
|
|
216
|
+
.pt-modal-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px;
|
|
217
|
+
padding: 18px 20px 14px; border-bottom: 1px solid var(--dsw-alias-border-l2, #ececec); }
|
|
218
|
+
.pt-modal-body { overflow-y: auto; padding: 14px 20px 20px; }
|
|
219
|
+
.pt-modal-wide { width: min(760px, 100%); }
|
|
220
|
+
.pt-preview-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(132px, 1fr)); gap: 10px;
|
|
221
|
+
max-height: 56vh; overflow-y: auto; }
|
|
222
|
+
.pt-preview-item { border: 1px solid var(--dsw-alias-border-l2, #ddd); border-radius: 10px; overflow: hidden;
|
|
223
|
+
cursor: pointer; transition: border-color 120ms; position: relative; background: var(--dsw-alias-bg-layer-1, #fff); }
|
|
224
|
+
.pt-preview-item:hover { border-color: var(--dsw-alias-border-l1, #aaa); }
|
|
225
|
+
.pt-preview-item.sel { border-color: var(--dsw-alias-state-business-primary, #4f7cff);
|
|
226
|
+
box-shadow: 0 0 0 1px var(--dsw-alias-state-business-primary, #4f7cff); }
|
|
227
|
+
.pt-preview-item img { width: 100%; height: 88px; object-fit: cover; display: block;
|
|
228
|
+
background: var(--dsw-alias-bg-layer-0, #f4f4f2); }
|
|
229
|
+
.pt-preview-item .nm { font-size: 12px; font-weight: 600; padding: 5px 8px; color: var(--dsw-alias-label-primary, #1a1a1a); }
|
|
230
|
+
.pt-preview-item .ai-chip { position: absolute; top: 4px; right: 4px; font-size: 10px; font-weight: 700;
|
|
231
|
+
background: var(--dsw-alias-state-business-primary, #4f7cff); color: #fff; border-radius: 999px; padding: 1px 6px; }
|
|
232
|
+
.pt-layout-changed { color: #b45309; }
|
|
233
|
+
|
|
234
|
+
.pt-modal-close { width: 30px; height: 30px; border-radius: 50%; border: 1px solid var(--dsw-alias-border-l2, #ddd);
|
|
235
|
+
background: transparent; color: var(--dsw-alias-label-secondary, #777); font-size: 16px; line-height: 1;
|
|
236
|
+
display: inline-flex; align-items: center; justify-content: center; cursor: pointer; flex: none;
|
|
237
|
+
transition: background 150ms, color 150ms, transform 150ms; }
|
|
238
|
+
.pt-modal-close:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,.05));
|
|
239
|
+
color: var(--dsw-alias-label-primary, #1a1a1a); }
|
|
240
|
+
.pt-modal-close:active { transform: scale(.94); }
|
|
241
|
+
|
|
242
|
+
.pt-detail-h { font-size: 13px; font-weight: 650; margin: 0 0 6px; color: var(--dsw-alias-label-primary, #1a1a1a); }
|
|
243
|
+
.pt-list { margin: 0 0 12px; padding-left: 20px; font-size: 14px; line-height: 1.6; color: var(--dsw-alias-label-secondary, #555); }
|
|
244
|
+
.pt-quote { margin: 0 0 12px; padding: 10px 14px; border-left: 3px solid var(--dsw-alias-brand-primary, #4f7cff);
|
|
245
|
+
background: var(--dsw-alias-bg-layer-0, #f4f4f2); border-radius: 0 10px 10px 0; font-size: 13.5px; line-height: 1.65;
|
|
246
|
+
color: var(--dsw-alias-label-secondary, #555); }
|
|
247
|
+
.pt-log { font-size: 12px; line-height: 1.65; color: var(--dsw-alias-label-caption, #777); max-height: 260px;
|
|
248
|
+
overflow-y: auto; background: var(--dsw-alias-bg-layer-0, #f4f4f4); border-radius: 12px; padding: 10px 12px;
|
|
249
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; word-break: break-word; }
|
|
250
|
+
`
|
|
251
|
+
|
|
252
|
+
// ---------- 国际化(pomasa 同款机制:字典 + store + t() + LangSwitch) ----------
|
|
253
|
+
const I18N = {
|
|
254
|
+
zh: {
|
|
255
|
+
open: '打开 Pictor', close: '收起 Pictor',
|
|
256
|
+
tagline: '文档转信息图', newProject: '新建项目', newShort: '新建', noProjects: '暂无项目',
|
|
257
|
+
navSettings: '设置画图模型',
|
|
258
|
+
settingsTitle: '设置', settingsSubtitle: '两个模型:推理走 dsh 默认模型(extract/advise 随之);这里是画图模型。',
|
|
259
|
+
fieldProvider: '生图 provider', fieldModel: '模型名', fieldAspect: '画面比例', fieldKey: 'API 密钥',
|
|
260
|
+
fieldExtra: '额外请求参数(JSON,可选)', extraHint: '例如:{\"parameters\":{\"prompt_extend\":true}};会深合并进请求体。',
|
|
261
|
+
keySet: '(已配置)', keyPlaceholderSet: '已配置,留空不变', keyPlaceholder: '经 dsh 凭据子系统保存',
|
|
262
|
+
keyStorageFile: '当前 profile 未装凭据服务,密钥将以明文保存在 ~/.pictor/pictor-config.json。',
|
|
263
|
+
back: '返回', saveTest: '保存并测试', saving: '保存中…', savedTest: '已保存,测试 ',
|
|
264
|
+
fieldBaseUrl: '接口地址', testOk: '通过',
|
|
265
|
+
sPrepared: '未就位', sRunning: '执行中', sStruct: '待选结构', sAdvice: '待选方案', sDone: '已出图',
|
|
266
|
+
stepMeta: '确认信息', sMeta: '待确认信息',
|
|
267
|
+
metaTitle: '文档标题', metaSummary: '文档摘要', projectTitle: '项目标题',
|
|
268
|
+
confirmMeta: '确认信息并开始提取', metaRunning: '正在提炼文档信息…',
|
|
269
|
+
metaRunningHint: '会话正在读取文档并提炼标题与摘要,稍候可在上方确认。',
|
|
270
|
+
waitMeta: '等待信息确认', waitMetaHint: '请先完成「确认信息」步骤。',
|
|
271
|
+
deadHint: '会话当前未在运行:可在讨论面板发一句话让它继续。',stepExtract: '提取结构', stepAdvise: '方案设计', stepRender: '渲染出图',
|
|
272
|
+
newTitle: '新建项目', newSubtitle: '一份文档转成一组信息图。项目名取文档首行,随时可改。',
|
|
273
|
+
tabUpload: '上传文件', tabPaste: '粘贴内容',
|
|
274
|
+
pickFile: '点击选择本地文件', fileHint: '支持 md / txt / docx / pdf / 图片。文件原样进入项目目录,读取与转换交给会话。',
|
|
275
|
+
richHint: '从 Word 或网页粘贴,格式将保留;落盘为消毒后的 HTML,再由会话规整成 Markdown。',
|
|
276
|
+
richPlaceholder: '从 Word 或网页粘贴内容(格式将保留)…',
|
|
277
|
+
createProject: '创建项目', creating: '创建中…',
|
|
278
|
+
rename: '改名', del: '删除', metaTime: '创建于 {c} · 更新于 {u}',
|
|
279
|
+
delConfirm: '删除项目「{t}」?其目录与全部产物将一并移除。',
|
|
280
|
+
confirmStructures: '确认所选,进入方案设计',
|
|
281
|
+
viewPrompt: '查看完整 prompt', hidePrompt: '收起完整 prompt',
|
|
282
|
+
renderSelected: '渲染所选方案', rendering: '渲染中…', renderFailed: '失败', download: '下载',
|
|
283
|
+
batchTitle: '渲染于 {t}', batchN: '{n} 张', batchLegacy: '已渲图',
|
|
284
|
+
emptyProjects: '还没有项目',
|
|
285
|
+
creditsPre: '受 ', creditsPost: ' 开源项目启发,致谢 宝玉。',
|
|
286
|
+
emptyProjectsHint: '把一份文档变成一组信息图:点左上「新建项目」,上传文件或粘贴内容,会话会完成提取、方案与出图。',
|
|
287
|
+
emptyExtract: '正在提取结构…', emptyExtractWait: '等待结构提取',
|
|
288
|
+
emptyExtractHint: '会话正在分析文档并提取结构,自动推进;完成后这里会列出候选结构供你选择。',
|
|
289
|
+
pendingExtract: '结构提取中', pendingExtractHint: '方案在结构提取完成并确认后生成,请先完成提取。',
|
|
290
|
+
waitConfirmStructures: '等待结构确认', waitConfirmStructuresHint: '结构已提取,请回到提取步骤勾选并确认。',
|
|
291
|
+
emptyAdviceRunning: '正在生成方案…',
|
|
292
|
+
emptyAdvice: '等待方案', emptyAdviceHint: '方案生成完成后即可选择并渲染。',
|
|
293
|
+
emptyRender: '尚未出图', emptyRenderHint: '在方案步骤勾选方案并渲染,这里会展示生成的信息图。',
|
|
294
|
+
log: '会话日志', logEmpty: '暂无日志(dsh 会话或已结束)。',
|
|
295
|
+
viewSource: '回看输入文档', hideSource: '收起原文',
|
|
296
|
+
structFallback: '结构', details: '详情', collapse: '收起',
|
|
297
|
+
descriptionLabel: '描述', keyElementsLabel: '关键要素', relationshipsLabel: '关系',
|
|
298
|
+
excerptLabel: '原文摘录', locationLabel: '出处', complexityLabel: '复杂度',
|
|
299
|
+
potentialLabel: '可视化价值', addElement: '+ 添加要素', emptyExtractWaitHint: '结构提取尚未开始。',
|
|
300
|
+
imgKeyHint: '生图模型未配置:请先到「设置:画图模型」配置后再渲染。', sourceLabel: '来源',
|
|
301
|
+
edit: '编辑', save: '保存', cancel: '取消', editStruct: '编辑结构', fieldTitle: '标题', fieldSummary: '概要',
|
|
302
|
+
select: '选择', selectAll: '全选', deselectAll: '取消全选', selectCount: '已选 {s} / {t}',
|
|
303
|
+
layoutLabel: '布局', styleLabel: '风格', metaConfirmedHint: '确认后作为项目名与提取依据,可编辑。',
|
|
304
|
+
chooseLayout: '选择布局', chooseStyle: '选择风格', resetToAi: '回到 AI 推荐',
|
|
305
|
+
layoutChanged: '·已改', aiSuggestion: 'AI 推荐',
|
|
306
|
+
close: '关闭',
|
|
307
|
+
discussion: '讨论', discussHint: '对项目会话自由说话:补充参考、要求搜索、调整方向。你的决定会在同一会话里接着执行。',
|
|
308
|
+
discussPlaceholder: '例如:先不要继续,帮我查一下这篇文章提到的背景…',
|
|
309
|
+
send: '发送', langLabel: '语言', loading: '加载中…',
|
|
310
|
+
},
|
|
311
|
+
en: {
|
|
312
|
+
open: 'Open Pictor', close: 'Collapse Pictor',
|
|
313
|
+
tagline: 'Document to infographics', newProject: 'New project', newShort: 'New', noProjects: 'No projects yet',
|
|
314
|
+
navSettings: 'Settings image model',
|
|
315
|
+
settingsTitle: 'Settings', settingsSubtitle: 'Two models: reasoning uses the DSH default model (extract/advise); image model is configured here.',
|
|
316
|
+
fieldProvider: 'Image provider', fieldModel: 'Model name', fieldAspect: 'Aspect ratio', fieldKey: 'API key',
|
|
317
|
+
fieldExtra: 'Extra request params (JSON, optional)', extraHint: 'e.g. {\"parameters\":{\"prompt_extend\":true}}; deep-merged into the request body.',
|
|
318
|
+
keySet: '(set)', keyPlaceholderSet: 'Set — leave blank to keep', keyPlaceholder: 'Stored via the dsh credentials subsystem',
|
|
319
|
+
keyStorageFile: 'This profile has no credentials service; the key will be stored in plain text in ~/.pictor/pictor-config.json.',
|
|
320
|
+
back: 'Back', saveTest: 'Save & test', saving: 'Saving…', savedTest: 'Saved; test ',
|
|
321
|
+
fieldBaseUrl: 'Base URL', testOk: 'ok',
|
|
322
|
+
sPrepared: 'Preparing', sRunning: 'Running', sStruct: 'Pick structures', sAdvice: 'Pick proposals', sDone: 'Rendered',
|
|
323
|
+
stepMeta: 'Confirm info', sMeta: 'Info waiting',
|
|
324
|
+
metaTitle: 'Document title', metaSummary: 'Document summary', projectTitle: 'Project title',
|
|
325
|
+
confirmMeta: 'Confirm and start extraction', metaRunning: 'Extracting document info…',
|
|
326
|
+
metaRunningHint: 'The session is reading the document and deriving a title and summary; confirm them here.',
|
|
327
|
+
waitMeta: 'Awaiting info confirmation', waitMetaHint: 'Finish the Confirm info step first.',
|
|
328
|
+
deadHint: 'The session is not running: send a message in Discussion to continue.',stepExtract: 'Extract', stepAdvise: 'Advise', stepRender: 'Render',
|
|
329
|
+
newTitle: 'New project', newSubtitle: 'One document becomes a set of infographics. The project name comes from the first line; rename anytime.',
|
|
330
|
+
tabUpload: 'Upload', tabPaste: 'Paste',
|
|
331
|
+
pickFile: 'Choose a local file', fileHint: 'md / txt / docx / pdf / images. The file lands as-is in the project; reading & conversion is done by the session.',
|
|
332
|
+
richHint: 'Paste from Word or the web; formatting is kept. Stored as sanitized HTML; the session normalizes it to Markdown.',
|
|
333
|
+
richPlaceholder: 'Paste from Word or the web (formatting preserved)…',
|
|
334
|
+
createProject: 'Create project', creating: 'Creating…',
|
|
335
|
+
rename: 'Rename', del: 'Delete', metaTime: 'Created {c} · Updated {u}',
|
|
336
|
+
delConfirm: 'Delete project «{t}»? Its directory and all outputs will be removed.',
|
|
337
|
+
confirmStructures: 'Confirm selection, continue to advise',
|
|
338
|
+
viewPrompt: 'View full prompt', hidePrompt: 'Hide prompt',
|
|
339
|
+
renderSelected: 'Render selected', rendering: 'Rendering…', renderFailed: 'Failed', download: 'Download',
|
|
340
|
+
batchTitle: 'Rendered at {t}', batchN: '{n} imgs', batchLegacy: 'Rendered',
|
|
341
|
+
emptyProjects: 'No projects yet',
|
|
342
|
+
creditsPre: 'Inspired by the open-source ', creditsPost: '. Thanks, 宝玉.',
|
|
343
|
+
emptyProjectsHint: 'Turn a document into a set of infographics: New project → upload or paste, then the session extracts, advises and renders.',
|
|
344
|
+
emptyExtract: 'Extracting structures…', emptyExtractWait: 'Awaiting extraction',
|
|
345
|
+
emptyExtractHint: 'The session is analyzing the document and extracting structures automatically; candidates will appear here for you to pick.',
|
|
346
|
+
pendingExtract: 'Extracting structures', pendingExtractHint: 'Proposals come after structures are extracted and confirmed — finish the Extract step first.',
|
|
347
|
+
waitConfirmStructures: 'Awaiting structure confirmation', waitConfirmStructuresHint: 'Structures are ready — confirm them in the Extract step.',
|
|
348
|
+
emptyAdviceRunning: 'Generating proposals…',
|
|
349
|
+
emptyAdvice: 'Awaiting proposals', emptyAdviceHint: 'Pick a proposal and render it once ready.',
|
|
350
|
+
emptyRender: 'Nothing rendered yet', emptyRenderHint: 'Pick proposals in the Advise step and render them here.',
|
|
351
|
+
log: 'Session log', logEmpty: 'No log yet (session ended or never ran).',
|
|
352
|
+
viewSource: 'View source', hideSource: 'Hide source',
|
|
353
|
+
structFallback: 'Structure', details: 'Details', collapse: 'Collapse',
|
|
354
|
+
descriptionLabel: 'Description', keyElementsLabel: 'Key elements', relationshipsLabel: 'Relationships',
|
|
355
|
+
excerptLabel: 'Source excerpt', locationLabel: 'Location', complexityLabel: 'Complexity',
|
|
356
|
+
potentialLabel: 'Value', addElement: '+ Add element', emptyExtractWaitHint: 'Extraction has not started.',
|
|
357
|
+
imgKeyHint: 'Image model not configured — configure it in Settings → image model first.', sourceLabel: 'source',
|
|
358
|
+
edit: 'Edit', save: 'Save', cancel: 'Cancel', editStruct: 'Edit structure', fieldTitle: 'Title', fieldSummary: 'Summary',
|
|
359
|
+
select: 'Select', selectAll: 'Select all', deselectAll: 'Deselect all', selectCount: '{s} / {t} selected',
|
|
360
|
+
layoutLabel: 'Layout', styleLabel: 'Style', metaConfirmedHint: 'Confirmed as the project name and extraction basis; editable.',
|
|
361
|
+
chooseLayout: 'Choose layout', chooseStyle: 'Choose style', resetToAi: 'Reset to AI',
|
|
362
|
+
layoutChanged: '· edited', aiSuggestion: 'AI',
|
|
363
|
+
close: 'Close',
|
|
364
|
+
discussion: 'Discussion', discussHint: 'Talk to the project session: add context, ask for research, adjust direction. Your decisions continue it in the same session.',
|
|
365
|
+
discussPlaceholder: 'e.g. Hold on — research the background this article mentions…',
|
|
366
|
+
send: 'Send', langLabel: 'Language', loading: 'Loading…',
|
|
367
|
+
},
|
|
368
|
+
}
|
|
369
|
+
let savedLang = 'zh'
|
|
370
|
+
try { savedLang = typeof localStorage !== 'undefined' ? localStorage.getItem('pictor-lang') : null } catch { /* 沙箱可能没有 storage */ }
|
|
371
|
+
const langStore = {
|
|
372
|
+
val: savedLang === 'en' ? 'en' : 'zh',
|
|
373
|
+
subs: new Set(),
|
|
374
|
+
emit() { for (const f of this.subs) f() },
|
|
375
|
+
set(v) { this.val = v === 'en' ? 'en' : 'zh'; try { localStorage.setItem('pictor-lang', this.val) } catch { /* ignore */ } this.emit() },
|
|
376
|
+
subscribe(f) { this.subs.add(f); return () => { this.subs.delete(f) } },
|
|
377
|
+
}
|
|
378
|
+
function useLang() {
|
|
379
|
+
const [v, setV] = React.useState(langStore.val)
|
|
380
|
+
React.useEffect(() => langStore.subscribe(() => setV(langStore.val)), [])
|
|
381
|
+
return v
|
|
382
|
+
}
|
|
383
|
+
function t(key, vars) {
|
|
384
|
+
let text = (I18N[langStore.val] && I18N[langStore.val][key]) || I18N.zh[key] || key
|
|
385
|
+
if (vars) { for (const k of Object.keys(vars)) text = text.replace('{' + k + '}', String(vars[k])) }
|
|
386
|
+
return text
|
|
387
|
+
}
|
|
388
|
+
function LangSwitch() {
|
|
389
|
+
const lang = useLang()
|
|
390
|
+
const opt = (code, label) => h('button', {
|
|
391
|
+
className: 'pt-lang-opt' + (lang === code ? ' on' : ''),
|
|
392
|
+
onClick: () => langStore.set(code),
|
|
393
|
+
}, label)
|
|
394
|
+
return h('div', { className: 'pt-lang' },
|
|
395
|
+
h('span', { className: 'pt-lang-label' }, t('langLabel')),
|
|
396
|
+
h('div', { className: 'pt-lang-opts' }, opt('zh', '中文'), opt('en', 'English')))
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ---------- 工具 ----------
|
|
400
|
+
|
|
401
|
+
function rpc(ctx, endpoint, payload) {
|
|
402
|
+
// 服务端 schema 要求 payload 字段必须存在(对象);缺省补 {},否则序列化丢键被拒。
|
|
403
|
+
return ctx.connection.rpc.call(RPC, endpoint, payload === undefined ? {} : payload).then((result) => {
|
|
404
|
+
if (result && result.ok === false) {
|
|
405
|
+
const msg = result.error && result.error.message ? result.error.message : String(result.error || 'RPC 调用失败')
|
|
406
|
+
throw new Error(msg)
|
|
407
|
+
}
|
|
408
|
+
return result && result.ok === true ? result.value : result
|
|
409
|
+
})
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** 阶段 -> 左中右三步状态(pending/running/gated/done)。 */
|
|
413
|
+
function deriveSteps(stage, hasMeta) {
|
|
414
|
+
const s = (state) => ({ state })
|
|
415
|
+
switch (stage) {
|
|
416
|
+
case 'prepared':
|
|
417
|
+
return [s('running'), s('pending'), s('pending'), s('pending')]
|
|
418
|
+
case 'running':
|
|
419
|
+
return hasMeta
|
|
420
|
+
? [s('done'), s('running'), s('pending'), s('pending')]
|
|
421
|
+
: [s('running'), s('pending'), s('pending'), s('pending')]
|
|
422
|
+
case 'gated:meta':
|
|
423
|
+
return [s('gated'), s('pending'), s('pending'), s('pending')]
|
|
424
|
+
case 'gated:structure':
|
|
425
|
+
return [s('done'), s('gated'), s('pending'), s('pending')]
|
|
426
|
+
case 'gated:advice':
|
|
427
|
+
return [s('done'), s('done'), s('gated'), s('pending')]
|
|
428
|
+
case 'done':
|
|
429
|
+
return [s('done'), s('done'), s('done'), s('done')]
|
|
430
|
+
default:
|
|
431
|
+
return [s('pending'), s('pending'), s('pending'), s('pending')]
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const STEP_KEYS = ['meta', 'extract', 'advise', 'render']
|
|
436
|
+
const STEP_T = { meta: 'stepMeta', extract: 'stepExtract', advise: 'stepAdvise', render: 'stepRender' }
|
|
437
|
+
|
|
438
|
+
function stageBadge(stage) {
|
|
439
|
+
const map = {
|
|
440
|
+
prepared: [t('sPrepared'), 'pct-badge-idle', 'pt-badge-idle'],
|
|
441
|
+
running: [t('sRunning'), '', 'pt-badge-running'],
|
|
442
|
+
'gated:meta': [t('sMeta'), '', 'pt-badge-gated'],
|
|
443
|
+
'gated:structure': [t('sStruct'), '', 'pt-badge-gated'],
|
|
444
|
+
'gated:advice': [t('sAdvice'), '', 'pt-badge-gated'],
|
|
445
|
+
done: [t('sDone'), '', 'pt-badge-done'],
|
|
446
|
+
}
|
|
447
|
+
const [label, , cls] = map[stage] || [stage, '', 'pt-badge-idle']
|
|
448
|
+
return h('span', { className: 'pt-badge ' + cls }, label)
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// ---------- 粘贴消毒(客户端):白名单子集,粘贴与落盘各过一次 ----------
|
|
452
|
+
|
|
453
|
+
const ALLOWED_TAGS = new Set(['P', 'DIV', 'BR', 'B', 'STRONG', 'I', 'EM', 'U', 'A', 'UL', 'OL', 'LI',
|
|
454
|
+
'BLOCKQUOTE', 'SPAN', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'TABLE', 'THEAD', 'TBODY', 'TR', 'TD', 'TH', 'CODE', 'PRE'])
|
|
455
|
+
|
|
456
|
+
function sanitizePaste(html) {
|
|
457
|
+
const doc = new DOMParser().parseFromString(String(html || ''), 'text/html')
|
|
458
|
+
const walk = (node) => {
|
|
459
|
+
Array.from(node.children || []).forEach((el) => {
|
|
460
|
+
if (!ALLOWED_TAGS.has(el.tagName)) {
|
|
461
|
+
// 黑名单外壳标签(script/style/...)直接丢弃;其余未知标签改为 span。
|
|
462
|
+
el.replaceWith(...Array.from(el.childNodes).map((c) => {
|
|
463
|
+
if (c.nodeType === Node.ELEMENT_NODE) { walk(c); return c }
|
|
464
|
+
return document.createTextNode(c.nodeValue || '')
|
|
465
|
+
}))
|
|
466
|
+
} else {
|
|
467
|
+
Array.from(el.attributes).forEach((attr) => {
|
|
468
|
+
const ok = attr.name === 'href' && /^(https?:|mailto:)/i.test(attr.value)
|
|
469
|
+
|| attr.name === 'colspan' || attr.name === 'rowspan' || attr.name === 'start'
|
|
470
|
+
if (!ok) el.removeAttribute(attr.name)
|
|
471
|
+
})
|
|
472
|
+
if (el.tagName === 'A' && !/^(https?:|mailto:)/i.test(el.getAttribute('href') || '')) {
|
|
473
|
+
const txt = el.textContent || ''
|
|
474
|
+
el.replaceWith(document.createTextNode(txt))
|
|
475
|
+
return
|
|
476
|
+
}
|
|
477
|
+
walk(el)
|
|
478
|
+
}
|
|
479
|
+
})
|
|
480
|
+
}
|
|
481
|
+
walk(doc.body)
|
|
482
|
+
return doc.body.innerHTML
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// 会话创建与驱动走 dsh 的 workspace 流程(照 pomasa 的 driveSession):
|
|
486
|
+
// 宿主只出 prompt,客户端经 workspaces.connectWorkspace 建会话(自然归入
|
|
487
|
+
// 「Pictor」工作区、继承 profile 默认模型),再经 sessions.prompt 驱动。
|
|
488
|
+
async function pictorWorkspace(ctx) {
|
|
489
|
+
const ws = ctx.workspaces || (ctx.get && ctx.get('workspaces'))
|
|
490
|
+
const base = await rpc(ctx, 'config.get').then((c) => (c && c.dataRoot) || '')
|
|
491
|
+
if (!ws || typeof ws.connectWorkspace !== 'function') throw new Error('会话服务不可用(workspaces)')
|
|
492
|
+
const items = () => {
|
|
493
|
+
try {
|
|
494
|
+
const snap = ws.list && typeof ws.list.getSnapshot === 'function' ? ws.list.getSnapshot() : null
|
|
495
|
+
return snap && Array.isArray(snap.items) ? snap.items : []
|
|
496
|
+
} catch { return [] }
|
|
497
|
+
}
|
|
498
|
+
const byPath = (list) => list.find((w) => w && String(w.path || w.cwd || '') === base) || null
|
|
499
|
+
const ensureTitle = async (wid, row) => {
|
|
500
|
+
if ((row && row.title) === 'Pictor' || !base) return
|
|
501
|
+
if (typeof ws.rename === 'function') {
|
|
502
|
+
try { await ws.rename(wid, 'Pictor') } catch { /* 标题纯外观 */ }
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
const widOf = (row) => row && (row.workspaceId ?? row.id)
|
|
506
|
+
|
|
507
|
+
// 1) 严格按 path 匹配(沙箱根 = 会话 cwd = 工作区 path,必须是 dataRoot)
|
|
508
|
+
const list = items()
|
|
509
|
+
const hit = byPath(list)
|
|
510
|
+
if (hit) {
|
|
511
|
+
const wid = widOf(hit)
|
|
512
|
+
await ensureTitle(wid, hit)
|
|
513
|
+
return wid
|
|
514
|
+
}
|
|
515
|
+
// 2) 自愈:存在标题 Pictor 但 path 不符的脏行(早期探针/旧数据)→ 删掉再建正确的
|
|
516
|
+
const stale = list.find((w) => w && (w.title || '') === 'Pictor') || null
|
|
517
|
+
if (stale && typeof ws.delete === 'function') {
|
|
518
|
+
try { await ws.delete(widOf(stale)) } catch { /* 尽力 */ }
|
|
519
|
+
}
|
|
520
|
+
if (!base) throw new Error('无法创建 Pictor 工作区(dataRoot 未知)')
|
|
521
|
+
const created = await ws.create({ path: base })
|
|
522
|
+
const row = (created && created.workspaceId) ? created : byPath(items()) || null
|
|
523
|
+
if (!row) throw new Error('无法创建 Pictor 工作区')
|
|
524
|
+
const wid = widOf(row)
|
|
525
|
+
await ensureTitle(wid, row)
|
|
526
|
+
return wid
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
async function driveProjectSession(ctx, prompt) {
|
|
530
|
+
const ws = ctx.workspaces || (ctx.get && ctx.get('workspaces'))
|
|
531
|
+
const sessionsSvc = ctx.sessions || (ctx.get && ctx.get('sessions'))
|
|
532
|
+
const wid = await pictorWorkspace(ctx)
|
|
533
|
+
let sessionId
|
|
534
|
+
try { sessionId = await ws.connectWorkspace(wid) }
|
|
535
|
+
catch (e) { throw new Error('创建会话失败:' + String((e && e.message) || e)) }
|
|
536
|
+
const bound = sessionsSvc && typeof sessionsSvc.binding === 'function' ? sessionsSvc.binding(sessionId) : null
|
|
537
|
+
const sess = bound && bound.session
|
|
538
|
+
if (!sess || typeof sess.prompt !== 'function') throw new Error('会话无 prompt 通道')
|
|
539
|
+
await sess.prompt([{ type: 'text', text: String(prompt || '') }], 'queue')
|
|
540
|
+
return sessionId
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** 驱动:优先续用已记录的会话(同一项目保持连贯上下文,后面阶段可读前面
|
|
544
|
+
* 全文/结构),续用失败(dsh 重启等)再经 workspace 流程新建并回填。 */
|
|
545
|
+
async function drivePrompt(ctx, id, message) {
|
|
546
|
+
const p = await rpc(ctx, 'project.prompt', { id, message })
|
|
547
|
+
if (p && p.sessionId) {
|
|
548
|
+
const sessionsSvc = ctx.sessions || (ctx.get && ctx.get('sessions'))
|
|
549
|
+
if (sessionsSvc && typeof sessionsSvc.binding === 'function') {
|
|
550
|
+
try {
|
|
551
|
+
const bound = sessionsSvc.binding(p.sessionId)
|
|
552
|
+
const sess = bound && bound.session
|
|
553
|
+
if (sess && typeof sess.prompt === 'function') {
|
|
554
|
+
await sess.prompt([{ type: 'text', text: String(message || '') }], 'queue')
|
|
555
|
+
await rpc(ctx, 'project.attach', { id, sessionId: p.sessionId })
|
|
556
|
+
return p.sessionId
|
|
557
|
+
}
|
|
558
|
+
} catch { /* 会话对象失效则回落到新建 */ }
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
const sessionId = await driveProjectSession(ctx, p && p.prompt)
|
|
562
|
+
await rpc(ctx, 'project.attach', { id, sessionId })
|
|
563
|
+
return sessionId
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// ---------- 组件 ----------
|
|
567
|
+
|
|
568
|
+
function EmptyState(props) {
|
|
569
|
+
return h('div', { className: 'pt-empty' },
|
|
570
|
+
h('div', { className: 'pt-empty-fig' },
|
|
571
|
+
h('img', { className: 'pt-empty-img', src: '/pictor/asset/empty-state.png', alt: '' })),
|
|
572
|
+
h('p', { className: 'pt-empty-title' }, props.title),
|
|
573
|
+
h('p', { className: 'pt-empty-hint' }, props.hint || ''))
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function StageStrip({ steps, active, onPick }) {
|
|
577
|
+
return h('div', { className: 'pt-stages' },
|
|
578
|
+
steps.map((step, i) => {
|
|
579
|
+
const key = STEP_KEYS[i]
|
|
580
|
+
const cls = 'pt-stage ' + step.state + (active === key ? ' active' : '')
|
|
581
|
+
return h('button', { key, className: cls, onClick: () => onPick(key) },
|
|
582
|
+
h('span', { className: 'dot' }),
|
|
583
|
+
t(STEP_T[key]), step.count ? h('span', { className: 'cnt' }, step.count) : null)
|
|
584
|
+
}))
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** 单行文本转 HTML 的最小渲染器(React 元素输出,杜绝 innerHTML)。 */
|
|
588
|
+
function MiniMarkdown({ text }) {
|
|
589
|
+
const lines = String(text || '').split('\n')
|
|
590
|
+
return h('div', null,
|
|
591
|
+
lines.map((line, i) => {
|
|
592
|
+
if (/^#{1,3}\s/.test(line)) {
|
|
593
|
+
const level = line.match(/^#+/)[0].length
|
|
594
|
+
return h('div', { key: i, style: { fontWeight: 650, marginTop: 8 } }, line.replace(/^#+\s*/, ''))
|
|
595
|
+
}
|
|
596
|
+
if (!line.trim()) return null
|
|
597
|
+
return h('p', { key: i, style: { margin: '4px 0' } }, line)
|
|
598
|
+
}))
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// 结构类型/复杂度/可视化价值标签(枚举自 agents/11.extractor.md)
|
|
602
|
+
const STRUCT_TYPE_LABELS = {
|
|
603
|
+
zh: { network: '关系网络', hierarchy: '层次结构', 'concept-decomposition': '概念拆解', stakeholder: '利益相关方',
|
|
604
|
+
argument: '论证结构', debate: '正反对立', 'semantic-opposition': '语义对立', cycle: '循环过程', flow: '流量分配',
|
|
605
|
+
timeline: '时间序列', 'parallel-evolution': '并行演变', 'multi-dimensional': '多维评估', 'two-dimensional': '二维定位',
|
|
606
|
+
landscape: '领域全景', geographic: '地理分布' },
|
|
607
|
+
en: { network: 'Network', hierarchy: 'Hierarchy', 'concept-decomposition': 'Concept Decomposition', stakeholder: 'Stakeholder',
|
|
608
|
+
argument: 'Argument', debate: 'Debate', 'semantic-opposition': 'Semantic Opposition', cycle: 'Cycle', flow: 'Flow',
|
|
609
|
+
timeline: 'Timeline', 'parallel-evolution': 'Parallel Evolution', 'multi-dimensional': 'Multi-dimensional',
|
|
610
|
+
'two-dimensional': 'Two-dimensional', landscape: 'Landscape', geographic: 'Geographic' },
|
|
611
|
+
}
|
|
612
|
+
const STRUCT_LEVEL_LABELS = { zh: { low: '低', medium: '中', high: '高' }, en: { low: 'Low', medium: 'Medium', high: 'High' } }
|
|
613
|
+
function structTypeLabel(v) { return (STRUCT_TYPE_LABELS[langStore.val] || {})[v] || v }
|
|
614
|
+
function structLevelLabel(v) { return (STRUCT_LEVEL_LABELS[langStore.val] || {})[v] || v }
|
|
615
|
+
|
|
616
|
+
function StructuresPanel({ stage, structures, onToggle, selected, onToggleAll, disabled, onConfirm, document, seedKey, onSaveStructures, steps, running }) {
|
|
617
|
+
const gated = stage === 'gated:structure'
|
|
618
|
+
const [showSource, setShowSource] = React.useState(false)
|
|
619
|
+
const [detailId, setDetailId] = React.useState(null)
|
|
620
|
+
const [items, setItems] = React.useState(null)
|
|
621
|
+
const [editId, setEditId] = React.useState(null)
|
|
622
|
+
const [draft, setDraft] = React.useState({ title: '', description: '', keyElements: [], sourceExcerpt: '' })
|
|
623
|
+
const [saving, setSaving] = React.useState(false)
|
|
624
|
+
React.useEffect(() => { setItems(structures) }, [seedKey])
|
|
625
|
+
const list = items !== null ? items : structures
|
|
626
|
+
const allSelected = list.length > 0 && selected.length === list.length
|
|
627
|
+
const titleOf = (s, i) => s.title || s.description || t('structFallback') + ' ' + String(i + 1)
|
|
628
|
+
|
|
629
|
+
function startEdit(s) {
|
|
630
|
+
setEditId(s.id)
|
|
631
|
+
setDraft({
|
|
632
|
+
title: s.title || '',
|
|
633
|
+
description: s.description || '',
|
|
634
|
+
keyElements: Array.isArray(s.key_elements) ? s.key_elements.slice() : [],
|
|
635
|
+
sourceExcerpt: s.source_excerpt || '',
|
|
636
|
+
})
|
|
637
|
+
}
|
|
638
|
+
function setEl(i, v) { setDraft(function (d) { return { ...d, keyElements: d.keyElements.map((x, j) => (j === i ? v : x)) } }) }
|
|
639
|
+
function addEl() { setDraft(function (d) { return { ...d, keyElements: d.keyElements.concat('') } }) }
|
|
640
|
+
function rmEl(i) { setDraft(function (d) { return { ...d, keyElements: d.keyElements.filter(function (_, j) { return j !== i }) } }) }
|
|
641
|
+
async function saveEdit() {
|
|
642
|
+
setSaving(true)
|
|
643
|
+
try {
|
|
644
|
+
const updated = list.map(function (s) {
|
|
645
|
+
if (s.id === editId) {
|
|
646
|
+
return { ...s, title: draft.title, description: draft.description, key_elements: draft.keyElements, source_excerpt: draft.sourceExcerpt }
|
|
647
|
+
}
|
|
648
|
+
return s
|
|
649
|
+
})
|
|
650
|
+
await onSaveStructures(updated)
|
|
651
|
+
setEditId(null)
|
|
652
|
+
} catch (e) { console.error(e) } finally { setSaving(false) }
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
if (!list.length) {
|
|
656
|
+
const metaState = steps ? steps[0].state : 'done'
|
|
657
|
+
const extractState = steps ? steps[1].state : 'pending'
|
|
658
|
+
if (metaState !== 'done') {
|
|
659
|
+
const runningMeta = metaState === 'running'
|
|
660
|
+
return h(EmptyState, { title: runningMeta ? t('metaRunning') : t('waitMeta'), hint: runningMeta ? t('metaRunningHint') : t('waitMetaHint') })
|
|
661
|
+
}
|
|
662
|
+
if (extractState === 'running') {
|
|
663
|
+
return h(EmptyState, { title: t('emptyExtract'), hint: running === false ? t('deadHint') : t('emptyExtractHint') })
|
|
664
|
+
}
|
|
665
|
+
return h(EmptyState, { title: t('emptyExtractWait'), hint: t('emptyExtractWaitHint') })
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// 详情内容片段(弹窗内复用,也是原本地展开的内容)
|
|
669
|
+
function detailFrag(s) {
|
|
670
|
+
return [
|
|
671
|
+
Array.isArray(s.key_elements) && s.key_elements.length ? h('div', { key: 'k', style: { marginBottom: 12 } }, h('h4', { className: 'pt-detail-h' }, t('keyElementsLabel')), h('ol', { className: 'pt-list' }, s.key_elements.map((el, j) => h('li', { key: j }, el)))) : null,
|
|
672
|
+
Array.isArray(s.relationships) && s.relationships.length ? h('div', { key: 'r', style: { marginBottom: 12 } }, h('h4', { className: 'pt-detail-h' }, t('relationshipsLabel')), h('ul', { className: 'pt-list' }, s.relationships.map((rl, j) => h('li', { key: j }, rl)))) : null,
|
|
673
|
+
s.source_excerpt ? h('div', { key: 'e', style: { marginBottom: 12 } }, h('h4', { className: 'pt-detail-h' }, t('excerptLabel')), h('blockquote', { className: 'pt-quote' }, s.source_excerpt)) : null,
|
|
674
|
+
s.source_location ? h('p', { key: 'l' }, t('locationLabel') + ': ' + s.source_location) : null,
|
|
675
|
+
s.notes ? h('p', { key: 'n', className: 'pt-detail', style: { marginTop: 8 } }, s.notes) : null,
|
|
676
|
+
]
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const cards = list.map((s, i) => {
|
|
680
|
+
const idx = i
|
|
681
|
+
if (editId === s.id) {
|
|
682
|
+
return h('div', { key: s.id, className: 'pt-card' },
|
|
683
|
+
h('h3', { className: 'pt-option-title', style: { margin: 0 } }, t('editStruct')),
|
|
684
|
+
h('div', { className: 'pt-field', style: { marginTop: 10 } }, h('label', { className: 'pt-field-label' }, t('fieldTitle')), h('input', { className: 'pt-input', value: draft.title, onChange: (e) => setDraft(function (d) { return { ...d, title: e.target.value } }) })),
|
|
685
|
+
h('div', { className: 'pt-field', style: { marginTop: 10 } }, h('label', { className: 'pt-field-label' }, t('descriptionLabel')), h('textarea', { className: 'pt-input', style: { minHeight: 56 }, value: draft.description, onChange: (e) => setDraft(function (d) { return { ...d, description: e.target.value } }) })),
|
|
686
|
+
h('div', { className: 'pt-field', style: { marginTop: 10 } },
|
|
687
|
+
h('label', { className: 'pt-field-label' }, t('keyElementsLabel')),
|
|
688
|
+
draft.keyElements.map((el, j) => h('div', { key: j, style: { display: 'flex', gap: 6, marginBottom: 6 } }, h('input', { className: 'pt-input', value: el, onChange: (e) => setEl(j, e.target.value) }), h('button', { className: 'pt-btn pt-btn-ghost', onClick: () => rmEl(j) }, '×'))),
|
|
689
|
+
h('button', { className: 'pt-btn pt-btn-ghost', style: { padding: '4px 8px', fontSize: 13 }, onClick: addEl }, t('addElement'))),
|
|
690
|
+
h('div', { className: 'pt-field', style: { marginTop: 10 } }, h('label', { className: 'pt-field-label' }, t('excerptLabel')), h('textarea', { className: 'pt-input', style: { minHeight: 72 }, value: draft.sourceExcerpt, onChange: (e) => setDraft(function (d) { return { ...d, sourceExcerpt: e.target.value } }) })),
|
|
691
|
+
h('div', { className: 'pt-actions', style: { marginTop: 12, justifyContent: 'flex-start' } },
|
|
692
|
+
h('button', { className: 'pt-btn pt-btn-primary', disabled: saving, onClick: saveEdit }, saving ? t('saving') : t('save')),
|
|
693
|
+
h('button', { className: 'pt-btn', onClick: () => setEditId(null) }, t('cancel'))))
|
|
694
|
+
}
|
|
695
|
+
const badges = h('div', { style: { marginTop: 6, display: 'flex', gap: 6, flexWrap: 'wrap' } },
|
|
696
|
+
h('span', { className: 'pt-tag' }, structTypeLabel(s.type)),
|
|
697
|
+
s.complexity ? h('span', { className: 'pt-tag' }, t('complexityLabel') + ' ' + structLevelLabel(s.complexity)) : null,
|
|
698
|
+
s.visualization_potential ? h('span', { className: 'pt-tag' }, t('potentialLabel') + ' ' + structLevelLabel(s.visualization_potential)) : null)
|
|
699
|
+
const footer = h('div', { className: 'pt-card-row', style: { marginTop: 12, paddingTop: 10, borderTop: '1px solid var(--dsw-alias-border-l2, #ececec)' } },
|
|
700
|
+
h('div', { style: { display: 'flex', gap: 8, alignItems: 'center' } },
|
|
701
|
+
h('input', { className: 'pt-check', type: 'checkbox', checked: selected.includes(s.id), onChange: () => onToggle(s.id), disabled: !gated }),
|
|
702
|
+
h('span', { className: 'pt-meta' }, t('select'))),
|
|
703
|
+
h('div', { style: { display: 'flex', gap: 6 } },
|
|
704
|
+
h('button', { className: 'pt-btn pt-btn-ghost', style: { padding: '4px 8px', fontSize: 12.5 }, onClick: () => startEdit(s) }, t('edit')),
|
|
705
|
+
h('button', { className: 'pt-btn pt-btn-ghost', style: { padding: '4px 8px', fontSize: 12.5 }, onClick: () => setDetailId(s.id) }, t('details'))))
|
|
706
|
+
return h('div', { key: s.id, className: 'pt-card' },
|
|
707
|
+
h('div', { className: 'pt-card-row' },
|
|
708
|
+
h('div', { style: { flex: 1, minWidth: 0 } },
|
|
709
|
+
h('h3', { className: 'pt-option-title', style: { margin: 0 } }, titleOf(s, idx)),
|
|
710
|
+
badges)),
|
|
711
|
+
s.description ? h('p', { className: 'pt-option-desc', style: { marginTop: 8 } }, s.description) : null,
|
|
712
|
+
footer)
|
|
713
|
+
})
|
|
714
|
+
|
|
715
|
+
const gatedRow = gated ? h('div', { className: 'pt-card-row', style: { marginTop: 18 } },
|
|
716
|
+
h('div', { style: { display: 'flex', gap: 10, alignItems: 'center' } },
|
|
717
|
+
h('button', { className: 'pt-btn', onClick: onToggleAll }, allSelected ? t('deselectAll') : t('selectAll')),
|
|
718
|
+
h('span', { className: 'pt-meta' }, t('selectCount', { s: selected.length, t: list.length }))),
|
|
719
|
+
h('button', { className: 'pt-btn pt-btn-primary', disabled: selected.length === 0, onClick: onConfirm }, t('confirmStructures'))) : null
|
|
720
|
+
|
|
721
|
+
const sourceFold = h('div', { className: 'pt-fold', style: { marginTop: 22 } },
|
|
722
|
+
h('button', { className: 'pt-fold-head', onClick: () => setShowSource(!showSource) },
|
|
723
|
+
h('span', null, showSource ? t('hideSource') : t('viewSource')),
|
|
724
|
+
h('span', { className: 'chev' }, showSource ? '▾' : '▸')),
|
|
725
|
+
showSource && document ? h('pre', { className: 'pt-log', style: { marginTop: 10 } }, document) : null)
|
|
726
|
+
|
|
727
|
+
const active = detailId ? list.find((x) => x.id === detailId) || null : null
|
|
728
|
+
const detailModal = active ? h('div', { className: 'pt-modal-backdrop', onClick: () => setDetailId(null) },
|
|
729
|
+
h('div', { className: 'pt-modal', onClick: (e) => e.stopPropagation() },
|
|
730
|
+
h('div', { className: 'pt-modal-head' },
|
|
731
|
+
h('div', null,
|
|
732
|
+
h('h3', { className: 'pt-option-title', style: { margin: 0 } }, titleOf(active, list.indexOf(active))),
|
|
733
|
+
h('div', { style: { marginTop: 6, display: 'flex', gap: 6, flexWrap: 'wrap' } },
|
|
734
|
+
h('span', { className: 'pt-tag' }, structTypeLabel(active.type)),
|
|
735
|
+
active.complexity ? h('span', { className: 'pt-tag' }, t('complexityLabel') + ' ' + structLevelLabel(active.complexity)) : null,
|
|
736
|
+
active.visualization_potential ? h('span', { className: 'pt-tag' }, t('potentialLabel') + ' ' + structLevelLabel(active.visualization_potential)) : null)),
|
|
737
|
+
h('button', { className: 'pt-modal-close', onClick: () => setDetailId(null), title: t('close'),
|
|
738
|
+
'aria-label': t('close') }, '×')),
|
|
739
|
+
h('div', { className: 'pt-modal-body' },
|
|
740
|
+
active.description ? h('p', { className: 'pt-option-desc' }, active.description) : null,
|
|
741
|
+
detailFrag(active))))
|
|
742
|
+
: null
|
|
743
|
+
|
|
744
|
+
return h('div', null,
|
|
745
|
+
h('div', { className: 'pt-grid' }, cards),
|
|
746
|
+
gatedRow,
|
|
747
|
+
sourceFold,
|
|
748
|
+
detailModal)
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function layoutLabel(k) {
|
|
752
|
+
return String(k || '').split('-').map(function (w) { return w.charAt(0).toUpperCase() + w.slice(1) }).join(' ') || k
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function MetaPanel({ meta, documentTitle, onDocumentTitle, documentSummary, onDocumentSummary, projectTitle, onConfirm, busy }) {
|
|
756
|
+
if (!meta) {
|
|
757
|
+
return h(EmptyState, { title: t('metaRunning'), hint: t('metaRunningHint') })
|
|
758
|
+
}
|
|
759
|
+
return h('div', null,
|
|
760
|
+
h('p', { className: 'pt-subtitle' }, t('newSubtitle')),
|
|
761
|
+
h('div', { className: 'pt-card' },
|
|
762
|
+
h('div', { className: 'pt-field' },
|
|
763
|
+
h('label', { className: 'pt-field-label' }, t('projectTitle')),
|
|
764
|
+
h('input', { className: 'pt-input', value: projectTitle, placeholder: meta.document_title || '', readOnly: true })),
|
|
765
|
+
h('div', { className: 'pt-field', style: { marginTop: 12 } },
|
|
766
|
+
h('label', { className: 'pt-field-label' }, t('metaTitle')),
|
|
767
|
+
h('input', { className: 'pt-input', value: documentTitle, onChange: (e) => onDocumentTitle(e.target.value), placeholder: meta.document_title || '' })),
|
|
768
|
+
h('div', { className: 'pt-field', style: { marginTop: 12 } },
|
|
769
|
+
h('label', { className: 'pt-field-label' }, t('metaSummary')),
|
|
770
|
+
h('textarea', { className: 'pt-input pt-textarea', value: documentSummary, onChange: (e) => onDocumentSummary(e.target.value), placeholder: meta.document_summary || '' })),
|
|
771
|
+
h('p', { className: 'pt-detail', style: { marginTop: 4 } },
|
|
772
|
+
t('metaConfirmedHint')),
|
|
773
|
+
h('div', { className: 'pt-actions' },
|
|
774
|
+
h('button', { className: 'pt-btn pt-btn-primary', disabled: busy || !String(documentTitle || '').trim(), onClick: onConfirm },
|
|
775
|
+
busy ? t('saving') : t('confirmMeta')))))
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function ProposalsPanel({ stage, proposals, suggestedStyle, steps, selected, onToggle, allSelected, onToggleAll,
|
|
779
|
+
layouts, styles, lay, sty, setLay, setSty, promptId, setPromptId,
|
|
780
|
+
ratios, setRatio, onRender, renderBusy, renderState, renderRunning, hasImageKey, running }) {
|
|
781
|
+
const [pick, setPick] = React.useState(null) // { id, kind: 'layout' | 'style' }
|
|
782
|
+
if (!proposals.length) {
|
|
783
|
+
const metaState = steps ? steps[0].state : 'done'
|
|
784
|
+
const extractState = steps ? steps[1].state : 'done'
|
|
785
|
+
if (metaState !== 'done') {
|
|
786
|
+
const runningMeta = metaState === 'running'
|
|
787
|
+
return h(EmptyState, { title: runningMeta ? t('metaRunning') : t('waitMeta'), hint: runningMeta ? t('metaRunningHint') : t('waitMetaHint') })
|
|
788
|
+
}
|
|
789
|
+
if (extractState === 'gated') return h(EmptyState, { title: t('waitConfirmStructures'), hint: t('waitConfirmStructuresHint') })
|
|
790
|
+
if (extractState !== 'done') return h(EmptyState, { title: t('pendingExtract'), hint: t('pendingExtractHint') })
|
|
791
|
+
return h(EmptyState, { title: t('emptyAdviceRunning'), hint: t('emptyAdviceHint') })
|
|
792
|
+
}
|
|
793
|
+
const ratioFor = (id) => ratios[id] || '16:9'
|
|
794
|
+
const activePrompt = promptId ? proposals.find((x) => x.id === promptId) || null : null
|
|
795
|
+
|
|
796
|
+
function pickOption(name) {
|
|
797
|
+
const cur = pick
|
|
798
|
+
if (!cur) return
|
|
799
|
+
if (cur.kind === 'layout') setLay(cur.id, name)
|
|
800
|
+
else setSty(cur.id, name)
|
|
801
|
+
setPick(null)
|
|
802
|
+
}
|
|
803
|
+
function resetCurrent() {
|
|
804
|
+
const cur = pick
|
|
805
|
+
if (!cur) return
|
|
806
|
+
if (cur.kind === 'layout') setLay(cur.id, undefined)
|
|
807
|
+
else setSty(cur.id, undefined)
|
|
808
|
+
setPick(null)
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
const cards = proposals.map((p) => {
|
|
812
|
+
const layV = lay[p.id] || p.suggested_layout || ''
|
|
813
|
+
const styV = sty[p.id] || p.suggested_style || suggestedStyle || ''
|
|
814
|
+
const titleV = p.title || layoutLabel(p.suggested_layout) || t('structFallback') + ' ' + p.id
|
|
815
|
+
const layoutChanged = Boolean(lay[p.id] && lay[p.id] !== p.suggested_layout)
|
|
816
|
+
const st = renderState && renderState.proposals ? renderState.proposals[p.id] : null
|
|
817
|
+
const stErr = st === 'error' && renderState && renderState.errors ? renderState.errors[p.id] : null
|
|
818
|
+
const statusTag = st === 'running' ? h('span', { className: 'pt-tag', style: { color: '#b45309' } }, t('rendering'))
|
|
819
|
+
: st === 'done' ? h('span', { className: 'pt-tag', style: { color: '#15803d' } }, '✓')
|
|
820
|
+
: st === 'error' ? h('span', { className: 'pt-tag', style: { color: '#b91c1c' } }, t('renderFailed'))
|
|
821
|
+
: null
|
|
822
|
+
const badges = h('div', { style: { marginTop: 6, display: 'flex', gap: 6, flexWrap: 'wrap' } },
|
|
823
|
+
h('span', { className: 'pt-tag' }, layoutLabel(layV)),
|
|
824
|
+
h('span', { className: 'pt-tag' }, styV),
|
|
825
|
+
p.estimated_complexity ? h('span', { className: 'pt-tag' }, t('complexityLabel') + ' ' + structLevelLabel(p.estimated_complexity)) : null,
|
|
826
|
+
statusTag)
|
|
827
|
+
const controls = h('div', { className: 'pt-card-row', style: { marginTop: 10 } },
|
|
828
|
+
h('div', { style: { display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'center' } },
|
|
829
|
+
h('label', { style: { display: 'inline-flex', alignItems: 'center', gap: 6 } },
|
|
830
|
+
h('span', { className: 'pt-meta' }, t('fieldAspect')),
|
|
831
|
+
h('select', { className: 'pt-input', style: { width: 92, padding: '4px 8px', fontSize: 13 }, value: ratioFor(p.id), onChange: (e) => setRatio(p.id, e.target.value) },
|
|
832
|
+
['16:9', '4:3', '3:4', '9:16', '1:1'].map((r) => h('option', { key: r, value: r }, r)))),
|
|
833
|
+
h('button', { className: 'pt-btn', style: { padding: '4px 10px', fontSize: 13 }, onClick: () => setPick({ id: p.id, kind: 'layout' }) },
|
|
834
|
+
t('layoutLabel') + ': ' + layoutLabel(layV) + (layoutChanged ? ' ' + t('layoutChanged') : '')),
|
|
835
|
+
h('button', { className: 'pt-btn', style: { padding: '4px 10px', fontSize: 13 }, onClick: () => setPick({ id: p.id, kind: 'style' }) },
|
|
836
|
+
t('styleLabel') + ': ' + styV)),
|
|
837
|
+
h('button', { className: 'pt-btn pt-btn-ghost', style: { padding: '4px 8px', fontSize: 13 }, onClick: () => setPromptId(p.id) }, t('viewPrompt')))
|
|
838
|
+
return h('div', { key: p.id, className: 'pt-card' },
|
|
839
|
+
h('div', { className: 'pt-card-row' },
|
|
840
|
+
h('div', { style: { flex: 1, minWidth: 0 } },
|
|
841
|
+
h('h3', { className: 'pt-option-title', style: { margin: 0 } }, titleV),
|
|
842
|
+
badges),
|
|
843
|
+
h('div', { style: { display: 'flex', gap: 8, alignItems: 'center' } },
|
|
844
|
+
h('input', { className: 'pt-check', type: 'checkbox', checked: selected.includes(p.id), onChange: () => onToggle(p.id) }),
|
|
845
|
+
h('span', { className: 'pt-meta' }, t('select')))),
|
|
846
|
+
p.communicative_intent ? h('p', { className: 'pt-option-desc', style: { marginTop: 8 } }, p.communicative_intent) : null,
|
|
847
|
+
stErr ? h('p', { className: 'pt-error', style: { marginTop: 8 } }, t('renderFailed') + ': ' + stErr) : null,
|
|
848
|
+
controls)
|
|
849
|
+
})
|
|
850
|
+
|
|
851
|
+
const promptModal = activePrompt ? h('div', { className: 'pt-modal-backdrop', onClick: () => setPromptId(null) },
|
|
852
|
+
h('div', { className: 'pt-modal', onClick: (e) => e.stopPropagation() },
|
|
853
|
+
h('div', { className: 'pt-modal-head' },
|
|
854
|
+
h('div', null, h('h3', { className: 'pt-option-title', style: { margin: 0 } }, t('viewPrompt'))),
|
|
855
|
+
h('button', { className: 'pt-modal-close', onClick: () => setPromptId(null), title: t('close'), 'aria-label': t('close') }, '×')),
|
|
856
|
+
h('div', { className: 'pt-modal-body' },
|
|
857
|
+
h('p', { className: 'pt-detail' }, activePrompt.layout_rationale || ''),
|
|
858
|
+
h('pre', { className: 'pt-code' }, activePrompt.source_text || ''))))
|
|
859
|
+
: null
|
|
860
|
+
|
|
861
|
+
const chooserModal = pick ? (function chooser() {
|
|
862
|
+
const p = proposals.find((x) => x.id === pick.id)
|
|
863
|
+
if (!p) return null
|
|
864
|
+
const kind = pick.kind
|
|
865
|
+
const list = kind === 'layout'
|
|
866
|
+
? (layouts && layouts.length ? layouts : [p.suggested_layout])
|
|
867
|
+
: (styles && styles.length ? styles : [p.suggested_style || suggestedStyle])
|
|
868
|
+
const base = kind === 'layout' ? p.suggested_layout : (p.suggested_style || suggestedStyle)
|
|
869
|
+
const current = kind === 'layout' ? (lay[pick.id] || p.suggested_layout) : (sty[pick.id] || p.suggested_style || suggestedStyle)
|
|
870
|
+
const changed = kind === 'layout'
|
|
871
|
+
? Boolean(lay[pick.id] && lay[pick.id] !== base)
|
|
872
|
+
: Boolean(sty[pick.id] && sty[pick.id] !== base)
|
|
873
|
+
const items = list.map((n) => h('div', { key: n, className: 'pt-preview-item' + (n === current ? ' sel' : ''), onClick: () => pickOption(n) },
|
|
874
|
+
n === base ? h('span', { className: 'ai-chip' }, t('aiSuggestion')) : null,
|
|
875
|
+
h('img', { src: '/pictor/preview/' + (kind === 'layout' ? 'layouts' : 'styles') + '/' + encodeURIComponent(n) + '.webp', alt: kind === 'layout' ? layoutLabel(n) : n }),
|
|
876
|
+
h('div', { className: 'nm' }, kind === 'layout' ? layoutLabel(n) : n)))
|
|
877
|
+
return h('div', { className: 'pt-modal-backdrop', onClick: () => setPick(null) },
|
|
878
|
+
h('div', { className: 'pt-modal pt-modal-wide', onClick: (e) => e.stopPropagation() },
|
|
879
|
+
h('div', { className: 'pt-modal-head' },
|
|
880
|
+
h('h3', { className: 'pt-option-title', style: { margin: 0 } }, kind === 'layout' ? t('chooseLayout') : t('chooseStyle')),
|
|
881
|
+
h('div', { style: { display: 'flex', gap: 8, alignItems: 'center' } },
|
|
882
|
+
changed ? h('button', { className: 'pt-btn', style: { padding: '6px 12px', fontSize: 13 }, onClick: resetCurrent }, t('resetToAi')) : null,
|
|
883
|
+
h('button', { className: 'pt-modal-close', onClick: () => setPick(null), title: t('close'), 'aria-label': t('close') }, '×'))),
|
|
884
|
+
h('div', { className: 'pt-modal-body' }, h('div', { className: 'pt-preview-grid' }, items))))
|
|
885
|
+
})() : null
|
|
886
|
+
|
|
887
|
+
const actionRow = h('div', { className: 'pt-card-row', style: { marginTop: 18 } },
|
|
888
|
+
h('div', { style: { display: 'flex', gap: 10, alignItems: 'center' } },
|
|
889
|
+
h('button', { className: 'pt-btn', onClick: onToggleAll }, allSelected ? t('deselectAll') : t('selectAll')),
|
|
890
|
+
h('span', { className: 'pt-meta' }, t('selectCount', { s: selected.length, t: proposals.length }))),
|
|
891
|
+
hasImageKey === false
|
|
892
|
+
? h('p', { className: 'pt-warn', style: { margin: 0 } }, t('imgKeyHint'))
|
|
893
|
+
: h('button', { className: 'pt-btn pt-btn-primary', disabled: selected.length === 0 || renderBusy || renderRunning === true, onClick: onRender },
|
|
894
|
+
renderBusy || renderRunning ? t('rendering') : t('renderSelected')))
|
|
895
|
+
|
|
896
|
+
return h('div', null,
|
|
897
|
+
h('div', { className: 'pt-grid' }, cards),
|
|
898
|
+
actionRow,
|
|
899
|
+
promptModal,
|
|
900
|
+
chooserModal)
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function fileSlug(s) {
|
|
904
|
+
return String(s || '').toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'infographic'
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function ResultsPanel({ outputs, images, renderErrors, proposals, lay, renderBatches }) {
|
|
908
|
+
const [big, setBig] = React.useState(null)
|
|
909
|
+
const [collapsed, setCollapsed] = React.useState({})
|
|
910
|
+
if (!outputs.length && !renderErrors.length) {
|
|
911
|
+
return h(EmptyState, { title: t('emptyRender'), hint: t('emptyRenderHint') })
|
|
912
|
+
}
|
|
913
|
+
const pidOf = (n) => n.replace(/\.png$/, '').replace(/-\d+$/, '')
|
|
914
|
+
const propOf = (pid) => (Array.isArray(proposals) ? proposals.find((p) => String(p.id) === pid) : null)
|
|
915
|
+
const titleAndLayout = (name) => {
|
|
916
|
+
const pid = pidOf(name)
|
|
917
|
+
const prop = propOf(pid)
|
|
918
|
+
const actualLayout = lay && lay[pid] ? lay[pid] : (prop ? (prop.suggested_layout || '') : '')
|
|
919
|
+
const title = prop && prop.title ? prop.title : (layoutLabel(actualLayout || prop?.suggested_layout) || name)
|
|
920
|
+
return { pid, prop, actualLayout, title }
|
|
921
|
+
}
|
|
922
|
+
const bigInfo = big ? titleAndLayout(big) : null
|
|
923
|
+
const bigModal = big && images[big] && bigInfo ? h('div', { className: 'pt-modal-backdrop', onClick: () => setBig(null) },
|
|
924
|
+
h('div', { className: 'pt-modal', style: { width: 'min(92vw, 1200px)' }, onClick: (e) => e.stopPropagation() },
|
|
925
|
+
h('div', { className: 'pt-modal-head' },
|
|
926
|
+
h('div', null, h('h3', { className: 'pt-option-title', style: { margin: 0 } }, bigInfo.title)),
|
|
927
|
+
h('button', { className: 'pt-modal-close', onClick: () => setBig(null), title: t('close'), 'aria-label': t('close') }, '×')),
|
|
928
|
+
h('div', { className: 'pt-modal-body', style: { textAlign: 'center' } },
|
|
929
|
+
h('img', { src: images[big], alt: bigInfo.title, style: { maxWidth: '100%', maxHeight: '70vh', borderRadius: 10 } }),
|
|
930
|
+
bigInfo.actualLayout ? h('p', { className: 'pt-meta', style: { marginTop: 10 } }, t('layoutLabel') + ': ' + layoutLabel(bigInfo.actualLayout)) : null)))
|
|
931
|
+
: null
|
|
932
|
+
|
|
933
|
+
// 按渲染批次分组(新批次在前),历史批次与旧图全部保留
|
|
934
|
+
const batches = Array.isArray(renderBatches) ? renderBatches : []
|
|
935
|
+
const knownFiles = new Set()
|
|
936
|
+
batches.forEach((b) => (Array.isArray(b.outputs) ? b.outputs : []).forEach((f) => knownFiles.add(String(f))))
|
|
937
|
+
const groups = []
|
|
938
|
+
for (const b of batches) {
|
|
939
|
+
// 每个批次精确持有自己的产出文件(同一方案重复渲染也不覆盖、不串组)
|
|
940
|
+
const files = (Array.isArray(b.outputs) ? b.outputs : []).filter((f) => outputs.includes(f))
|
|
941
|
+
if (!files.length) continue
|
|
942
|
+
const time = b.startedAt ? new Date(b.startedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''
|
|
943
|
+
groups.push({ key: String(b.batchId || time || 'batch'), title: t('batchTitle', { t: time }) + ' · ' + t('batchN', { n: files.length }), files })
|
|
944
|
+
}
|
|
945
|
+
const leftover = outputs.filter((n) => !knownFiles.has(n))
|
|
946
|
+
if (leftover.length) groups.push({ key: 'legacy', title: t('batchLegacy') + ' · ' + t('batchN', { n: leftover.length }), files: leftover })
|
|
947
|
+
|
|
948
|
+
const figs = (files) => files.map((name) => {
|
|
949
|
+
const info = titleAndLayout(name)
|
|
950
|
+
const dlName = info.prop ? (fileSlug(info.prop.title || layoutLabel(info.actualLayout || info.prop.suggested_layout)) + '-' + info.pid + '.png') : name
|
|
951
|
+
return h('figure', { key: name, className: 'pt-img-card' },
|
|
952
|
+
images[name]
|
|
953
|
+
? h('img', { className: 'pt-img', src: images[name], alt: info.title, style: { cursor: 'zoom-in' }, onClick: () => setBig(name) })
|
|
954
|
+
: h('div', { className: 'thumb', style: { width: '100%', height: 140 } }),
|
|
955
|
+
h('figcaption', null,
|
|
956
|
+
h('div', { className: 'pt-fig-title' }, info.title),
|
|
957
|
+
h('div', { className: 'pt-fig-row' },
|
|
958
|
+
h('a', { className: 'pt-dl', href: images[name] || '#', download: dlName }, t('download')))))
|
|
959
|
+
})
|
|
960
|
+
|
|
961
|
+
return h('div', null,
|
|
962
|
+
renderErrors && renderErrors.length
|
|
963
|
+
? renderErrors.map((e, i) => h('p', { key: i, className: 'pt-error' }, `${e.id}: ${e.error}`))
|
|
964
|
+
: null,
|
|
965
|
+
groups.map((g) => {
|
|
966
|
+
const open = !collapsed[g.key]
|
|
967
|
+
return h('div', { key: g.key, className: 'pt-batch' },
|
|
968
|
+
h('div', { className: 'pt-batch-head', onClick: () => setCollapsed((p) => ({ ...p, [g.key]: !p[g.key] })) },
|
|
969
|
+
h('span', { className: 'pt-batch-title' }, g.title),
|
|
970
|
+
h('span', { className: 'chev' }, open ? '▾' : '▸')),
|
|
971
|
+
open ? h('div', { className: 'pt-img-grid' }, figs(g.files)) : null)
|
|
972
|
+
}),
|
|
973
|
+
bigModal)
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
function expandPrompt(proposal) {
|
|
977
|
+
const parts = [
|
|
978
|
+
proposal.source_text ? '来源内容:' + proposal.source_text : '',
|
|
979
|
+
proposal.communicative_intent ? '传达意图:' + proposal.communicative_intent : '',
|
|
980
|
+
proposal.suggested_layout ? '布局:' + proposal.suggested_layout : '',
|
|
981
|
+
proposal.layout_rationale ? '布局理由:' + proposal.layout_rationale : '',
|
|
982
|
+
proposal.style_rationale ? '风格理由:' + proposal.style_rationale : '',
|
|
983
|
+
]
|
|
984
|
+
return parts.filter(Boolean).join('\n')
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// ---------- 新建项目 ----------
|
|
988
|
+
|
|
989
|
+
function NewProjectPane(props) {
|
|
990
|
+
const [mode, setMode] = React.useState('file')
|
|
991
|
+
const [fileName, setFileName] = React.useState(null)
|
|
992
|
+
const [fileData, setFileData] = React.useState(null)
|
|
993
|
+
const [fileSize, setFileSize] = React.useState('')
|
|
994
|
+
const [busy, setBusy] = React.useState(false)
|
|
995
|
+
const [error, setError] = React.useState('')
|
|
996
|
+
const fileRef = React.useRef(null)
|
|
997
|
+
const richRef = React.useRef(null)
|
|
998
|
+
|
|
999
|
+
function pickFile() {
|
|
1000
|
+
if (fileRef.current) fileRef.current.click()
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
function onFile(e) {
|
|
1004
|
+
const f = e.target.files && e.target.files[0]
|
|
1005
|
+
if (!f) return
|
|
1006
|
+
const kb = Math.round(f.size / 1024)
|
|
1007
|
+
setFileName(f.name)
|
|
1008
|
+
setFileSize(kb < 1024 ? kb + ' KB' : (kb / 1024).toFixed(1) + ' MB')
|
|
1009
|
+
const reader = new FileReader()
|
|
1010
|
+
reader.onload = () => setFileData(String(reader.result || ''))
|
|
1011
|
+
reader.readAsDataURL(f)
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
function onPaste(e) {
|
|
1015
|
+
if (mode !== 'paste') return
|
|
1016
|
+
const html = e.clipboardData.getData('text/html')
|
|
1017
|
+
const text = e.clipboardData.getData('text/plain')
|
|
1018
|
+
e.preventDefault()
|
|
1019
|
+
if (html) {
|
|
1020
|
+
const clean = sanitizePaste(html)
|
|
1021
|
+
if (document.execCommand) {
|
|
1022
|
+
try { document.execCommand('insertHTML', false, clean); return } catch { /* 回落 */ }
|
|
1023
|
+
}
|
|
1024
|
+
document.execCommand('insertText', false, text)
|
|
1025
|
+
} else {
|
|
1026
|
+
document.execCommand('insertText', false, text)
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
function submitRich() {
|
|
1031
|
+
const el = richRef.current
|
|
1032
|
+
if (!el) return ''
|
|
1033
|
+
return sanitizePaste(el.innerHTML)
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
async function submit() {
|
|
1037
|
+
if (busy) return
|
|
1038
|
+
setBusy(true)
|
|
1039
|
+
setError('')
|
|
1040
|
+
try {
|
|
1041
|
+
if (mode === 'file') {
|
|
1042
|
+
if (!fileData) throw new Error('请先选择文件')
|
|
1043
|
+
const res = await rpc(props.ctx, 'project.create', { file: { name: fileName, dataUrl: fileData } })
|
|
1044
|
+
if (res && res.id) {
|
|
1045
|
+
const sessionId = await driveProjectSession(props.ctx, res.prompt)
|
|
1046
|
+
await rpc(props.ctx, 'project.attach', { id: res.id, sessionId })
|
|
1047
|
+
props.onCreated(res.id)
|
|
1048
|
+
}
|
|
1049
|
+
} else {
|
|
1050
|
+
const html = submitRich()
|
|
1051
|
+
if (!html || !html.replace(/<[^>]*>/g, '').trim()) throw new Error('请先粘贴内容')
|
|
1052
|
+
const res = await rpc(props.ctx, 'project.create', { html })
|
|
1053
|
+
if (res && res.id) {
|
|
1054
|
+
const sessionId = await driveProjectSession(props.ctx, res.prompt)
|
|
1055
|
+
await rpc(props.ctx, 'project.attach', { id: res.id, sessionId })
|
|
1056
|
+
props.onCreated(res.id)
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
} catch (e) {
|
|
1060
|
+
setError(String(e && e.message ? e.message : e))
|
|
1061
|
+
} finally {
|
|
1062
|
+
setBusy(false)
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
return h('div', null,
|
|
1067
|
+
h('h1', { className: 'pt-title' }, t('newProject')),
|
|
1068
|
+
h('p', { className: 'pt-subtitle' }, '一份文档转成一组信息图。项目名取文档首行,随时可改。'),
|
|
1069
|
+
|
|
1070
|
+
h('div', { className: 'pt-tabbar' },
|
|
1071
|
+
h('button', { className: 'pt-tab' + (mode === 'file' ? ' active' : ''), onClick: () => setMode('file') }, t('tabUpload')),
|
|
1072
|
+
h('button', { className: 'pt-tab' + (mode === 'paste' ? ' active' : ''), onClick: () => setMode('paste') }, t('tabPaste'))),
|
|
1073
|
+
|
|
1074
|
+
mode === 'file'
|
|
1075
|
+
? h('div', null,
|
|
1076
|
+
h('div', { className: 'pt-file', onClick: pickFile },
|
|
1077
|
+
h('div', null,
|
|
1078
|
+
(fileName
|
|
1079
|
+
? h('p', { className: 'pt-file-name' }, fileName + '(' + fileSize + ')')
|
|
1080
|
+
: h('p', { style: { margin: 0 } }, t('pickFile'))),
|
|
1081
|
+
h('p', { className: 'pt-detail', style: { margin: '4px 0 0' } },
|
|
1082
|
+
t('fileHint')))),
|
|
1083
|
+
h('input', { ref: fileRef, type: 'file',
|
|
1084
|
+
accept: '.md,.markdown,.txt,.text,.docx,.pdf,.png,.jpg,.jpeg,.webp', style: { display: 'none' },
|
|
1085
|
+
onChange: onFile }))
|
|
1086
|
+
: h('div', null,
|
|
1087
|
+
h('div', { ref: richRef, className: 'pt-input pt-rich', contentEditable: true, onPaste,
|
|
1088
|
+
'data-placeholder': t('richPlaceholder') }),
|
|
1089
|
+
h('p', { className: 'pt-detail', style: { marginTop: 8 } },
|
|
1090
|
+
t('richHint'))),
|
|
1091
|
+
|
|
1092
|
+
error ? h('p', { className: 'pt-error' }, error) : null,
|
|
1093
|
+
h('div', { className: 'pt-actions' },
|
|
1094
|
+
h('button', { className: 'pt-btn pt-btn-primary', onClick: submit, disabled: busy }, busy ? t('creating') : t('createProject'))))
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
// ---------- 项目详情 ----------
|
|
1098
|
+
|
|
1099
|
+
function DetailPane(props) {
|
|
1100
|
+
const { ctx, projectId, onDeleted } = props
|
|
1101
|
+
const [data, setData] = React.useState(null)
|
|
1102
|
+
const [structuresSel, setStructuresSel] = React.useState([])
|
|
1103
|
+
const [proposalSel, setProposalSel] = React.useState([])
|
|
1104
|
+
const [ratios, setRatios] = React.useState({}) // proposalId -> 宽高比,每张图单独设,缺省 16:9
|
|
1105
|
+
const ovDraft = React.useRef({ lay: {}, sty: {}, ratios: {} })
|
|
1106
|
+
const overrideTimer = React.useRef(null)
|
|
1107
|
+
function scheduleOverridesSave() {
|
|
1108
|
+
if (overrideTimer.current) clearTimeout(overrideTimer.current)
|
|
1109
|
+
overrideTimer.current = setTimeout(() => {
|
|
1110
|
+
overrideTimer.current = null
|
|
1111
|
+
const { lay, sty, ratios: rat } = ovDraft.current
|
|
1112
|
+
const ov = {}
|
|
1113
|
+
for (const id of new Set([...Object.keys(lay), ...Object.keys(sty), ...Object.keys(rat)])) {
|
|
1114
|
+
const c = {}
|
|
1115
|
+
if (lay[id]) c.layout = lay[id]
|
|
1116
|
+
if (sty[id]) c.style = sty[id]
|
|
1117
|
+
if (rat[id]) c.aspectRatio = rat[id]
|
|
1118
|
+
if (Object.keys(c).length) ov[id] = c
|
|
1119
|
+
}
|
|
1120
|
+
rpc(ctx, 'project.saveOverrides', { id: projectId, overrides: ov }).catch((e) => console.error(e))
|
|
1121
|
+
}, 500)
|
|
1122
|
+
}
|
|
1123
|
+
function setRatio(id, v) {
|
|
1124
|
+
setRatios((prev) => { const n = { ...prev }; if (v === undefined) delete n[id]; else n[id] = v; ovDraft.current.ratios = n; return n })
|
|
1125
|
+
scheduleOverridesSave()
|
|
1126
|
+
}
|
|
1127
|
+
const [discuss, setDiscuss] = React.useState('')
|
|
1128
|
+
const [images, setImages] = React.useState({})
|
|
1129
|
+
const [metaTitle, setMetaTitle] = React.useState('')
|
|
1130
|
+
const [metaSummary, setMetaSummary] = React.useState('')
|
|
1131
|
+
const metaDirty = React.useRef(false)
|
|
1132
|
+
const [metaBusy, setMetaBusy] = React.useState(false)
|
|
1133
|
+
const saveMetaTimer = React.useRef(null)
|
|
1134
|
+
function saveMetaNext(nTitle, nSummary) {
|
|
1135
|
+
if (saveMetaTimer.current) clearTimeout(saveMetaTimer.current)
|
|
1136
|
+
saveMetaTimer.current = setTimeout(() => {
|
|
1137
|
+
rpc(ctx, 'project.saveMeta', { id: projectId, title: nTitle || (data && data.meta && data.meta.document_title) || '', summary: nSummary || '' })
|
|
1138
|
+
.then(() => setTick((x) => x + 1))
|
|
1139
|
+
.catch((e) => console.error(e))
|
|
1140
|
+
}, 600)
|
|
1141
|
+
}
|
|
1142
|
+
const [layOv, setLayOv] = React.useState({})
|
|
1143
|
+
const [styOv, setStyOv] = React.useState({})
|
|
1144
|
+
function setLayWrap(id, v) { setLayOv((prev) => { const n = { ...prev }; if (v === undefined) delete n[id]; else n[id] = v; ovDraft.current.lay = n; return n }); scheduleOverridesSave() }
|
|
1145
|
+
function setStyWrap(id, v) { setStyOv((prev) => { const n = { ...prev }; if (v === undefined) delete n[id]; else n[id] = v; ovDraft.current.sty = n; return n }); scheduleOverridesSave() }
|
|
1146
|
+
const [promptId, setPromptId] = React.useState(null)
|
|
1147
|
+
const [renderErrors, setRenderErrors] = React.useState([])
|
|
1148
|
+
const [renderBusy, setRenderBusy] = React.useState(false)
|
|
1149
|
+
const [step, setStep] = React.useState('extract')
|
|
1150
|
+
const [showDiscuss, setShowDiscuss] = React.useState(true)
|
|
1151
|
+
const [renaming, setRenaming] = React.useState(false)
|
|
1152
|
+
const [renameValue, setRenameValue] = React.useState('')
|
|
1153
|
+
const initLanding = React.useRef(false)
|
|
1154
|
+
const [tick, setTick] = React.useState(0)
|
|
1155
|
+
const [imageCfg, setImageCfg] = React.useState(null)
|
|
1156
|
+
const [styleOptions, setStyleOptions] = React.useState([])
|
|
1157
|
+
const [layoutOptions, setLayoutOptions] = React.useState([])
|
|
1158
|
+
React.useEffect(() => {
|
|
1159
|
+
rpc(ctx, 'config.get').then((c) => {
|
|
1160
|
+
if (c && c.image) setImageCfg(c.image)
|
|
1161
|
+
if (c && Array.isArray(c.styles)) setStyleOptions(c.styles)
|
|
1162
|
+
if (c && Array.isArray(c.layouts)) setLayoutOptions(c.layouts)
|
|
1163
|
+
}).catch(() => {})
|
|
1164
|
+
}, [ctx])
|
|
1165
|
+
|
|
1166
|
+
React.useEffect(() => {
|
|
1167
|
+
let alive = true
|
|
1168
|
+
async function load() {
|
|
1169
|
+
try {
|
|
1170
|
+
const res = await rpc(ctx, 'project.get', { id: projectId })
|
|
1171
|
+
if (res && alive) setData(res)
|
|
1172
|
+
} catch (e) {
|
|
1173
|
+
console.error(e)
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
load()
|
|
1177
|
+
const t = setInterval(load, 2000)
|
|
1178
|
+
return () => { alive = false; clearInterval(t) }
|
|
1179
|
+
}, [projectId, tick])
|
|
1180
|
+
|
|
1181
|
+
React.useEffect(() => {
|
|
1182
|
+
if (!data) return
|
|
1183
|
+
const steps = deriveSteps(data.record.stage, Boolean(data.meta))
|
|
1184
|
+
const available = STEP_KEYS.filter((k) => steps[STEP_KEYS.indexOf(k)].state !== 'pending')
|
|
1185
|
+
const furthest = available.length ? available[available.length - 1] : 'extract'
|
|
1186
|
+
if (!initLanding.current) {
|
|
1187
|
+
initLanding.current = true
|
|
1188
|
+
setStep(furthest) // 初登落在最靠后的有产物步骤(pictorial getTargetPage 语义)
|
|
1189
|
+
} else if (!available.includes(step)) {
|
|
1190
|
+
setStep(furthest) // 之后只在当前步骤失效时纠正
|
|
1191
|
+
}
|
|
1192
|
+
}, [data && data.record && data.record.stage])
|
|
1193
|
+
|
|
1194
|
+
React.useEffect(() => {
|
|
1195
|
+
// 后台渲染收尾:render.json 落 finishedAt 后恢复按钮、跳渲染页、汇总错误
|
|
1196
|
+
if (!renderBusy || !data || !data.renderState) return
|
|
1197
|
+
if (!data.renderState.finishedAt) return
|
|
1198
|
+
setRenderBusy(false)
|
|
1199
|
+
const errs = Object.entries(data.renderState.errors || {}).map(([id2, e]) => ({ id: id2, error: String(e) }))
|
|
1200
|
+
if (errs.length) setRenderErrors(errs)
|
|
1201
|
+
setStep('render')
|
|
1202
|
+
setTick((x) => x + 1)
|
|
1203
|
+
}, [renderBusy, data && data.renderState])
|
|
1204
|
+
|
|
1205
|
+
React.useEffect(() => {
|
|
1206
|
+
if (!data) return
|
|
1207
|
+
const outs = data.outputs || []
|
|
1208
|
+
outs.forEach((name) => {
|
|
1209
|
+
if (images[name]) return
|
|
1210
|
+
rpc(ctx, 'project.image', { id: projectId, name }).then((img) => {
|
|
1211
|
+
if (img && img.dataUrl) setImages((p) => ({ ...p, [name]: img.dataUrl }))
|
|
1212
|
+
}).catch(() => {})
|
|
1213
|
+
})
|
|
1214
|
+
}, [data, projectId, images])
|
|
1215
|
+
|
|
1216
|
+
React.useEffect(() => {
|
|
1217
|
+
if (!data || !data.meta) return
|
|
1218
|
+
if (metaDirty.current) return
|
|
1219
|
+
setMetaTitle(data.meta.document_title || '')
|
|
1220
|
+
setMetaSummary(data.meta.document_summary || '')
|
|
1221
|
+
}, [data && data.meta])
|
|
1222
|
+
|
|
1223
|
+
const appliedMetaRef = React.useRef(null)
|
|
1224
|
+
React.useEffect(() => {
|
|
1225
|
+
// 方案覆盖(比例/layout/style)持久化到 12.advice/overrides.json,刷新回填;
|
|
1226
|
+
// 有未落盘的待保存改动(debounce 计时中)时跳过本轮,避免覆盖用户刚做的修改。
|
|
1227
|
+
if (!data || !data.overrides) return
|
|
1228
|
+
if (overrideTimer.current) return
|
|
1229
|
+
const lay = {}; const sty = {}; const rat = {}
|
|
1230
|
+
for (const [pid, v] of Object.entries(data.overrides)) {
|
|
1231
|
+
if (!v || typeof v !== 'object') continue
|
|
1232
|
+
if (v.layout) lay[pid] = v.layout
|
|
1233
|
+
if (v.style) sty[pid] = v.style
|
|
1234
|
+
if (v.aspectRatio) rat[pid] = v.aspectRatio
|
|
1235
|
+
}
|
|
1236
|
+
ovDraft.current = { lay, sty, ratios: rat }
|
|
1237
|
+
setLayOv(lay); setStyOv(sty); setRatios(rat)
|
|
1238
|
+
}, [data && data.overrides, projectId])
|
|
1239
|
+
|
|
1240
|
+
React.useEffect(() => {
|
|
1241
|
+
// 阶段0 信息就位后自动把项目标题改写为 AI 提炼的合理标题,仅在
|
|
1242
|
+
// 「同份 meta 尚未应用过 且 用户未手动编辑」时执行一次;此后用户
|
|
1243
|
+
// 任何手动改名都保留,不再回改。
|
|
1244
|
+
if (!data || !data.meta) return
|
|
1245
|
+
if (metaDirty.current) return
|
|
1246
|
+
const k = projectId + ':' + String(data.meta.document_title || '') + ':' + String(data.meta.document_summary || '')
|
|
1247
|
+
const t = String(data.meta.document_title || '').trim()
|
|
1248
|
+
if (!t) return
|
|
1249
|
+
if (data.record.title === t) { appliedMetaRef.current = k; return }
|
|
1250
|
+
if (appliedMetaRef.current === k) return
|
|
1251
|
+
rpc(ctx, 'project.saveMeta', { id: projectId, title: t, summary: String(data.meta.document_summary || '') })
|
|
1252
|
+
.then(() => { appliedMetaRef.current = k; setTick((x) => x + 1) })
|
|
1253
|
+
.catch((e) => console.error(e))
|
|
1254
|
+
}, [data && data.meta, data && data.record && data.record.title, projectId])
|
|
1255
|
+
|
|
1256
|
+
async function deleteProject() {
|
|
1257
|
+
if (!window.confirm(t('delConfirm', { t: (data && data.record && data.record.title) || projectId }))) return
|
|
1258
|
+
try {
|
|
1259
|
+
await rpc(ctx, 'project.delete', { id: projectId })
|
|
1260
|
+
if (onDeleted) onDeleted()
|
|
1261
|
+
} catch (e) { console.error(e) }
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
if (!data) {
|
|
1265
|
+
return h('p', { className: 'pt-meta' }, t('loading'))
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
const record = data.record
|
|
1269
|
+
const stage = record.stage
|
|
1270
|
+
const structures = parseStructures(data.structuresJson)
|
|
1271
|
+
const propData = parseProposals(data.proposalsJson)
|
|
1272
|
+
const proposals = propData ? (propData.proposals || []) : []
|
|
1273
|
+
const styleName = propData ? (propData.suggested_style || '') : ''
|
|
1274
|
+
const stepCounts = { meta: null, extract: structures.length, advise: proposals.length, render: data.outputs.length }
|
|
1275
|
+
const steps = deriveSteps(stage, Boolean(data.meta)).map((s, i) => {
|
|
1276
|
+
return { ...s, count: stepCounts[STEP_KEYS[i]] || null }
|
|
1277
|
+
})
|
|
1278
|
+
if (data.renderRunning && steps[3]) steps[3].state = 'running'
|
|
1279
|
+
|
|
1280
|
+
async function confirmStructures() {
|
|
1281
|
+
const ids = selectedLabel(structuresSel)
|
|
1282
|
+
const message =
|
|
1283
|
+
'用户已确认候选结构:[' + ids + ']。请进入方案阶段:' +
|
|
1284
|
+
'用 subagent 工具实例化 advisor(指令原样发送:「请阅读 agents/12.advisor.md 并严格按该蓝图执行。' +
|
|
1285
|
+
'参数:EXTRACTION_DIR 指向 11.extraction/(含 structures.json);OUTPUT_DIR 指向 12.advice/,' +
|
|
1286
|
+
'产出 proposals.json」)。完成后读取 12.advice/proposals.json,' +
|
|
1287
|
+
'向用户汇报方案摘要,然后停下等待用户选择方案。禁止自动进入 render 阶段。'
|
|
1288
|
+
await drivePrompt(ctx, projectId, message)
|
|
1289
|
+
setStructuresSel([])
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
async function renderSelected() {
|
|
1293
|
+
const runningNow = data && data.renderRunning
|
|
1294
|
+
if (!proposalSel.length || renderBusy || runningNow) return
|
|
1295
|
+
setRenderBusy(true)
|
|
1296
|
+
setRenderErrors([])
|
|
1297
|
+
try {
|
|
1298
|
+
await rpc(ctx, 'project.render', {
|
|
1299
|
+
id: projectId,
|
|
1300
|
+
proposalIds: proposalSel,
|
|
1301
|
+
aspectRatios: Object.fromEntries(proposalSel.map((id) => [id, ratios[id] || '16:9'])),
|
|
1302
|
+
overrides: Object.fromEntries(proposalSel.map((id) => [id, {
|
|
1303
|
+
layout: layOv[id] || undefined,
|
|
1304
|
+
style: styOv[id] || undefined,
|
|
1305
|
+
}])),
|
|
1306
|
+
})
|
|
1307
|
+
// busy 保持为 true;render.json 的 finishedAt 出现后 effect 收尾
|
|
1308
|
+
} catch (e) {
|
|
1309
|
+
setRenderErrors([{ id: projectId, error: String(e && e.message ? e.message : e) }])
|
|
1310
|
+
setRenderBusy(false)
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
async function confirmMeta() {
|
|
1315
|
+
if (metaBusy) return
|
|
1316
|
+
setMetaBusy(true)
|
|
1317
|
+
try {
|
|
1318
|
+
// 标题/摘要的修改在编辑时已自动写盘;确认只做「进入提取」这一个决定。
|
|
1319
|
+
await rpc(ctx, 'project.confirmMeta', { id: projectId })
|
|
1320
|
+
await drivePrompt(ctx, projectId, '用户已确认文档信息,开始提取结构。')
|
|
1321
|
+
setStep('extract')
|
|
1322
|
+
setTick((x) => x + 1)
|
|
1323
|
+
} catch (e) { console.error(e) } finally { setMetaBusy(false) }
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
function sendDiscussion() {
|
|
1327
|
+
const msg = discuss.trim()
|
|
1328
|
+
if (!msg) return
|
|
1329
|
+
drivePrompt(ctx, projectId, msg).catch((e) => console.error(e))
|
|
1330
|
+
setDiscuss('')
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
function rename() {
|
|
1334
|
+
const title = renameValue.trim()
|
|
1335
|
+
if (!title) { setRenaming(false); return }
|
|
1336
|
+
rpc(ctx, 'project.rename', { id: projectId, title }).then(() => setRenaming(false)).catch(() => setRenaming(false))
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
const stepBody = {
|
|
1340
|
+
meta: h(MetaPanel, {
|
|
1341
|
+
meta: data.meta || null, documentTitle: metaTitle,
|
|
1342
|
+
onDocumentTitle: (v) => { metaDirty.current = true; setMetaTitle(v); saveMetaNext(v, metaSummary) },
|
|
1343
|
+
documentSummary: metaSummary,
|
|
1344
|
+
onDocumentSummary: (v) => { metaDirty.current = true; setMetaSummary(v); saveMetaNext(metaTitle, v) },
|
|
1345
|
+
projectTitle: data.record.title || '', onConfirm: confirmMeta, busy: metaBusy,
|
|
1346
|
+
}),
|
|
1347
|
+
extract: h(StructuresPanel, { stage, structures, selected: structuresSel,
|
|
1348
|
+
steps, running: Boolean(data.running),
|
|
1349
|
+
onToggle: (id) => setStructuresSel((p) => p.includes(id) ? p.filter((x) => x !== id) : [...p, id]),
|
|
1350
|
+
disabled: stage !== 'gated:structure', onConfirm: confirmStructures, document: data.document,
|
|
1351
|
+
onToggleAll: () => setStructuresSel(structuresSel.length === structures.length ? [] : structures.map((x) => x.id)),
|
|
1352
|
+
seedKey: data.structuresJson,
|
|
1353
|
+
onSaveStructures: async (items) => {
|
|
1354
|
+
await rpc(ctx, 'project.patchStructures', { id: projectId, structures: items })
|
|
1355
|
+
setTick((x) => x + 1)
|
|
1356
|
+
} }),
|
|
1357
|
+
advise: h(ProposalsPanel, { stage, proposals, suggestedStyle: styleName, steps, selected: proposalSel,
|
|
1358
|
+
onToggle: (id) => setProposalSel((p) => p.includes(id) ? p.filter((x) => x !== id) : [...p, id]),
|
|
1359
|
+
allSelected: proposalSel.length > 0 && proposalSel.length === proposals.length,
|
|
1360
|
+
onToggleAll: () => setProposalSel(proposalSel.length === proposals.length ? [] : proposals.map((x) => x.id)),
|
|
1361
|
+
layouts: layoutOptions, styles: styleOptions,
|
|
1362
|
+
lay: layOv, sty: styOv, setLay: setLayWrap, setSty: setStyWrap,
|
|
1363
|
+
promptId, setPromptId,
|
|
1364
|
+
ratios, setRatio, onRender: renderSelected, renderBusy,
|
|
1365
|
+
hasImageKey: Boolean(imageCfg && imageCfg.hasKey), running: Boolean(data.running),
|
|
1366
|
+
renderState: data.renderState, renderRunning: Boolean(data.renderRunning) }),
|
|
1367
|
+
render: h(ResultsPanel, { outputs: data.outputs, images, renderErrors, proposals, lay: layOv, renderBatches: data.renderBatches }),
|
|
1368
|
+
}[step]
|
|
1369
|
+
|
|
1370
|
+
return h('div', null,
|
|
1371
|
+
h('div', { className: 'pt-infobar' },
|
|
1372
|
+
renaming
|
|
1373
|
+
? h('input', { className: 'pt-input pt-rename-input', value: renameValue, autoFocus: true,
|
|
1374
|
+
onChange: (e) => setRenameValue(e.target.value), onBlur: rename,
|
|
1375
|
+
onKeyDown: (e) => { if (e.key === 'Enter') rename() } })
|
|
1376
|
+
: h('h1', null, record.title),
|
|
1377
|
+
h('button', { className: 'pt-btn pt-btn-ghost', style: { padding: '4px 10px', fontSize: 13 },
|
|
1378
|
+
onClick: () => { setRenaming(true); setRenameValue(record.title) } }, t('rename')),
|
|
1379
|
+
h('button', { className: 'pt-btn pt-btn-ghost', style: { padding: '4px 10px', fontSize: 13, color: '#b91c1c' },
|
|
1380
|
+
onClick: deleteProject }, t('del')),
|
|
1381
|
+
stageBadge(stage)),
|
|
1382
|
+
h('p', { className: 'pt-meta', style: { marginTop: 2 } },
|
|
1383
|
+
t('metaTime', { c: (record.createdAt || '').slice(0, 16).replace('T', ' '), u: (record.updatedAt || '').slice(0, 16).replace('T', ' ') }) +
|
|
1384
|
+
(data.sourceName ? ' · ' + t('sourceLabel') + ' ' + data.sourceName : '')),
|
|
1385
|
+
|
|
1386
|
+
h(StageStrip, { steps, active: step, onPick: setStep }),
|
|
1387
|
+
|
|
1388
|
+
h('section', { className: 'pt-section' }, stepBody),
|
|
1389
|
+
|
|
1390
|
+
h('div', { className: 'pt-fold' },
|
|
1391
|
+
h('button', { className: 'pt-fold-head', onClick: () => setShowDiscuss(!showDiscuss) },
|
|
1392
|
+
h('span', null, t('discussion')),
|
|
1393
|
+
h('span', { className: 'chev' }, showDiscuss ? '▾' : '▸')),
|
|
1394
|
+
showDiscuss ? h('div', { className: 'pt-fold-body' },
|
|
1395
|
+
h('p', { className: 'pt-detail' }, t('discussHint')),
|
|
1396
|
+
h('textarea', { className: 'pt-input pt-textarea', style: { marginTop: 10 }, value: discuss,
|
|
1397
|
+
placeholder: t('discussPlaceholder'),
|
|
1398
|
+
onChange: (e) => setDiscuss(e.target.value) }),
|
|
1399
|
+
h('div', { className: 'pt-actions' },
|
|
1400
|
+
h('button', { className: 'pt-btn', disabled: !discuss.trim(), onClick: sendDiscussion }, t('send')))) : null))
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
// ---------- 设置 ----------
|
|
1404
|
+
|
|
1405
|
+
/** 各 provider 的内置默认端点(image-providers.ts 同源);留空即用默认,可覆盖(如方舟其它区/网关)。 */
|
|
1406
|
+
function defaultBaseUrl(provider) {
|
|
1407
|
+
if (provider === 'seedream') return 'https://ark.cn-beijing.volces.com/api/v3'
|
|
1408
|
+
if (provider === 'gemini') return 'https://generativelanguage.googleapis.com'
|
|
1409
|
+
if (provider === 'dashscope') return 'https://dashscope.aliyuncs.com/api/v1'
|
|
1410
|
+
return '必填:OpenAI 兼容端点,如 https://…/api/v3'
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
function SettingsPane(props) {
|
|
1414
|
+
const { ctx, onBack } = props
|
|
1415
|
+
const [cfg, setCfg] = React.useState(null)
|
|
1416
|
+
const [provider, setProvider] = React.useState('seedream')
|
|
1417
|
+
const [baseUrl, setBaseUrl] = React.useState('')
|
|
1418
|
+
const [model, setModel] = React.useState('')
|
|
1419
|
+
const [apiKey, setApiKey] = React.useState('')
|
|
1420
|
+
const [extraText, setExtraText] = React.useState('')
|
|
1421
|
+
const [busy, setBusy] = React.useState(false)
|
|
1422
|
+
const [msg, setMsg] = React.useState(null)
|
|
1423
|
+
|
|
1424
|
+
React.useEffect(() => {
|
|
1425
|
+
rpc(ctx, 'config.get').then((c) => {
|
|
1426
|
+
if (!c) return
|
|
1427
|
+
setCfg(c)
|
|
1428
|
+
const im = c.image || {}
|
|
1429
|
+
setProvider(im.provider || 'seedream')
|
|
1430
|
+
setBaseUrl(im.baseUrl || '')
|
|
1431
|
+
setModel(im.model || '')
|
|
1432
|
+
if (im.extra && Object.keys(im.extra).length) setExtraText(JSON.stringify(im.extra, null, 2))
|
|
1433
|
+
}).catch((e) => console.error(e))
|
|
1434
|
+
}, [ctx])
|
|
1435
|
+
|
|
1436
|
+
async function save() {
|
|
1437
|
+
if (busy || !cfg) return
|
|
1438
|
+
setBusy(true)
|
|
1439
|
+
setMsg(null)
|
|
1440
|
+
try {
|
|
1441
|
+
const res = await rpc(ctx, 'config.set', {
|
|
1442
|
+
image: { provider, baseUrl, model, apiKey: apiKey || undefined, extra: extraText },
|
|
1443
|
+
})
|
|
1444
|
+
setApiKey('')
|
|
1445
|
+
const test = await rpc(ctx, 'config.test')
|
|
1446
|
+
setMsg({ ok: true, text: t('savedTest') + (test && test.size ? test.size : t('testOk')) })
|
|
1447
|
+
} catch (e) {
|
|
1448
|
+
setMsg({ ok: false, text: String(e && e.message ? e.message : e) })
|
|
1449
|
+
} finally {
|
|
1450
|
+
setBusy(false)
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
return h('div', null,
|
|
1455
|
+
h('h1', { className: 'pt-title' }, t('settingsTitle')),
|
|
1456
|
+
h('p', { className: 'pt-subtitle' }, t('settingsSubtitle')),
|
|
1457
|
+
|
|
1458
|
+
cfg ? h('div', { className: 'pt-card' },
|
|
1459
|
+
h('div', { className: 'pt-field' },
|
|
1460
|
+
h('label', { className: 'pt-field-label' }, t('fieldProvider')),
|
|
1461
|
+
h('select', { className: 'pt-input', value: provider, onChange: (e) => setProvider(e.target.value) },
|
|
1462
|
+
['seedream', 'gemini', 'dashscope', 'openai-compatible', 'mock'].map((p) => h('option', { key: p, value: p }, p)))),
|
|
1463
|
+
provider !== 'mock'
|
|
1464
|
+
? h('div', { className: 'pt-field', style: { marginTop: 14 } },
|
|
1465
|
+
h('label', { className: 'pt-field-label' }, t('fieldBaseUrl')),
|
|
1466
|
+
h('input', { className: 'pt-input', value: baseUrl, onChange: (e) => setBaseUrl(e.target.value),
|
|
1467
|
+
placeholder: defaultBaseUrl(provider) }))
|
|
1468
|
+
: null,
|
|
1469
|
+
h('div', { className: 'pt-field', style: { marginTop: 14 } },
|
|
1470
|
+
h('label', { className: 'pt-field-label' }, t('fieldModel')),
|
|
1471
|
+
h('input', { className: 'pt-input', value: model, onChange: (e) => setModel(e.target.value),
|
|
1472
|
+
placeholder: provider === 'seedream' ? 'doubao-seedream-4-5-251128' : provider === 'gemini' ? 'gemini-3-pro-image-preview' : provider === 'dashscope' ? 'qwen-image-3.0-pro' : '' })),
|
|
1473
|
+
h('div', { className: 'pt-field', style: { marginTop: 14 } },
|
|
1474
|
+
h('label', { className: 'pt-field-label' }, t('fieldExtra')),
|
|
1475
|
+
h('textarea', { className: 'pt-input', style: { minHeight: 64, fontFamily: 'ui-monospace, Menlo, monospace', fontSize: 13 }, value: extraText, onChange: (e) => setExtraText(e.target.value),
|
|
1476
|
+
placeholder: '{}' }),
|
|
1477
|
+
h('p', { className: 'pt-detail', style: { marginTop: 6 } }, t('extraHint'))),
|
|
1478
|
+
h('div', { className: 'pt-field', style: { marginTop: 14 } },
|
|
1479
|
+
h('label', { className: 'pt-field-label' },
|
|
1480
|
+
t('fieldKey') + (cfg.image && cfg.image.hasKey ? t('keySet') : '')),
|
|
1481
|
+
h('input', { className: 'pt-input', type: 'password', value: apiKey, onChange: (e) => setApiKey(e.target.value),
|
|
1482
|
+
placeholder: cfg.image && cfg.image.hasKey ? t('keyPlaceholderSet') : t('keyPlaceholder') })),
|
|
1483
|
+
cfg.image && cfg.image.keyStorage === 'file'
|
|
1484
|
+
? h('p', { className: 'pt-warn', style: { marginTop: 10 } }, t('keyStorageFile'))
|
|
1485
|
+
: null,
|
|
1486
|
+
msg ? h('p', { className: msg.ok ? 'pt-detail' : 'pt-error', style: { marginTop: 12 } }, msg.text) : null,
|
|
1487
|
+
h('div', { className: 'pt-actions' },
|
|
1488
|
+
h('button', { className: 'pt-btn', onClick: onBack }, t('back')),
|
|
1489
|
+
h('button', { className: 'pt-btn pt-btn-primary', onClick: save, disabled: busy }, busy ? t('saving') : t('saveTest'))))
|
|
1490
|
+
: h('p', { className: 'pt-meta' }, t('loading')))
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
// ---------- 工作台 ----------
|
|
1494
|
+
|
|
1495
|
+
function Workbench(props) {
|
|
1496
|
+
useLang() // 语言切换时整工作台重渲染
|
|
1497
|
+
const ctx = props.ctx
|
|
1498
|
+
const [projects, setProjects] = React.useState([])
|
|
1499
|
+
const [selected, setSelected] = React.useState(null)
|
|
1500
|
+
const [view, setView] = React.useState('list') // list | new | settings
|
|
1501
|
+
const [thumb, setThumb] = React.useState({})
|
|
1502
|
+
|
|
1503
|
+
async function reload() {
|
|
1504
|
+
try {
|
|
1505
|
+
const data = await rpc(ctx, 'project.list')
|
|
1506
|
+
if (data && data.projects) setProjects(data.projects)
|
|
1507
|
+
} catch (e) {
|
|
1508
|
+
console.error(e)
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
React.useEffect(() => {
|
|
1513
|
+
reload()
|
|
1514
|
+
const t = setInterval(reload, 3000)
|
|
1515
|
+
return () => clearInterval(t)
|
|
1516
|
+
}, [])
|
|
1517
|
+
|
|
1518
|
+
async function openProject(id) {
|
|
1519
|
+
setSelected(id)
|
|
1520
|
+
setView('list')
|
|
1521
|
+
try {
|
|
1522
|
+
const data = await rpc(ctx, 'project.get', { id })
|
|
1523
|
+
if (data && data.outputs && data.outputs.length) {
|
|
1524
|
+
const first = await rpc(ctx, 'project.image', { id, name: data.outputs[0] })
|
|
1525
|
+
if (first && first.dataUrl) setThumb((p) => ({ ...p, [id]: first.dataUrl }))
|
|
1526
|
+
}
|
|
1527
|
+
} catch (e) { console.error(e) }
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
async function removeProject(id) {
|
|
1531
|
+
if (!window.confirm(t('delConfirm', { t: projects.find((p) => p.id === id)?.title || id }))) return
|
|
1532
|
+
try {
|
|
1533
|
+
await rpc(ctx, 'project.delete', { id })
|
|
1534
|
+
if (selected === id) setSelected(null)
|
|
1535
|
+
await reload()
|
|
1536
|
+
} catch (e) { console.error(e) }
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
let body
|
|
1540
|
+
if (view === 'new') {
|
|
1541
|
+
body = h('div', { className: 'pt-wrap' }, h(NewProjectPane, { ctx,
|
|
1542
|
+
onCreated: (id) => openProject(id) }))
|
|
1543
|
+
} else if (view === 'settings') {
|
|
1544
|
+
body = h('div', { className: 'pt-wrap' }, h(SettingsPane, { ctx, onBack: () => setView('list') }))
|
|
1545
|
+
} else if (selected) {
|
|
1546
|
+
body = h('div', { className: 'pt-wrap' }, h(DetailPane, { ctx, projectId: selected,
|
|
1547
|
+
onDeleted: () => { setSelected(null); setView('list'); reload() } }))
|
|
1548
|
+
} else {
|
|
1549
|
+
body = h('div', { className: 'pt-wrap' },
|
|
1550
|
+
h('div', { style: { minHeight: '62vh', display: 'flex', flexDirection: 'column', justifyContent: 'center' } },
|
|
1551
|
+
h(EmptyState, {
|
|
1552
|
+
title: t('emptyProjects'),
|
|
1553
|
+
hint: t('emptyProjectsHint'),
|
|
1554
|
+
})),
|
|
1555
|
+
h('div', { className: 'pt-credits' },
|
|
1556
|
+
t('creditsPre'),
|
|
1557
|
+
h('a', { href: 'https://github.com/jimliu/baoyu-skills', target: '_blank', rel: 'noopener noreferrer' }, 'baoyu-skills'),
|
|
1558
|
+
t('creditsPost')))
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
// 左栏:项目列表
|
|
1562
|
+
const nav = h('div', { className: 'pt-nav' },
|
|
1563
|
+
h('div', { className: 'pt-nav-head' },
|
|
1564
|
+
h('div', { className: 'pt-nav-titles' },
|
|
1565
|
+
h('h2', { className: 'pt-nav-title' }, 'Pictor'),
|
|
1566
|
+
h('p', { className: 'pt-nav-sub' }, t('tagline'))),
|
|
1567
|
+
h('button', { className: 'pt-nav-new', onClick: () => setView('new') }, t('newShort'))),
|
|
1568
|
+
h('div', { className: 'pt-nav-list' },
|
|
1569
|
+
projects.length === 0
|
|
1570
|
+
? h('p', { style: { color: 'var(--dsw-alias-label-caption,#999)', fontSize: 13, padding: '12px 10px', margin: 0 } },
|
|
1571
|
+
t('noProjects'))
|
|
1572
|
+
: projects.map((p) => h('button', {
|
|
1573
|
+
key: p.id,
|
|
1574
|
+
className: 'pt-nav-item' + (selected === p.id && view === 'list' ? ' active' : ''),
|
|
1575
|
+
onClick: () => openProject(p.id),
|
|
1576
|
+
onContextMenu: (e) => { e.preventDefault(); removeProject(p.id) },
|
|
1577
|
+
},
|
|
1578
|
+
thumb[p.id]
|
|
1579
|
+
? h('img', { className: 'thumb', src: thumb[p.id], alt: '' })
|
|
1580
|
+
: h('span', { className: 'thumb thumb-empty' },
|
|
1581
|
+
h('svg', { width: 14, height: 14, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.5 },
|
|
1582
|
+
h('path', { d: 'M9 12h6m-6 4h6m2 5H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5.586a1 1 0 0 1 .707.293l5.414 5.414a1 1 0 0 1 .293.707V19a2 2 0 0 1-2 2z' }))),
|
|
1583
|
+
h('div', { style: { flex: 1, minWidth: 0 } },
|
|
1584
|
+
h('div', { className: 'name' }, p.title),
|
|
1585
|
+
h('div', { className: 'meta' }, stageLabelShort(p.stage) + (p.renderRunning ? ' · ' + t('rendering') : '')))))),
|
|
1586
|
+
h('div', { className: 'pt-nav-foot' },
|
|
1587
|
+
h('button', { className: 'pt-nav-settings', onClick: () => setView('settings') },
|
|
1588
|
+
h('span', { className: 'glyph' }, '⚙'),
|
|
1589
|
+
t('navSettings')),
|
|
1590
|
+
h(LangSwitch, null)))
|
|
1591
|
+
|
|
1592
|
+
return h('div', { className: 'pt-root pt-workbench' },
|
|
1593
|
+
nav,
|
|
1594
|
+
h('div', { className: 'pt-main' }, body))
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
function stageLabelShort(stage) {
|
|
1598
|
+
return {
|
|
1599
|
+
prepared: '准备中', running: t('sRunning'), 'gated:meta': t('sMeta'),
|
|
1600
|
+
'gated:structure': t('sStruct'),
|
|
1601
|
+
'gated:advice': t('sAdvice'), done: t('sDone'),
|
|
1602
|
+
}[stage] || stage
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
// ---------- 入口 ----------
|
|
1606
|
+
|
|
1607
|
+
function parseStructures(structuresJson) {
|
|
1608
|
+
try {
|
|
1609
|
+
const data = JSON.parse(structuresJson || 'null')
|
|
1610
|
+
if (Array.isArray(data)) return data
|
|
1611
|
+
if (data && Array.isArray(data.structures)) return data.structures
|
|
1612
|
+
return []
|
|
1613
|
+
} catch {
|
|
1614
|
+
return []
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
function parseProposals(proposalsJson) {
|
|
1619
|
+
try {
|
|
1620
|
+
const data = JSON.parse(proposalsJson || 'null')
|
|
1621
|
+
if (data && Array.isArray(data.proposals)) return data
|
|
1622
|
+
return null
|
|
1623
|
+
} catch {
|
|
1624
|
+
return null
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
function selectedLabel(ids) {
|
|
1629
|
+
return ids.join('、')
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
function apply(ctx) {
|
|
1633
|
+
console.info('[dsh-pictor] apply 被调用', { hasSlots: Boolean(ctx && ctx.slots) })
|
|
1634
|
+
if (typeof document !== 'undefined') {
|
|
1635
|
+
const styleId = 'dsh-pictor-style'
|
|
1636
|
+
if (!document.getElementById(styleId)) {
|
|
1637
|
+
const style = document.createElement('style')
|
|
1638
|
+
style.id = styleId
|
|
1639
|
+
style.textContent = STYLE
|
|
1640
|
+
document.head.appendChild(style)
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
const slots = ctx.slots || (ctx.get && ctx.get('slots'))
|
|
1644
|
+
if (!slots) return
|
|
1645
|
+
|
|
1646
|
+
const panel = { open: false, subs: new Set() }
|
|
1647
|
+
panel.emit = () => { for (const fn of panel.subs) fn() }
|
|
1648
|
+
panel.toggle = () => { panel.open = !panel.open; panel.emit() }
|
|
1649
|
+
panel.close = () => { if (panel.open) { panel.open = false; panel.emit() } }
|
|
1650
|
+
panel.subscribe = (fn) => { panel.subs.add(fn); return () => { panel.subs.delete(fn) } }
|
|
1651
|
+
// 点选 dsh 左侧会话(或面板外任意处)自动收起工作台:
|
|
1652
|
+
// 只排除面板本身与 Pictor 启动按钮;侧栏会话此刻在面板左侧始终可点。
|
|
1653
|
+
if (typeof document !== 'undefined') {
|
|
1654
|
+
document.addEventListener('mousedown', (e) => {
|
|
1655
|
+
if (!panel.open) return
|
|
1656
|
+
const t = e.target
|
|
1657
|
+
if (t && typeof t.closest === 'function' && (t.closest('.pt-shell-panel') || t.closest('.pt-footer-action'))) return
|
|
1658
|
+
panel.close()
|
|
1659
|
+
})
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
function usePanelOpen() {
|
|
1663
|
+
if (typeof React.useSyncExternalStore === 'function') {
|
|
1664
|
+
return React.useSyncExternalStore(panel.subscribe.bind(panel), () => panel.open)
|
|
1665
|
+
}
|
|
1666
|
+
const [v, setV] = React.useState(panel.open)
|
|
1667
|
+
React.useEffect(() => panel.subscribe(() => setV(panel.open)), [])
|
|
1668
|
+
return v
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
function WorkbenchPanel() {
|
|
1672
|
+
const open = usePanelOpen()
|
|
1673
|
+
// 透明隔条宽度 = dsh 侧栏(sidebarCol)当前实际宽度,随折叠/展开实时跟随,
|
|
1674
|
+
// 避免猜错宽度在会话区左缘留缝。
|
|
1675
|
+
const [sb, setSb] = React.useState(280)
|
|
1676
|
+
React.useEffect(() => {
|
|
1677
|
+
if (!open) return
|
|
1678
|
+
const el = document.querySelector('[class*="sidebarCol"]')
|
|
1679
|
+
if (!el) return
|
|
1680
|
+
const measure = () => {
|
|
1681
|
+
const w = Math.round(el.getBoundingClientRect().width)
|
|
1682
|
+
if (w > 0) setSb(w)
|
|
1683
|
+
}
|
|
1684
|
+
measure()
|
|
1685
|
+
if (typeof ResizeObserver === 'function') {
|
|
1686
|
+
const ro = new ResizeObserver(measure)
|
|
1687
|
+
ro.observe(el)
|
|
1688
|
+
return () => ro.disconnect()
|
|
1689
|
+
}
|
|
1690
|
+
return undefined
|
|
1691
|
+
}, [open])
|
|
1692
|
+
// 恒挂载 + display 切换:关闭不卸载,重开恢复关闭前的界面
|
|
1693
|
+
return h('div', { className: 'pt-shell-root', style: open ? undefined : { display: 'none' } },
|
|
1694
|
+
h('div', { className: 'pt-shell-nav', style: { width: sb + 'px' } }),
|
|
1695
|
+
h('div', { className: 'pt-shell-panel' },
|
|
1696
|
+
h(Workbench, { ctx, key: 'shell' })))
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
function FooterAction() {
|
|
1700
|
+
useLang() // footer 文案随语言刷新
|
|
1701
|
+
const open = usePanelOpen()
|
|
1702
|
+
return h('div', {
|
|
1703
|
+
className: 'pt-footer-action' + (open ? ' on' : ''),
|
|
1704
|
+
role: 'button',
|
|
1705
|
+
tabIndex: 0,
|
|
1706
|
+
onClick: () => panel.toggle(),
|
|
1707
|
+
onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); panel.toggle() } },
|
|
1708
|
+
title: open ? t('close') : t('open'),
|
|
1709
|
+
'aria-expanded': open ? 'true' : 'false',
|
|
1710
|
+
}, h('span', { className: 'glyph' }, '◈'), 'Pictor')
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
slots.inject('sidebar.footer.action', () => slots.register(
|
|
1714
|
+
{ name: 'sidebar.footer.action', id: 'dsh-pictor', order: 20, label: 'Pictor' },
|
|
1715
|
+
() => h(FooterAction, null),
|
|
1716
|
+
))
|
|
1717
|
+
slots.inject('shell.overlay', () => slots.register(
|
|
1718
|
+
{ name: 'shell.overlay', id: 'dsh-pictor', order: 10, label: 'Pictor' },
|
|
1719
|
+
() => h(WorkbenchPanel, null),
|
|
1720
|
+
))
|
|
1721
|
+
}
|
|
1722
|
+
return { inject, apply }
|
|
1723
|
+
}
|
|
1724
|
+
})
|