@iamsamyiok/agents-chat 3.18.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/README.md +42 -0
- package/app/lib/agent.js +487 -0
- package/app/lib/aux-llm.js +107 -0
- package/app/lib/cards.js +479 -0
- package/app/lib/env.js +47 -0
- package/app/lib/memory.js +202 -0
- package/app/lib/oc.js +244 -0
- package/app/lib/orchestrator.js +1177 -0
- package/app/lib/store.js +686 -0
- package/app/mock/mock-agent.js +125 -0
- package/app/public/cards.html +628 -0
- package/app/public/index.html +3279 -0
- package/app/server.js +1334 -0
- package/bin/agents-chat.js +263 -0
- package/lib/start.js +10 -0
- package/package.json +39 -0
|
@@ -0,0 +1,3279 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Agents 群聊</title>
|
|
7
|
+
<style>
|
|
8
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
9
|
+
html, body { height: 100%; }
|
|
10
|
+
body {
|
|
11
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
|
12
|
+
background: #f2f2f2; color: #191919; overflow: hidden;
|
|
13
|
+
}
|
|
14
|
+
#app { display: flex; height: 100vh; max-width: 1240px; margin: 0 auto; background: #fff; box-shadow: 0 0 24px rgba(0,0,0,.08); }
|
|
15
|
+
|
|
16
|
+
/* ---------- 左侧:会话列表(微信风格:主会话 + 任务会话) ---------- */
|
|
17
|
+
#sidebar { width: 290px; flex-shrink: 0; background: #fff; border-right: 1px solid #e7e7e7; display: flex; flex-direction: column; }
|
|
18
|
+
.side-header { padding: 14px 14px 10px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid #f0f0f0; }
|
|
19
|
+
.side-header h2 { font-size: 15px; font-weight: 600; display: flex; align-items: center; gap: 6px; }
|
|
20
|
+
.badge { font-size: 11px; background: #fa5151; color: #fff; border-radius: 8px; padding: 0 6px; line-height: 16px; }
|
|
21
|
+
.side-actions { display: flex; gap: 6px; }
|
|
22
|
+
.mini-btn { font-size: 12px; color: #07c160; border: 1px solid #07c160; background: #fff; border-radius: 4px; padding: 3px 8px; cursor: pointer; }
|
|
23
|
+
.mini-btn.gray { color: #888; border-color: #ddd; }
|
|
24
|
+
.mini-btn:hover { opacity: .8; }
|
|
25
|
+
|
|
26
|
+
.task-empty { color: #b0b0b0; font-size: 13px; text-align: center; padding: 30px 20px; line-height: 1.8; }
|
|
27
|
+
.list-sep { font-size: 11px; color: #b2b2b2; padding: 10px 14px 4px; background: #fff; position: sticky; top: 0; z-index: 1; }
|
|
28
|
+
.task-item { display: flex; gap: 10px; padding: 12px 14px; border-bottom: 1px solid #f5f5f5; cursor: pointer; align-items: center; position: relative; }
|
|
29
|
+
.task-item:hover { background: #f7f7f7; }
|
|
30
|
+
.task-item.active { background: #eef7f0; }
|
|
31
|
+
/* 未读绿点:会话有未查看的完成消息 */
|
|
32
|
+
.unread-dot { position: absolute; left: 5px; top: 50%; transform: translateY(-50%); width: 8px; height: 8px; border-radius: 50%; background: #07c160; box-shadow: 0 0 0 2px #fff; }
|
|
33
|
+
/* 任务完成右下角提醒弹窗 */
|
|
34
|
+
.done-toast { position: fixed; right: 16px; bottom: 16px; z-index: 80; width: 300px; max-width: calc(100vw - 32px); background: #fff; border: 1px solid #d9f2e3; border-left: 3px solid #07c160; border-radius: 10px; box-shadow: 0 8px 24px rgba(0,0,0,.16); cursor: pointer; overflow: hidden; animation: dt-in .25s ease; }
|
|
35
|
+
@keyframes dt-in { from { transform: translateY(12px); opacity: 0; } to { transform: none; opacity: 1; } }
|
|
36
|
+
.done-toast .dt-head { display: flex; align-items: center; gap: 6px; padding: 10px 12px 4px; font-size: 13px; font-weight: 600; color: #07c160; }
|
|
37
|
+
.done-toast .dt-body { padding: 0 12px 10px; font-size: 12px; color: #555; line-height: 1.6; max-height: 72px; overflow: hidden; }
|
|
38
|
+
.task-avatar { width: 40px; height: 40px; border-radius: 6px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; color: #fff; font-size: 16px; }
|
|
39
|
+
.st-pending { background: #c9cdd4; }
|
|
40
|
+
.st-running { background: #10aeff; animation: pulse 1.2s infinite; }
|
|
41
|
+
.st-done { background: #07c160; }
|
|
42
|
+
.st-failed { background: #fa5151; }
|
|
43
|
+
.main-av { background: linear-gradient(135deg, #57be6a, #10aeff); font-size: 18px; }
|
|
44
|
+
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .55; } }
|
|
45
|
+
.task-main { flex: 1; min-width: 0; }
|
|
46
|
+
.task-title { font-size: 13px; color: #191919; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
47
|
+
.task-sub { font-size: 11px; color: #b2b2b2; margin-top: 3px; display: flex; justify-content: space-between; gap: 6px; }
|
|
48
|
+
.task-sub span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
49
|
+
.side-footer { padding: 10px 14px; border-top: 1px solid #f0f0f0; }
|
|
50
|
+
.run-all { width: 100%; background: #07c160; color: #fff; border: none; border-radius: 6px; padding: 9px 0; font-size: 13px; cursor: pointer; }
|
|
51
|
+
.run-all:hover { background: #06ad56; }
|
|
52
|
+
.run-all:disabled { background: #9ff0c1; cursor: not-allowed; }
|
|
53
|
+
|
|
54
|
+
/* ---------- 右侧:聊天区(微信风格) ---------- */
|
|
55
|
+
#chat { flex: 1; display: flex; flex-direction: column; min-width: 0; background: #f5f5f5; }
|
|
56
|
+
#chatHeader { background: #fff; padding: 12px 18px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid #e7e7e7; gap: 10px; }
|
|
57
|
+
.chat-title { font-size: 15px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 480px; }
|
|
58
|
+
.chat-members { display: flex; align-items: center; gap: 4px; margin-top: 2px; flex-wrap: wrap; }
|
|
59
|
+
.member-avatar { width: 20px; height: 20px; border-radius: 4px; color: #fff; font-size: 10px; display: flex; align-items: center; justify-content: center; }
|
|
60
|
+
.runner-tag { font-size: 11px; color: #888; background: #f2f2f2; padding: 2px 8px; border-radius: 10px; white-space: nowrap; }
|
|
61
|
+
.runner-tag.opencode { color: #07c160; background: #eef7f0; }
|
|
62
|
+
.runner-tag.demo { color: #c2821d; background: #faf3e3; }
|
|
63
|
+
.runner-tag.missing { color: #d54941; background: #fbeceb; }
|
|
64
|
+
.cfg-btn { font-size: 12px; color: #07c160; border: 1px solid #07c160; background: #fff; border-radius: 4px; padding: 4px 10px; cursor: pointer; white-space: nowrap; }
|
|
65
|
+
.cfg-btn:hover { background: #f0faf4; }
|
|
66
|
+
.hdr-right { display: flex; align-items: center; gap: 10px; }
|
|
67
|
+
|
|
68
|
+
#messages { flex: 1; overflow-y: auto; padding: 16px 18px; }
|
|
69
|
+
.sys-tip { text-align: center; margin: 10px 0; }
|
|
70
|
+
.approval-card { max-width: 480px; margin: 10px auto; background: #fff8e6; border: 1px solid #e8a33d; border-radius: 10px; padding: 10px 14px; text-align: left; }
|
|
71
|
+
.approval-card .ap-title { font-weight: 600; font-size: 13px; color: #8a5a00; margin-bottom: 4px; }
|
|
72
|
+
.approval-card .ap-label { font-size: 12px; color: #555; margin-bottom: 8px; word-break: break-all; }
|
|
73
|
+
.approval-card .ap-btns { display: flex; gap: 8px; }
|
|
74
|
+
.approval-card .ap-btns button { flex: 1; border: none; border-radius: 6px; padding: 6px 0; font-size: 13px; cursor: pointer; }
|
|
75
|
+
.approval-card .ap-ok { background: #07c160; color: #fff; }
|
|
76
|
+
.approval-card .ap-no { background: #fa5151; color: #fff; }
|
|
77
|
+
.approval-card .ap-done { font-size: 12px; color: #888; text-align: center; padding: 2px 0; }
|
|
78
|
+
.sys-tip span { font-size: 12px; color: #9e9e9e; background: #e8e8e8; border-radius: 4px; padding: 3px 10px; display: inline-block; max-width: 80%; }
|
|
79
|
+
.msg-row { display: flex; gap: 10px; margin: 14px 0; align-items: flex-start; }
|
|
80
|
+
.msg-row.self { flex-direction: row-reverse; }
|
|
81
|
+
.avatar { width: 38px; height: 38px; border-radius: 6px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; color: #fff; font-size: 14px; }
|
|
82
|
+
.avatar.me { background: #57be6a; }
|
|
83
|
+
.avatar.butler { background: #8a6fe8; }
|
|
84
|
+
.msg-body { max-width: 64%; display: flex; flex-direction: column; }
|
|
85
|
+
.msg-row.self .msg-body { align-items: flex-end; }
|
|
86
|
+
.msg-name { font-size: 11px; color: #9e9e9e; margin-bottom: 3px; padding: 0 2px; }
|
|
87
|
+
.bubble { position: relative; padding: 9px 12px; border-radius: 6px; font-size: 14px; line-height: 1.65; white-space: pre-wrap; word-break: break-word; text-align: left; }
|
|
88
|
+
.bubble.left { background: #fff; }
|
|
89
|
+
.bubble.left::before { content: ""; position: absolute; left: -5px; top: 13px; border: 5px solid transparent; border-right-color: #fff; border-left: none; }
|
|
90
|
+
.bubble.right { background: #95ec69; }
|
|
91
|
+
.bubble.right::after { content: ""; position: absolute; right: -5px; top: 13px; border: 5px solid transparent; border-left-color: #95ec69; border-right: none; }
|
|
92
|
+
.bubble .ph { color: #8a6fe8; font-weight: 600; }
|
|
93
|
+
|
|
94
|
+
/* 调度规划卡片 */
|
|
95
|
+
.bubble.plan-card { background: #fff; border: 1px solid #d9e2ff; max-width: 78%; }
|
|
96
|
+
.bubble.plan-card::before { border-right-color: #fff; }
|
|
97
|
+
.plan-thought { font-size: 13px; color: #555; margin-bottom: 8px; }
|
|
98
|
+
.plan-phase { font-size: 12.5px; margin-top: 6px; padding: 6px 8px; background: #f4f7ff; border-radius: 4px; }
|
|
99
|
+
.plan-phase b { color: #4a6cf7; }
|
|
100
|
+
.plan-step { font-size: 12px; color: #444; margin-top: 4px; line-height: 1.5; }
|
|
101
|
+
.plan-step b { color: #191919; }
|
|
102
|
+
|
|
103
|
+
.typing { display: inline-flex; gap: 4px; padding: 4px 0; }
|
|
104
|
+
.typing i { width: 6px; height: 6px; background: #c9c9c9; border-radius: 50%; animation: blink 1.2s infinite; }
|
|
105
|
+
.typing i:nth-child(2) { animation-delay: .2s; }
|
|
106
|
+
.typing i:nth-child(3) { animation-delay: .4s; }
|
|
107
|
+
@keyframes blink { 0%,80%,100% { opacity: .25; } 40% { opacity: 1; } }
|
|
108
|
+
|
|
109
|
+
.composer { background: #fff; border-top: 1px solid #e7e7e7; padding: 12px 14px; position: relative; }
|
|
110
|
+
.input-row { display: flex; gap: 10px; }
|
|
111
|
+
#input, #soloInput { flex: 1; border: 1px solid #e2e2e2; border-radius: 6px; padding: 10px 12px; font-size: 14px; outline: none; }
|
|
112
|
+
#input:focus, #soloInput:focus { border-color: #07c160; }
|
|
113
|
+
#sendBtn, #soloSendBtn { background: #07c160; color: #fff; border: none; border-radius: 6px; padding: 0 22px; font-size: 14px; cursor: pointer; }
|
|
114
|
+
#rtBtn { background: #f2f3f5; color: #555; border: none; border-radius: 6px; padding: 0 14px; font-size: 13px; cursor: pointer; white-space: nowrap; }
|
|
115
|
+
#rtBtn:hover { background: #e8eaee; }
|
|
116
|
+
#rtBtn.on { background: #8a6fe8; color: #fff; }
|
|
117
|
+
#sendBtn:disabled, #soloSendBtn:disabled { background: #9ff0c1; cursor: not-allowed; }
|
|
118
|
+
#sendBtn.stop, #soloSendBtn.stop, .run-all.stop { background: #fa5151; color: #fff; }
|
|
119
|
+
#sendBtn.stop:hover, #soloSendBtn.stop:hover, .run-all.stop:hover { background: #e04840; }
|
|
120
|
+
.msg-file { font-size: 11px; color: #8a6fe8; background: #f4f1fd; border-radius: 4px; padding: 3px 8px; margin-top: 4px; max-width: 100%; word-break: break-all; cursor: default; }
|
|
121
|
+
|
|
122
|
+
/* 过程内容折叠:默认约 3 行,点击展开 */
|
|
123
|
+
.fold-wrap { position: relative; max-height: 70px; overflow: hidden; }
|
|
124
|
+
.fold-wrap::after { content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 26px; background: linear-gradient(rgba(255,255,255,0), #fff); pointer-events: none; }
|
|
125
|
+
.fold-wrap.expanded { max-height: none; }
|
|
126
|
+
.fold-wrap.expanded::after { display: none; }
|
|
127
|
+
.fold-toggle { font-size: 11.5px; color: #07c160; cursor: pointer; margin-top: 3px; user-select: none; }
|
|
128
|
+
.fold-toggle:hover { text-decoration: underline; }
|
|
129
|
+
|
|
130
|
+
/* 工作中动画指示条 */
|
|
131
|
+
.working-bar { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: #4a6cf7; background: #f0f4ff; border-radius: 14px; padding: 4px 12px; margin: 6px 0; }
|
|
132
|
+
.working-bar .wb-ic { font-size: 15px; display: inline-block; animation: wb-bounce 1s ease-in-out infinite; }
|
|
133
|
+
.working-bar .wb-tx { animation: wb-fade 1.6s ease-in-out infinite; }
|
|
134
|
+
@keyframes wb-bounce { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-3px); } }
|
|
135
|
+
@keyframes wb-fade { 0%,100% { opacity: 1; } 50% { opacity: .45; } }
|
|
136
|
+
|
|
137
|
+
/* 导入弹窗 tab */
|
|
138
|
+
.tab-row { display: flex; gap: 8px; margin-bottom: 12px; }
|
|
139
|
+
.tab-row .tab { flex: 1; border: 1px solid #ddd; background: #fff; border-radius: 6px; padding: 8px 0; font-size: 13px; cursor: pointer; }
|
|
140
|
+
.tab-row .tab.active { border-color: #07c160; color: #07c160; background: #f0faf4; font-weight: 600; }
|
|
141
|
+
.ta-wrap { position: relative; }
|
|
142
|
+
#importAtMenu { display: none; position: absolute; left: 0; right: 0; top: calc(100% + 4px); background: #fff; border: 1px solid #e2e2e2; border-radius: 8px; box-shadow: 0 6px 20px rgba(0,0,0,.14); max-height: 220px; overflow-y: auto; z-index: 30; }
|
|
143
|
+
#importAtMenu .at-item { padding: 7px 12px; display: flex; gap: 8px; align-items: center; cursor: pointer; }
|
|
144
|
+
#importAtMenu .at-item:hover, #importAtMenu .at-item.active { background: #f0f7f0; }
|
|
145
|
+
|
|
146
|
+
/* 任务视图切换(顺序/定时同一位置)+ 定时条目 */
|
|
147
|
+
.task-tabs { display: flex; gap: 6px; padding: 10px 12px 0; border-bottom: 1px solid #f0f0f0; }
|
|
148
|
+
.ttab { flex: 1; border: 1px solid #ddd; background: #fff; border-radius: 6px; padding: 7px 0; font-size: 12.5px; cursor: pointer; color: #555; }
|
|
149
|
+
.ttab.active { border-color: #07c160; color: #07c160; background: #f0faf4; font-weight: 600; }
|
|
150
|
+
.sched-time { color: #4a6cf7; white-space: nowrap; font-size: 11px; }
|
|
151
|
+
.sched-btn { width: 100%; border: 1px solid #ddd; color: #555; background: #fff; border-radius: 6px; padding: 8px 0; font-size: 13px; cursor: pointer; margin-bottom: 8px; }
|
|
152
|
+
.sched-btn:hover { border-color: #07c160; color: #07c160; }
|
|
153
|
+
.st-scheduled { background: #8a6fe8; }
|
|
154
|
+
.run-all.off { background: #fff; color: #d54941; border: 1px solid #ecc; }
|
|
155
|
+
|
|
156
|
+
/* 图标选择 */
|
|
157
|
+
.icon-row { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 8px; }
|
|
158
|
+
.icon-pick { width: 30px; height: 30px; display: flex; align-items: center; justify-content: center; font-size: 16px; border: 1px solid #e5e5e5; border-radius: 6px; background: #fff; cursor: pointer; }
|
|
159
|
+
.icon-pick:hover { border-color: #07c160; }
|
|
160
|
+
.icon-pick.active { border-color: #07c160; background: #f0faf4; box-shadow: 0 0 0 1px #07c160 inset; }
|
|
161
|
+
.global-cwd-box { border: 1px solid #d9e2ff; background: #f7f9ff; border-radius: 8px; padding: 10px 12px; margin-bottom: 12px; font-size: 13px; }
|
|
162
|
+
.global-cwd-box .lbl { font-weight: 600; color: #4a6cf7; margin-bottom: 6px; }
|
|
163
|
+
|
|
164
|
+
/* 帮助弹窗 */
|
|
165
|
+
.help-sec { margin-bottom: 14px; }
|
|
166
|
+
.help-sec h4 { font-size: 13.5px; color: #07c160; margin-bottom: 4px; }
|
|
167
|
+
.help-sec p, .help-sec li { font-size: 12.5px; color: #444; line-height: 1.8; }
|
|
168
|
+
.help-sec ul, .help-sec ol { padding-left: 18px; }
|
|
169
|
+
.help-kernel { font-size: 12.5px; border-radius: 6px; padding: 8px 12px; margin-bottom: 12px; line-height: 1.6; }
|
|
170
|
+
.help-kernel.ok { background: #eef7f0; color: #0a7a43; }
|
|
171
|
+
.help-kernel.demo { background: #faf3e3; color: #8a6116; }
|
|
172
|
+
.help-kernel.missing { background: #fbeceb; color: #c0392b; }
|
|
173
|
+
.help-kernel code { background: rgba(0,0,0,.06); border-radius: 3px; padding: 0 5px; }
|
|
174
|
+
|
|
175
|
+
/* @ 智能体补全菜单 */
|
|
176
|
+
#atMenu { display: none; position: absolute; bottom: 100%; left: 14px; right: 14px; margin-bottom: 6px; background: #fff; border: 1px solid #e2e2e2; border-radius: 8px; box-shadow: 0 -4px 20px rgba(0,0,0,.12); max-height: 264px; overflow-y: auto; z-index: 20; }
|
|
177
|
+
.at-item { padding: 8px 12px; display: flex; gap: 10px; align-items: center; cursor: pointer; }
|
|
178
|
+
.at-item:hover, .at-item.active { background: #f0f7f0; }
|
|
179
|
+
.at-av { width: 30px; height: 30px; border-radius: 6px; color: #fff; font-size: 13px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
|
180
|
+
.at-tx { font-size: 13px; line-height: 1.4; min-width: 0; }
|
|
181
|
+
.at-tx i { font-style: normal; color: #999; font-size: 11px; display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 380px; }
|
|
182
|
+
|
|
183
|
+
/* ---------- 弹窗 ---------- */
|
|
184
|
+
#modalMask, #batchMask, #agentsMask, #historyMask, #helpMask, #flowMask, #cardMask, #teamsMask, #memMask { position: fixed; inset: 0; background: rgba(0,0,0,.4); display: none; align-items: center; justify-content: center; z-index: 51; }
|
|
185
|
+
#modalMask.show, #batchMask.show, #agentsMask.show, #historyMask.show, #helpMask.show, #flowMask.show, #cardMask.show, #teamsMask.show, #memMask.show { display: flex; }
|
|
186
|
+
/* 团队仓库卡片 */
|
|
187
|
+
.team-card { border: 1px solid #e5e7eb; border-radius: 10px; padding: 14px 16px; margin-bottom: 12px; }
|
|
188
|
+
.team-card .tc-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
|
|
189
|
+
.team-card .tc-head b { font-size: 15px; }
|
|
190
|
+
.team-card .tc-src { font-size: 11px; color: #10aeff; background: #eef7ff; border-radius: 4px; padding: 1px 6px; }
|
|
191
|
+
.team-card .tc-desc { font-size: 12px; color: #555; line-height: 1.6; margin-bottom: 8px; }
|
|
192
|
+
.team-card .tc-members { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
|
|
193
|
+
.team-card .tc-member { font-size: 12px; background: #f2f3f5; border-radius: 6px; padding: 3px 8px; }
|
|
194
|
+
.team-card .tc-usage { font-size: 11px; color: #8a6fe8; margin-bottom: 10px; }
|
|
195
|
+
.team-card .tc-actions { display: flex; gap: 8px; }
|
|
196
|
+
/* 流转视图 */
|
|
197
|
+
#flowMask .modal.flow { width: min(1100px, 94vw); height: min(760px, 92vh); display: flex; flex-direction: column; padding: 0; overflow: hidden; }
|
|
198
|
+
.flow-bar { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-bottom: 1px solid #eee; flex: none; }
|
|
199
|
+
.flow-bar h3 { margin: 0; font-size: 15px; }
|
|
200
|
+
.flow-bar select { flex: 1; min-width: 0; border: 1px solid #ddd; border-radius: 6px; padding: 5px 8px; font-size: 12px; background: #fff; }
|
|
201
|
+
.flow-legend { display: flex; gap: 10px; padding: 4px 14px 8px; font-size: 11px; color: #666; flex-wrap: wrap; flex: none; }
|
|
202
|
+
.flow-legend i { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 3px; vertical-align: -1px; }
|
|
203
|
+
.flow-canvas { flex: 1; overflow: auto; background: #fbfcfe; }
|
|
204
|
+
.flow-svg .lane-line { stroke: #e3e8f2; stroke-width: 1; }
|
|
205
|
+
.flow-svg .lane-label { font-size: 12px; fill: #333; font-weight: 600; }
|
|
206
|
+
.flow-svg .lane-sub { font-size: 10px; fill: #999; }
|
|
207
|
+
.flow-svg .ev-block { fill: #d9f2e3; stroke: #07c160; stroke-width: 1.2; rx: 6; cursor: pointer; }
|
|
208
|
+
.flow-svg .ev-block.err { fill: #fdeceb; stroke: #fa5151; }
|
|
209
|
+
.flow-svg .ev-block.running { stroke-dasharray: 5 3; animation: flowdash 1.2s linear infinite; }
|
|
210
|
+
@keyframes flowdash { to { stroke-dashoffset: -16; } }
|
|
211
|
+
.flow-svg .ev-block:hover { stroke-width: 2.4; }
|
|
212
|
+
.flow-svg .edge { fill: none; stroke: #9aa8c7; stroke-width: 1.4; cursor: pointer; }
|
|
213
|
+
.flow-svg .edge:hover { stroke-width: 2.6; }
|
|
214
|
+
.flow-svg .edge.handoff { stroke: #4a6cf7; }
|
|
215
|
+
.flow-svg .edge.rework { stroke: #e8a33d; }
|
|
216
|
+
.flow-svg .edge-label { font-size: 10px; fill: #556; }
|
|
217
|
+
.flow-svg .ev-dot { cursor: pointer; }
|
|
218
|
+
.flow-svg .ev-dot:hover { stroke-width: 3; }
|
|
219
|
+
.flow-detail { flex: none; max-height: 190px; overflow: auto; border-top: 1px solid #eee; padding: 10px 14px; font-size: 12px; background: #fff; white-space: pre-wrap; word-break: break-all; }
|
|
220
|
+
.flow-detail b { color: #333; }
|
|
221
|
+
.flow-detail .fd-title { font-weight: 600; margin-bottom: 6px; }
|
|
222
|
+
.flow-detail .fd-files { color: #7d5ac8; }
|
|
223
|
+
.modal { background: #fff; border-radius: 10px; width: 560px; max-width: 92vw; max-height: 88vh; overflow-y: auto; padding: 20px; }
|
|
224
|
+
.modal h3 { font-size: 15px; margin-bottom: 6px; }
|
|
225
|
+
.modal .tip { font-size: 12px; color: #999; line-height: 1.7; margin-bottom: 10px; }
|
|
226
|
+
.modal textarea { width: 100%; height: 200px; border: 1px solid #ddd; border-radius: 6px; padding: 10px; font-size: 13px; resize: vertical; outline: none; }
|
|
227
|
+
.modal textarea:focus { border-color: #07c160; }
|
|
228
|
+
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 14px; }
|
|
229
|
+
.modal-actions button { border-radius: 6px; padding: 8px 20px; font-size: 13px; cursor: pointer; border: 1px solid #ddd; background: #fff; }
|
|
230
|
+
.modal-actions .primary { background: #07c160; color: #fff; border-color: #07c160; }
|
|
231
|
+
|
|
232
|
+
/* 智能体配置卡片 */
|
|
233
|
+
.butler-ro { border: 1px solid #e3e3e3; background: #fafafa; border-radius: 8px; padding: 10px 12px; margin-bottom: 12px; font-size: 13px; line-height: 1.6; }
|
|
234
|
+
.butler-ro b { color: #8a6fe8; }
|
|
235
|
+
.butler-ro .lock { font-size: 11px; color: #999; background: #f0f0f0; border-radius: 4px; padding: 1px 6px; margin-left: 6px; }
|
|
236
|
+
.ag-card { border: 1px solid #ececec; border-radius: 8px; padding: 10px 12px; margin-bottom: 10px; }
|
|
237
|
+
.ag-row { display: flex; gap: 8px; margin-bottom: 8px; }
|
|
238
|
+
.ag-row input { flex: 1; min-width: 0; border: 1px solid #ddd; border-radius: 6px; padding: 6px 10px; font-size: 13px; outline: none; }
|
|
239
|
+
.ag-row input:focus { border-color: #07c160; }
|
|
240
|
+
.ag-row select { flex: 1; min-width: 0; border: 1px solid #cdd8f8; border-radius: 6px; padding: 6px 10px; font-size: 13px; outline: none; background: #fff; color: #333; }
|
|
241
|
+
.ag-row select:focus { border-color: #4a6cf7; }
|
|
242
|
+
.ag-card > input { width: 100%; border: 1px solid #ddd; border-radius: 6px; padding: 6px 10px; font-size: 13px; outline: none; margin-bottom: 8px; }
|
|
243
|
+
.ag-card > input:focus { border-color: #07c160; }
|
|
244
|
+
.ag-card textarea { width: 100%; height: 64px; border: 1px solid #ddd; border-radius: 6px; padding: 6px 10px; font-size: 13px; resize: vertical; outline: none; }
|
|
245
|
+
.ag-card textarea:focus { border-color: #07c160; }
|
|
246
|
+
.add-agent { width: 100%; border: 1px dashed #07c160; color: #07c160; background: #fff; border-radius: 8px; padding: 8px; font-size: 13px; cursor: pointer; }
|
|
247
|
+
.add-agent:hover { background: #f0faf4; }
|
|
248
|
+
|
|
249
|
+
.toast { position: fixed; top: 18px; left: 50%; transform: translateX(-50%); background: rgba(0,0,0,.75); color: #fff; font-size: 13px; padding: 8px 18px; border-radius: 6px; z-index: 99; display: none; }
|
|
250
|
+
|
|
251
|
+
.modal.wide { width: 680px; }
|
|
252
|
+
#historyList { max-height: 62vh; overflow-y: auto; background: #f5f5f5; border-radius: 8px; padding: 12px; }
|
|
253
|
+
|
|
254
|
+
/* ---------- 批量管理任务弹窗 ---------- */
|
|
255
|
+
#batchList { max-height: 56vh; overflow-y: auto; border: 1px solid #eee; border-radius: 8px; margin-bottom: 12px; }
|
|
256
|
+
.batch-group { font-size: 11px; color: #999; padding: 8px 12px 4px; background: #fafafa; position: sticky; top: 0; }
|
|
257
|
+
.batch-item { display: flex; align-items: center; gap: 10px; padding: 8px 12px; border-bottom: 1px solid #f5f5f5; }
|
|
258
|
+
.batch-item:last-child { border-bottom: none; }
|
|
259
|
+
.batch-item input[type="checkbox"] { flex: none; cursor: pointer; }
|
|
260
|
+
.batch-item .bi-title { flex: 1; min-width: 0; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
261
|
+
.batch-item .bi-sub { font-size: 11px; color: #b2b2b2; white-space: nowrap; }
|
|
262
|
+
.bi-state { font-size: 11px; border-radius: 4px; padding: 1px 6px; flex: none; }
|
|
263
|
+
.bi-state.pending { background: #f0f0f0; color: #888; }
|
|
264
|
+
.bi-state.running { background: #e8f4ff; color: #10aeff; }
|
|
265
|
+
.bi-state.done { background: #eef7f0; color: #07c160; }
|
|
266
|
+
.bi-state.failed { background: #fbeceb; color: #fa5151; }
|
|
267
|
+
.batch-bar { display: flex; align-items: center; gap: 10px; }
|
|
268
|
+
.batch-bar label { font-size: 12.5px; color: #555; display: flex; align-items: center; gap: 5px; cursor: pointer; flex: none; }
|
|
269
|
+
.batch-empty { color: #b0b0b0; font-size: 13px; text-align: center; padding: 30px 20px; line-height: 1.8; }
|
|
270
|
+
.task-item[draggable="true"] { cursor: grab; }
|
|
271
|
+
.task-item.drag-over { outline: 2px dashed #07c160; outline-offset: -2px; }
|
|
272
|
+
.mini-btn.danger { color: #d54941; border-color: #ecc; }
|
|
273
|
+
@media (max-width: 760px) {
|
|
274
|
+
#sidebar { position: absolute; left: 0; top: 0; bottom: 0; z-index: 10; transform: translateX(-100%); transition: transform .2s; box-shadow: 4px 0 16px rgba(0,0,0,.1); }
|
|
275
|
+
#sidebar.open { transform: translateX(0); }
|
|
276
|
+
#menuBtn { display: block !important; }
|
|
277
|
+
.msg-body { max-width: 80%; }
|
|
278
|
+
}
|
|
279
|
+
#menuBtn { display: none; background: none; border: none; font-size: 18px; cursor: pointer; color: #555; margin-right: 10px; }
|
|
280
|
+
|
|
281
|
+
/* ---------- 侧栏顶部:单聊/群聊开关 + 任务操作按钮 ---------- */
|
|
282
|
+
.side-header { padding: 12px 12px 10px; display: flex; flex-direction: column; gap: 8px; border-bottom: 1px solid #f0f0f0; }
|
|
283
|
+
.mode-switch { display: inline-flex; border: 1px solid #ddd; border-radius: 8px; overflow: hidden; }
|
|
284
|
+
.mode-switch button { border: none; background: #fff; font-size: 13px; padding: 5px 22px; min-width: 104px; cursor: pointer; color: #666; flex: 1; white-space: nowrap; }
|
|
285
|
+
|
|
286
|
+
/* ---------- 侧栏中部:会话区(上,约5条)+ 任务区(下)各占一半,总高固定 ---------- */
|
|
287
|
+
.side-mid { flex: 1; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
|
|
288
|
+
#sessArea { flex: 0 0 50%; min-height: 0; max-height: 348px; overflow-y: auto; border-bottom: 1px solid #f0f0f0; }
|
|
289
|
+
.task-area { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
|
|
290
|
+
#taskList { flex: 1; min-height: 0; overflow-y: auto; }
|
|
291
|
+
.mode-switch button + button { border-left: 1px solid #ddd; }
|
|
292
|
+
.mode-switch button.active { background: #07c160; color: #fff; font-weight: 600; }
|
|
293
|
+
.side-header .side-actions { display: flex; gap: 6px; width: 100%; }
|
|
294
|
+
.side-header .side-actions .mini-btn { flex: 1; padding: 4px 0; text-align: center; }
|
|
295
|
+
.ttab .badge { margin-left: 4px; vertical-align: 1px; }
|
|
296
|
+
|
|
297
|
+
/* ---------- 单聊工作台(OpenCode):模型输入框 / 过程块 ---------- */
|
|
298
|
+
#soloView { flex: 1; min-height: 0; display: flex; flex-direction: column; background: #f7f7f7; }
|
|
299
|
+
#soloChatPane { flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
|
300
|
+
.oc-model-box { display: flex; align-items: center; }
|
|
301
|
+
#ocModelInput { width: 200px; border: 1px solid #e2e2e2; border-radius: 4px; padding: 4px 10px; font-size: 12px; outline: none; color: #333; background: #fff; }
|
|
302
|
+
#ocModelInput:focus { border-color: #07c160; }
|
|
303
|
+
#ocModelInput::placeholder { color: #b2b2b2; }
|
|
304
|
+
#soloMessages { flex: 1; overflow-y: auto; padding: 16px 18px; }
|
|
305
|
+
.solo-empty { text-align: center; color: #b2b2b2; font-size: 13px; padding: 60px 20px; line-height: 2; }
|
|
306
|
+
.avatar.solo-av { background: #10aeff; }
|
|
307
|
+
.bubble .solo-tools { font: 12px/1.7 Consolas, Menlo, monospace; color: #0a742e; }
|
|
308
|
+
.bubble .solo-reasoning { border-left: 3px solid #d8d8d8; padding: 2px 10px; margin: 4px 0; color: #8a8a8a; font-size: 12.5px; font-style: italic; white-space: normal; max-height: 7.5em; overflow-y: auto; cursor: pointer; }
|
|
309
|
+
.bubble .solo-reasoning .sr-tag { font-style: normal; font-size: 11px; color: #b2b2b2; display: block; margin-bottom: 2px; }
|
|
310
|
+
.bubble .solo-part-md { white-space: normal; }
|
|
311
|
+
.bubble .solo-part-md p { margin: 0.35em 0; }
|
|
312
|
+
.bubble .solo-part-md > :first-child { margin-top: 0; }
|
|
313
|
+
.bubble .solo-part-md > :last-child { margin-bottom: 0; }
|
|
314
|
+
.bubble .solo-part-md pre { background: #f6f6f6; border-radius: 6px; padding: 8px 10px; overflow-x: auto; font-size: 12.5px; }
|
|
315
|
+
.bubble .solo-part-md code { background: #f0f0f0; border-radius: 3px; padding: 1px 4px; font-size: 12.5px; }
|
|
316
|
+
.bubble .solo-part-md pre code { background: none; padding: 0; }
|
|
317
|
+
.bubble .solo-part-md table { border-collapse: collapse; margin: 0.4em 0; }
|
|
318
|
+
.bubble .solo-part-md th, .bubble .solo-part-md td { border: 1px solid #ddd; padding: 4px 8px; font-size: 12.5px; }
|
|
319
|
+
.bubble .solo-part-md blockquote { border-left: 3px solid #ccc; margin: 0.4em 0; padding: 2px 10px; color: #777; }
|
|
320
|
+
.bubble .solo-part-md h1, .bubble .solo-part-md h2, .bubble .solo-part-md h3, .bubble .solo-part-md h4 { margin: 0.5em 0 0.3em; font-size: 1.05em; }
|
|
321
|
+
/* 列表符号防越界:全局 reset 清掉了 ul/ol 默认 padding,list-style-position outside 的 ·/数字 会画到气泡左边界外 */
|
|
322
|
+
.bubble .solo-part-md ul, .bubble .solo-part-md ol { padding-left: 1.4em; margin: 0.4em 0; }
|
|
323
|
+
.bubble .solo-part-md li { margin: 0.15em 0; }
|
|
324
|
+
|
|
325
|
+
/* ---------- Markdown 预览弹窗 ---------- */
|
|
326
|
+
#previewMask { position: fixed; inset: 0; background: rgba(0,0,0,.5); display: none; align-items: center; justify-content: center; z-index: 95; }
|
|
327
|
+
#previewMask.show { display: flex; }
|
|
328
|
+
.preview-card { background: #fff; border-radius: 10px; width: min(880px, 92vw); height: 88vh; display: flex; flex-direction: column; overflow: hidden; }
|
|
329
|
+
.preview-bar { display: flex; align-items: center; gap: 10px; padding: 10px 16px; border-bottom: 1px solid #eee; flex: none; }
|
|
330
|
+
.preview-bar .pv-name { font-weight: 600; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
331
|
+
.preview-bar .pv-path { flex: 1; min-width: 0; font-size: 11px; color: #999; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; direction: rtl; text-align: left; }
|
|
332
|
+
.pv-close { background: #fa5151; color: #fff; border: none; border-radius: 6px; padding: 5px 16px; font-size: 13px; cursor: pointer; flex: none; }
|
|
333
|
+
.md-body { flex: 1; overflow-y: auto; padding: 22px 28px; font-size: 14px; line-height: 1.85; color: #24292f; }
|
|
334
|
+
.md-body h1, .md-body h2, .md-body h3, .md-body h4 { margin: 18px 0 8px; line-height: 1.4; }
|
|
335
|
+
.md-body h1 { font-size: 21px; border-bottom: 1px solid #eaecef; padding-bottom: 6px; }
|
|
336
|
+
.md-body h2 { font-size: 18px; border-bottom: 1px solid #eaecef; padding-bottom: 5px; }
|
|
337
|
+
.md-body h3 { font-size: 16px; }
|
|
338
|
+
.md-body h4 { font-size: 14.5px; }
|
|
339
|
+
.md-body p { margin: 8px 0; }
|
|
340
|
+
.md-body ul, .md-body ol { padding-left: 24px; margin: 8px 0; }
|
|
341
|
+
.md-body li { margin: 3px 0; }
|
|
342
|
+
.md-body code { background: rgba(175,184,193,.22); border-radius: 4px; padding: 1px 6px; font: 12.5px Consolas, Menlo, monospace; }
|
|
343
|
+
.md-body pre { background: #f6f8fa; border: 1px solid #eaecef; border-radius: 8px; padding: 12px 14px; overflow-x: auto; margin: 10px 0; }
|
|
344
|
+
.md-body pre code { background: none; padding: 0; font-size: 12.5px; line-height: 1.7; }
|
|
345
|
+
.md-body blockquote { border-left: 4px solid #dfe2e5; color: #6a737d; padding: 2px 14px; margin: 10px 0; background: #f9fafb; }
|
|
346
|
+
.md-body table { border-collapse: collapse; margin: 10px 0; font-size: 13px; }
|
|
347
|
+
.md-body th, .md-body td { border: 1px solid #dfe2e5; padding: 6px 12px; }
|
|
348
|
+
.md-body th { background: #f6f8fa; }
|
|
349
|
+
.md-body hr { border: none; border-top: 1px solid #eaecef; margin: 16px 0; }
|
|
350
|
+
.md-body a { color: #0969da; }
|
|
351
|
+
.md-link { color: #7d5ac8; cursor: pointer; text-decoration: underline; text-underline-offset: 2px; }
|
|
352
|
+
.md-link:hover { color: #9d7ae8; }
|
|
353
|
+
.msg-file { cursor: pointer; }
|
|
354
|
+
.msg-file:hover .md-link { color: #9d7ae8; }
|
|
355
|
+
|
|
356
|
+
/* ---------- 流转视图全屏态 ---------- */
|
|
357
|
+
#flowMask .modal.flow.fs { width: 98vw; height: 96vh; }
|
|
358
|
+
.fd-close { float: right; font-size: 11px; color: #999; cursor: pointer; padding: 0 4px; }
|
|
359
|
+
.fd-close:hover { color: #fa5151; }
|
|
360
|
+
|
|
361
|
+
</style>
|
|
362
|
+
</head>
|
|
363
|
+
<body>
|
|
364
|
+
<div id="app">
|
|
365
|
+
<aside id="sidebar">
|
|
366
|
+
<div class="side-header">
|
|
367
|
+
<div class="mode-switch" id="modeSwitch">
|
|
368
|
+
<button id="modeSoloBtn" onclick="setChatMode('solo')">👤 单聊</button>
|
|
369
|
+
<button id="modeGroupBtn" class="active" onclick="setChatMode('group')">👥 群聊</button>
|
|
370
|
+
</div>
|
|
371
|
+
<div class="side-actions">
|
|
372
|
+
<button class="mini-btn" onclick="openImport()">导入任务</button>
|
|
373
|
+
<button class="mini-btn gray" onclick="openBatchMgr()" title="批量管理全部顺序任务与定时任务(删除/执行)">批量管理</button>
|
|
374
|
+
<button class="mini-btn gray" onclick="clearTasks()">清空</button>
|
|
375
|
+
</div>
|
|
376
|
+
</div>
|
|
377
|
+
<div class="side-mid">
|
|
378
|
+
<div id="sessArea"></div>
|
|
379
|
+
<div class="task-area">
|
|
380
|
+
<div class="task-tabs" id="taskTabs">
|
|
381
|
+
<button class="ttab active" id="ttabSeq" onclick="setTaskView('seq')">📋 顺序任务</button>
|
|
382
|
+
<button class="ttab" id="ttabSched" onclick="setTaskView('sched')">⏰ 定时任务 <span class="badge" id="pendingBadge" style="display:none">0</span></button>
|
|
383
|
+
</div>
|
|
384
|
+
<div id="taskList"></div>
|
|
385
|
+
</div>
|
|
386
|
+
</div>
|
|
387
|
+
<div class="side-footer">
|
|
388
|
+
<button class="run-all" id="runAllBtn" onclick="onRunAllBtn()" style="margin-bottom:0">按顺序执行全部待办</button>
|
|
389
|
+
<button class="run-all off" id="schedPowerBtn" onclick="toggleSchedPower()" style="display:none;margin-bottom:0">⏸ 关闭定时任务</button>
|
|
390
|
+
</div>
|
|
391
|
+
</aside>
|
|
392
|
+
|
|
393
|
+
<main id="chat">
|
|
394
|
+
<header id="chatHeader">
|
|
395
|
+
<div style="display:flex;align-items:center;min-width:0;">
|
|
396
|
+
<button id="menuBtn" onclick="document.getElementById('sidebar').classList.toggle('open')">☰</button>
|
|
397
|
+
<div style="min-width:0;">
|
|
398
|
+
<div class="chat-title" id="chatTitle">Agents 群聊</div>
|
|
399
|
+
<div class="chat-members" id="members"></div>
|
|
400
|
+
</div>
|
|
401
|
+
</div>
|
|
402
|
+
<div class="hdr-right">
|
|
403
|
+
<div class="oc-model-box" id="ocModelBox" style="display:none" title="默认模型:可直接输入 provider/model 筛选选择,点击自动刷新列表">
|
|
404
|
+
<input id="ocModelInput" list="ocModelList" placeholder="默认模型" autocomplete="off" spellcheck="false">
|
|
405
|
+
<datalist id="ocModelList"></datalist>
|
|
406
|
+
</div>
|
|
407
|
+
<button class="cfg-btn" id="newSessBtn" onclick="newSession()">✚ 新会话</button>
|
|
408
|
+
<button class="cfg-btn" id="histBtn" onclick="openHistory()">🕘 历史</button>
|
|
409
|
+
<button class="cfg-btn" id="flowEntryBtn" onclick="openFlow()">🔭 流转</button>
|
|
410
|
+
<button class="cfg-btn" id="agentsCfgBtn" onclick="openAgents()">⚙ 智能体配置</button>
|
|
411
|
+
<button class="cfg-btn" id="cardsNavBtn" onclick="openCards()" title="多任务编排 · 卡牌:把任务写成卡牌,Agent 干完一件自动注入下一件">🗂️ 多任务编排</button>
|
|
412
|
+
<span class="runner-tag" id="runnerTag" style="display:none"></span>
|
|
413
|
+
<button class="cfg-btn" id="helpBtn" onclick="openHelp()" title="使用帮助">❓ 帮助</button>
|
|
414
|
+
</div>
|
|
415
|
+
</header>
|
|
416
|
+
<div id="groupView" style="flex:1;min-height:0;display:flex;flex-direction:column">
|
|
417
|
+
<div id="messages"></div>
|
|
418
|
+
<footer class="composer" id="groupComposer">
|
|
419
|
+
<div class="input-row">
|
|
420
|
+
<button id="rtBtn" onclick="toggleRoundtable()" title="圆桌讨论模式:所有被 @ 的智能体(未 @ 则全体)围绕主题轮流发言、互相反驳,管家当主持人控制轮数并总结共识与分歧">💬 圆桌</button>
|
|
421
|
+
<input id="input" placeholder="输入消息,@ 可点名智能体…" autocomplete="off">
|
|
422
|
+
<button id="sendBtn" onclick="onSendBtn()">发送</button>
|
|
423
|
+
</div>
|
|
424
|
+
<div id="atMenu"></div>
|
|
425
|
+
</footer>
|
|
426
|
+
</div>
|
|
427
|
+
<div id="soloView" style="display:none">
|
|
428
|
+
<div id="soloChatPane" style="flex:1;min-height:0;display:flex;flex-direction:column">
|
|
429
|
+
<div id="soloMessages"></div>
|
|
430
|
+
<footer class="composer" id="soloComposer">
|
|
431
|
+
<div class="input-row">
|
|
432
|
+
<input id="soloInput" placeholder="输入消息,与 AI 单聊(同会话多轮续聊)…" autocomplete="off">
|
|
433
|
+
<button id="soloSendBtn" onclick="onSoloSendBtn()">发送</button>
|
|
434
|
+
</div>
|
|
435
|
+
</footer>
|
|
436
|
+
</div>
|
|
437
|
+
</div>
|
|
438
|
+
</main>
|
|
439
|
+
</div>
|
|
440
|
+
|
|
441
|
+
<!-- 批量管理任务弹窗 -->
|
|
442
|
+
<div id="batchMask">
|
|
443
|
+
<div class="modal wide">
|
|
444
|
+
<h3>批量管理任务</h3>
|
|
445
|
+
<div class="tip">全部顺序任务与定时任务(含单聊/群聊两类)。勾选后可批量删除;顺序任务可批量执行。</div>
|
|
446
|
+
<div id="batchList"></div>
|
|
447
|
+
<div class="batch-bar">
|
|
448
|
+
<label><input type="checkbox" id="batchAll" onchange="toggleBatchAll(this.checked)"> 全选</label>
|
|
449
|
+
<span style="flex:1"></span>
|
|
450
|
+
<button class="mini-btn" onclick="batchRunSel()">▶ 执行选中(顺序)</button>
|
|
451
|
+
<button class="mini-btn danger" onclick="batchDeleteSel()">🗑 删除选中</button>
|
|
452
|
+
<button class="mini-btn gray" onclick="closeBatchMgr()">关闭</button>
|
|
453
|
+
</div>
|
|
454
|
+
</div>
|
|
455
|
+
</div>
|
|
456
|
+
|
|
457
|
+
<!-- 导入任务弹窗 -->
|
|
458
|
+
<div id="modalMask">
|
|
459
|
+
<div class="modal">
|
|
460
|
+
<h3>导入任务</h3>
|
|
461
|
+
<div class="tab-row">
|
|
462
|
+
<button class="tab active" id="tabSeq" onclick="setImportMode('sequential')">顺序任务</button>
|
|
463
|
+
<button class="tab" id="tabSch" onclick="setImportMode('scheduled')">定时任务</button>
|
|
464
|
+
</div>
|
|
465
|
+
<div class="tip" id="importTip"></div>
|
|
466
|
+
<div class="ta-wrap">
|
|
467
|
+
<textarea id="importText" onkeydown="importKeydown(event)"></textarea>
|
|
468
|
+
<div id="importAtMenu"></div>
|
|
469
|
+
</div>
|
|
470
|
+
<div class="modal-actions">
|
|
471
|
+
<button onclick="closeImport()">取消</button>
|
|
472
|
+
<button class="primary" onclick="doImport()">提取任务</button>
|
|
473
|
+
</div>
|
|
474
|
+
</div>
|
|
475
|
+
</div>
|
|
476
|
+
|
|
477
|
+
<!-- 智能体配置弹窗 -->
|
|
478
|
+
<div id="agentsMask">
|
|
479
|
+
<div class="modal">
|
|
480
|
+
<h3>智能体配置</h3>
|
|
481
|
+
<div class="tip">每个智能体都是一个独立的 AI Agent:执行内核由顶部下拉框统一选择(OpenCode / Claude Code / Codex / pi),<b>图标</b>用于辨认,<b>名称</b>用于 @ 点名,<b>技能说明</b>供管家调度参考,<b>自定义提示词</b>会在任务下发时附加在用户提示词前面。所有智能体使用所选内核的默认模型。「管家」为内置调度智能体,不可修改。</div>
|
|
482
|
+
<div class="butler-ro" id="butlerRo"></div>
|
|
483
|
+
<div class="ag-row" style="margin:8px 0 2px">
|
|
484
|
+
<button class="add-agent" style="flex:1" onclick="openTeams()">📦 团队仓库 · 一键换经典团队</button>
|
|
485
|
+
</div>
|
|
486
|
+
<div class="global-cwd-box">
|
|
487
|
+
<div class="lbl">🧠 执行内核(所有智能体共用)</div>
|
|
488
|
+
<div class="ag-row">
|
|
489
|
+
<select id="kernelSel"></select>
|
|
490
|
+
</div>
|
|
491
|
+
<div class="lbl" style="margin-top:10px">📁 产出存档目录(可选)</div>
|
|
492
|
+
<div class="tip" style="margin:4px 0 6px">智能体工作中产生的文档与成果文件都存档到这个本机文件夹(填写文件夹完整路径即可,会自动创建)。可不设置(留空则用程序默认目录)。</div>
|
|
493
|
+
<div class="ag-row">
|
|
494
|
+
<input id="globalCwdInput" placeholder="文件夹完整路径,如 /Users/me/agents-work 或 D:\agents-work(智能体产出的存档地址,可不填)">
|
|
495
|
+
<button class="mini-btn" onclick="pickGlobalDir()">选文件夹</button>
|
|
496
|
+
</div>
|
|
497
|
+
<div class="lbl" style="margin-top:10px">💾 管家记忆</div>
|
|
498
|
+
<div class="tip" style="margin:4px 0 6px">管家跨会话记住工作笔记与你的偏好,规划时自动参考、任务完成后自动沉淀。点此查看或手动编辑。</div>
|
|
499
|
+
<div class="ag-row">
|
|
500
|
+
<button class="mini-btn" style="flex:1" onclick="openMem()">查看 / 编辑管家记忆</button>
|
|
501
|
+
</div>
|
|
502
|
+
<div class="lbl" style="margin-top:10px">🧹 数据维护</div>
|
|
503
|
+
<div class="tip" style="margin:4px 0 6px">任务、消息、会话、产出文件与流转日志等工作数据超过 15 天会自动清理(.env 中 AGENTS_CHAT_PRUNE_DAYS 可调整天数);管家记忆与配置永久保留。也可立即手动清理:</div>
|
|
504
|
+
<div class="ag-row">
|
|
505
|
+
<button class="mini-btn" style="flex:1" onclick="pruneHistory()">🗑 一键清理 15 天前的历史数据</button>
|
|
506
|
+
</div>
|
|
507
|
+
<div class="lbl" style="margin-top:10px">🛂 人工审批关卡</div>
|
|
508
|
+
<div class="tip" style="margin:4px 0 6px">开启后编排会在关键节点暂停,等你点「同意」才继续(聊天与定时任务都生效);超时 10 分钟未处理视为否决。</div>
|
|
509
|
+
<div class="ag-row">
|
|
510
|
+
<select id="approvalSel">
|
|
511
|
+
<option value="off">关闭(全自动,默认)</option>
|
|
512
|
+
<option value="plan">方案审批:调度方案确定后暂停</option>
|
|
513
|
+
<option value="verify">交付审批:最终交付前暂停</option>
|
|
514
|
+
<option value="all">全部审批(方案 + 交付)</option>
|
|
515
|
+
</select>
|
|
516
|
+
</div>
|
|
517
|
+
</div>
|
|
518
|
+
<div id="agentCards"></div>
|
|
519
|
+
<div class="ag-row" style="gap:8px">
|
|
520
|
+
<button class="add-agent" style="flex:1" onclick="addAgentRow()">+ 添加子智能体</button>
|
|
521
|
+
<button class="add-agent" style="flex:1" onclick="openCardImport()">📥 导入名片</button>
|
|
522
|
+
</div>
|
|
523
|
+
<div class="modal-actions">
|
|
524
|
+
<button onclick="closeAgents()">取消</button>
|
|
525
|
+
<button class="primary" onclick="saveAgentsCfg()">保存</button>
|
|
526
|
+
</div>
|
|
527
|
+
</div>
|
|
528
|
+
</div>
|
|
529
|
+
<!-- 导入智能体名片弹窗 -->
|
|
530
|
+
<div id="cardMask">
|
|
531
|
+
<div class="modal">
|
|
532
|
+
<h3>导入智能体名片</h3>
|
|
533
|
+
<div class="tip">粘贴名片 JSON,或选择名片文件(其他 Agents Chat 导出的 .json)。导入后先加入下方列表,点「保存」才生效。</div>
|
|
534
|
+
<div class="ta-wrap"><textarea id="cardText" style="min-height:180px" placeholder='{"type":"agents-chat-card","name":"架构师","icon":"📐","desc":"…","systemPrompt":"…"}'></textarea></div>
|
|
535
|
+
<div class="modal-actions">
|
|
536
|
+
<button class="mini-btn" onclick="pickCardFile()">选择文件…</button>
|
|
537
|
+
<span style="flex:1"></span>
|
|
538
|
+
<button onclick="closeCardImport()">取消</button>
|
|
539
|
+
<button class="primary" onclick="applyCardImport()">导入</button>
|
|
540
|
+
</div>
|
|
541
|
+
</div>
|
|
542
|
+
</div>
|
|
543
|
+
<!-- 团队仓库弹窗(经典智能体团队预设) -->
|
|
544
|
+
<div id="teamsMask">
|
|
545
|
+
<div class="modal wide">
|
|
546
|
+
<h3>📦 团队仓库</h3>
|
|
547
|
+
<div class="tip">取材自开源社区验证过的经典多智能体组合。选择一套应用:「替换」换掉当前全部子智能体,「追加」在现有名单后加入(重名自动加序号)。应用后仍可自由增删改,点配置页「保存」才生效。</div>
|
|
548
|
+
<div id="teamsList"></div>
|
|
549
|
+
<div class="modal-actions">
|
|
550
|
+
<button class="primary" onclick="closeTeams()">关闭</button>
|
|
551
|
+
</div>
|
|
552
|
+
</div>
|
|
553
|
+
</div>
|
|
554
|
+
<!-- 聊天历史弹窗 -->
|
|
555
|
+
<div id="historyMask">
|
|
556
|
+
<div class="modal wide">
|
|
557
|
+
<h3>聊天历史(主会话)</h3>
|
|
558
|
+
<div class="tip">按时间正序展示全部主会话记录(含已开启新会话前的内容),仅供查阅;消息中的 .md 文件路径可点击预览</div>
|
|
559
|
+
<div id="historyList"></div>
|
|
560
|
+
<div class="modal-actions" style="justify-content:space-between">
|
|
561
|
+
<div style="display:flex;gap:8px">
|
|
562
|
+
<button class="mini-btn" onclick="exportHistory()" title="全部会话(含任务会话)导出为 Markdown 文件">⬇ 导出 sessions.md</button>
|
|
563
|
+
<button class="mini-btn danger" onclick="clearAllHistory()" title="删除全部消息记录、会话产出文件与流转日志(任务列表保留)">🗑 清空全部会话</button>
|
|
564
|
+
</div>
|
|
565
|
+
<button class="primary" onclick="closeHistory()">关闭</button>
|
|
566
|
+
</div>
|
|
567
|
+
</div>
|
|
568
|
+
</div>
|
|
569
|
+
<!-- 管家记忆弹窗 -->
|
|
570
|
+
<div id="memMask">
|
|
571
|
+
<div class="modal wide">
|
|
572
|
+
<h3>💾 管家记忆</h3>
|
|
573
|
+
<div class="tip">管家跨会话记住的工作笔记与用户偏好:规划时自动参考,任务完成后自动提炼沉淀。一行一条,可手动增删改;两仓各有字数上限(笔记 2000 字 / 画像 1200 字),写满后系统自动让辅助小模型合并压缩,失败则淘汰最旧条目,绝不丢失新增内容。</div>
|
|
574
|
+
<div style="margin:6px 0 2px;font-weight:600">工作笔记(项目事实 / 环境约定 / 教训)</div>
|
|
575
|
+
<textarea id="memMemoryText" rows="7" style="width:100%;box-sizing:border-box;font:12px/1.6 Consolas,monospace" placeholder="一行一条"></textarea>
|
|
576
|
+
<div id="memMemoryUsage" style="font-size:11px;color:#888;margin:2px 0 8px"></div>
|
|
577
|
+
<div style="margin:6px 0 2px;font-weight:600">用户画像(偏好 / 习惯 / 身份)</div>
|
|
578
|
+
<textarea id="memUserText" rows="5" style="width:100%;box-sizing:border-box;font:12px/1.6 Consolas,monospace" placeholder="一行一条"></textarea>
|
|
579
|
+
<div id="memUserUsage" style="font-size:11px;color:#888;margin:2px 0 8px"></div>
|
|
580
|
+
<div class="modal-actions">
|
|
581
|
+
<button class="primary" onclick="saveMem()">保存</button>
|
|
582
|
+
<button onclick="closeMem()">关闭</button>
|
|
583
|
+
</div>
|
|
584
|
+
</div>
|
|
585
|
+
</div>
|
|
586
|
+
<!-- 使用帮助弹窗 -->
|
|
587
|
+
<div id="helpMask">
|
|
588
|
+
<div class="modal wide">
|
|
589
|
+
<h3>❓ 使用帮助</h3>
|
|
590
|
+
<div id="helpBody"></div>
|
|
591
|
+
<div class="modal-actions">
|
|
592
|
+
<button class="primary" onclick="closeHelp()">关闭</button>
|
|
593
|
+
</div>
|
|
594
|
+
</div>
|
|
595
|
+
</div>
|
|
596
|
+
<!-- 流转视图弹窗(智能体工作状态与信息传递) -->
|
|
597
|
+
<div id="flowMask">
|
|
598
|
+
<div class="modal flow">
|
|
599
|
+
<div class="flow-bar">
|
|
600
|
+
<h3>🔭 智能体流转</h3>
|
|
601
|
+
<select id="flowRunSel" onchange="onFlowRunChange()"></select>
|
|
602
|
+
<label style="font-size:11px;color:#666;white-space:nowrap"><input type="checkbox" id="flowAutoChk" checked onchange="onFlowAutoChange()"> 自动刷新</label>
|
|
603
|
+
<button class="mini-btn" onclick="loadFlowEvents()">刷新</button>
|
|
604
|
+
<button class="mini-btn" id="flowFsBtn" onclick="toggleFlowFs()" title="全屏显示,看完整泳道图">⤢ 全屏</button>
|
|
605
|
+
<button class="mini-btn gray" onclick="closeFlow()">关闭</button>
|
|
606
|
+
</div>
|
|
607
|
+
<div class="flow-legend">
|
|
608
|
+
<span><i style="background:#4a6cf7"></i>交接(产出→下游)</span>
|
|
609
|
+
<span><i style="background:#e8a33d"></i>返工</span>
|
|
610
|
+
<span><i style="background:#9aa8c7"></i>派发</span>
|
|
611
|
+
<span><i style="background:#d9f2e3;border:1px solid #07c160"></i>执行块</span>
|
|
612
|
+
<span><i style="background:#fdeceb;border:1px solid #fa5151"></i>失败</span>
|
|
613
|
+
<span><i style="background:#8a6fe8"></i>规划/验收/交付</span>
|
|
614
|
+
<span><i style="background:#10aeff"></i>自动核查</span>
|
|
615
|
+
<span><i style="background:#8a6fe8"></i>主持人判定</span>
|
|
616
|
+
<span style="color:#999">点击块/连线/圆点查看详情</span>
|
|
617
|
+
</div>
|
|
618
|
+
<div class="flow-canvas" id="flowCanvas"></div>
|
|
619
|
+
<div class="flow-detail" id="flowDetail" style="display:none"></div>
|
|
620
|
+
</div>
|
|
621
|
+
</div>
|
|
622
|
+
<div class="toast" id="toast"></div>
|
|
623
|
+
|
|
624
|
+
<!-- Markdown 预览弹窗(点击聊天中的 .md 文件路径打开) -->
|
|
625
|
+
<div id="previewMask">
|
|
626
|
+
<div class="preview-card">
|
|
627
|
+
<div class="preview-bar">
|
|
628
|
+
<span class="pv-name" id="pvName"></span>
|
|
629
|
+
<span class="pv-path" id="pvPath"></span>
|
|
630
|
+
<button class="pv-close" onclick="closePreview()">✕ 关闭</button>
|
|
631
|
+
</div>
|
|
632
|
+
<div class="md-body" id="pvBody"></div>
|
|
633
|
+
</div>
|
|
634
|
+
</div>
|
|
635
|
+
|
|
636
|
+
<script>
|
|
637
|
+
const PAGE_VERSION = '3.18.0'; // 与服务端 /api/health.version 互检,不一致说明页面缓存过期
|
|
638
|
+
const AV_COLORS = ['#5b8def','#07c160','#fa9d3b','#10aeff','#8a6fe8','#fa5151','#e8a33d','#3aa7a3'];
|
|
639
|
+
const PHASE_LABEL = { plan: '调度规划', work: '执行', review: '验收', report: '汇总', talk: '圆桌发言', task: '任务执行' };
|
|
640
|
+
// 职业图标库(智能体配置可选)
|
|
641
|
+
const ICON_PRESETS = ['🧑💻','🔍','✍️','🏗️','🎨','📊','🧪','📐','🚀','🛡️','🧭','📈','🧑🏫','🩺','⚖️','🔧','📖','🎬','🧙','💼','🖥️','🤖','🦉','🐝','🎯','🗂️','💡','🛠️','📡','🧠'];
|
|
642
|
+
|
|
643
|
+
let agents = [], butlerId = 'butler', tasks = [];
|
|
644
|
+
let chatBusy = false; // 聊天编排进行中(发送按钮变「停止」)
|
|
645
|
+
let taskBusy = false; // 任务批量执行进行中(执行按钮变「停止执行」)
|
|
646
|
+
let stopAsked = false; // 本次流中已请求过停止,避免重复点击
|
|
647
|
+
let curTaskId = ''; // '' = 主会话;否则为任务会话
|
|
648
|
+
let roundtableOn = false; // 圆桌讨论模式开关(输入栏 💬 按钮)
|
|
649
|
+
let runnerState = { kind: '', label: '', cmd: '', kernels: [] }; // 内核状态(帮助弹窗/配置页展示)
|
|
650
|
+
let globalCwd = ''; // 全局统一工作目录
|
|
651
|
+
let configKernel = 'auto'; // 配置的执行内核
|
|
652
|
+
let configApproval = 'off'; // 人工审批关卡:off/plan/verify/all
|
|
653
|
+
let teamPresets = []; // 团队仓库预设(配置页「📦 团队仓库」)
|
|
654
|
+
let chatMode = 'group'; // 当前模式:group=群聊(多智能体)| solo=单聊(OpenCode 工作台)
|
|
655
|
+
const sessions = new Map(); // key: 'main' | taskId | oc会话id → 消息数组
|
|
656
|
+
|
|
657
|
+
// ---------- 单聊工作台状态 ----------
|
|
658
|
+
let ocSessions = []; // 单聊会话列表(服务端 oc-sessions.json)
|
|
659
|
+
let curOcId = ''; // 当前单聊会话 id('' = 未选,显示空态引导)
|
|
660
|
+
let soloBusy = false; // 单聊进行中(发送按钮变「停止」)
|
|
661
|
+
let soloStopAsked = false;
|
|
662
|
+
let ocModels = []; // 可选模型(opencode models)
|
|
663
|
+
let ocModelDemo = false; // 演示模式(模型列表为假数据)
|
|
664
|
+
let ocModelNoResume = false; // 当前内核不支持 -s 续聊(每轮独立对话)
|
|
665
|
+
let curOcModel = ''; // 当前选中模型(localStorage 记忆)
|
|
666
|
+
|
|
667
|
+
const input = document.getElementById('input');
|
|
668
|
+
|
|
669
|
+
function esc(s) { const d = document.createElement('div'); d.textContent = s == null ? '' : String(s); return d.innerHTML; }
|
|
670
|
+
function escAttr(s) { return String(s == null ? '' : s).replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<'); }
|
|
671
|
+
function fmtTime(ts) { const d = new Date(ts); return String(d.getHours()).padStart(2,'0') + ':' + String(d.getMinutes()).padStart(2,'0'); }
|
|
672
|
+
function fmtSched(ts) { const d = new Date(ts); const p = (n) => String(n).padStart(2,'0'); return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`; }
|
|
673
|
+
function fmtSchedNow() { const d = new Date(); const p = (n) => String(n).padStart(2,'0'); return `${d.getFullYear()}${p(d.getMonth()+1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}`; }
|
|
674
|
+
function toast(msg) { const t = document.getElementById('toast'); t.textContent = msg; t.style.display = 'block'; setTimeout(() => t.style.display = 'none', 2200); }
|
|
675
|
+
function colorOf(id) { let h = 0; const s = String(id); for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0; return AV_COLORS[h % AV_COLORS.length]; }
|
|
676
|
+
// 智能体显示图标:配置的职业图标 > 名称首字
|
|
677
|
+
function agentIcon(agentId, name) {
|
|
678
|
+
const a = agents.find(x => x.id === agentId);
|
|
679
|
+
if (a && a.icon) return a.icon;
|
|
680
|
+
if (agentId === butlerId) return '🎩';
|
|
681
|
+
return String(name || 'AI').slice(0, 1);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
async function api(path, opts) {
|
|
685
|
+
const r = await fetch(path, opts);
|
|
686
|
+
return r.json();
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// ---------- 会话模型 ----------
|
|
690
|
+
function sessKey(taskId) { return taskId || 'main'; }
|
|
691
|
+
function getSess(key) { if (!sessions.has(key)) sessions.set(key, []); return sessions.get(key); }
|
|
692
|
+
function curKey() { return sessKey(curTaskId); }
|
|
693
|
+
|
|
694
|
+
// ---------- 初始化 ----------
|
|
695
|
+
async function init() {
|
|
696
|
+
const [health, ag] = await Promise.all([api('/api/health'), api('/api/agents')]);
|
|
697
|
+
agents = ag.agents || [];
|
|
698
|
+
butlerId = ag.butlerId || 'butler';
|
|
699
|
+
globalCwd = ag.globalCwd || '';
|
|
700
|
+
configKernel = ag.kernel || 'auto';
|
|
701
|
+
configApproval = ag.approval || 'off';
|
|
702
|
+
runnerState = { kind: health.runner || '', label: health.kernelLabel || '', cmd: health.kernelCmd || '', kernels: health.kernels || [] };
|
|
703
|
+
// 内核状态:正常不显示标签;演示/未检测到 属于需要用户知情的状态,保留醒目标注
|
|
704
|
+
const tag = document.getElementById('runnerTag');
|
|
705
|
+
if (health.runner === 'demo') {
|
|
706
|
+
tag.textContent = '演示模式';
|
|
707
|
+
tag.title = '当前为模拟输出(AGENTS_CHAT_MOCK=1),详见帮助';
|
|
708
|
+
tag.className = 'runner-tag demo';
|
|
709
|
+
tag.style.display = '';
|
|
710
|
+
} else if (health.runner === 'missing') {
|
|
711
|
+
tag.textContent = '未检测到内核';
|
|
712
|
+
tag.title = '可安装 OpenCode / Claude Code / Codex / pi 任一内核,详见帮助';
|
|
713
|
+
tag.className = 'runner-tag missing';
|
|
714
|
+
tag.style.display = '';
|
|
715
|
+
} else {
|
|
716
|
+
tag.style.display = '';
|
|
717
|
+
tag.textContent = runnerState.label || '内核';
|
|
718
|
+
tag.title = `运行内核:${runnerState.label}(${runnerState.cmd || '已安装'}),可在智能体配置中切换`;
|
|
719
|
+
tag.className = 'runner-tag opencode';
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
renderMembers();
|
|
723
|
+
await Promise.all([loadTasks(), loadMessages(), loadSchedState(), loadOcSessions(), loadOcModels()]);
|
|
724
|
+
restoreApprovals(); // 恢复仍在等待的人工审批卡片(刷新/断线场景)
|
|
725
|
+
updateFooterBtns();
|
|
726
|
+
|
|
727
|
+
// 版本互检:页面与服务端不一致 → 页面是旧缓存,必须强刷
|
|
728
|
+
if (health.version && health.version !== PAGE_VERSION) {
|
|
729
|
+
addSys(`⚠ 页面版本(${PAGE_VERSION})与服务端(${health.version})不一致:浏览器缓存了旧页面,请按 Ctrl+F5 强制刷新`);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
input.addEventListener('input', handleAtInput);
|
|
733
|
+
input.addEventListener('blur', () => setTimeout(hideAt, 150));
|
|
734
|
+
input.addEventListener('keydown', (e) => {
|
|
735
|
+
if (atState) {
|
|
736
|
+
if (e.key === 'ArrowDown') { e.preventDefault(); atState.active = (atState.active + 1) % atState.items.length; renderAt(); return; }
|
|
737
|
+
if (e.key === 'ArrowUp') { e.preventDefault(); atState.active = (atState.active - 1 + atState.items.length) % atState.items.length; renderAt(); return; }
|
|
738
|
+
if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); pickAt(atState.active); return; }
|
|
739
|
+
if (e.key === 'Escape') { hideAt(); return; }
|
|
740
|
+
}
|
|
741
|
+
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); }
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
// 单聊对话输入:回车发送
|
|
745
|
+
const si = document.getElementById('soloInput');
|
|
746
|
+
si.addEventListener('keydown', (e) => {
|
|
747
|
+
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); soloSend(); }
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
// 单聊模型输入框:点击自动刷新列表;仅 change(选择/修改)生效——不绑 blur,
|
|
751
|
+
// 否则用户只是点开看一眼,自动挑的免费模型就会被写进 localStorage,观感像被锁死
|
|
752
|
+
const om = document.getElementById('ocModelInput');
|
|
753
|
+
om.addEventListener('focus', () => { loadOcModels(true); });
|
|
754
|
+
om.addEventListener('change', onOcModelPick);
|
|
755
|
+
|
|
756
|
+
// Markdown 预览:消息中的 .md 路径 / 过程存档行 → 点击打开预览弹窗
|
|
757
|
+
document.addEventListener('click', (e) => {
|
|
758
|
+
const el = e.target.closest('.md-link, .msg-file[data-path]');
|
|
759
|
+
if (el && el.dataset.path) { e.preventDefault(); e.stopPropagation(); openPreview(el.dataset.path); }
|
|
760
|
+
});
|
|
761
|
+
// Esc 关闭预览弹窗
|
|
762
|
+
document.addEventListener('keydown', (e) => {
|
|
763
|
+
if (e.key === 'Escape') closePreview();
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
// 页面心跳:告知服务端「网页还开着」(全部页面关闭后服务端会自动退出)
|
|
767
|
+
setInterval(() => { fetch('/api/health').catch(() => {}); }, 25000);
|
|
768
|
+
|
|
769
|
+
// 轻量轮询:感知定时任务自动触发引起的任务状态变化(执行批次进行中或页面隐藏时跳过)
|
|
770
|
+
setInterval(() => {
|
|
771
|
+
if (taskBusy || document.hidden) return;
|
|
772
|
+
loadTasks(true);
|
|
773
|
+
}, 8000);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// ---------- 单聊/群聊模式切换(左上角开关) ----------
|
|
777
|
+
function setChatMode(mode) {
|
|
778
|
+
chatMode = mode === 'solo' ? 'solo' : 'group';
|
|
779
|
+
document.getElementById('modeSoloBtn').classList.toggle('active', chatMode === 'solo');
|
|
780
|
+
document.getElementById('modeGroupBtn').classList.toggle('active', chatMode === 'group');
|
|
781
|
+
document.getElementById('groupView').style.display = chatMode === 'group' ? 'flex' : 'none';
|
|
782
|
+
document.getElementById('soloView').style.display = chatMode === 'solo' ? 'flex' : 'none';
|
|
783
|
+
renderSidebar();
|
|
784
|
+
renderHeader();
|
|
785
|
+
updateFooterBtns();
|
|
786
|
+
if (chatMode === 'solo') {
|
|
787
|
+
loadOcSessions();
|
|
788
|
+
loadOcModels();
|
|
789
|
+
setTimeout(() => { try { document.getElementById('soloInput').focus(); } catch {} }, 100);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// ==================== 单聊工作台(OpenCode) ====================
|
|
794
|
+
|
|
795
|
+
// ---------- 模型列表 ----------
|
|
796
|
+
async function loadOcModels(refresh) {
|
|
797
|
+
try {
|
|
798
|
+
const r = await api('/api/oc/models' + (refresh ? '?refresh=1' : ''));
|
|
799
|
+
if (!r.success) return;
|
|
800
|
+
ocModelDemo = !!r.demo;
|
|
801
|
+
ocModelNoResume = !!r.noResume;
|
|
802
|
+
ocModels = r.models || [];
|
|
803
|
+
renderOcModelSel();
|
|
804
|
+
} catch { /* 模型列表加载失败不影响聊天(用默认模型) */ }
|
|
805
|
+
}
|
|
806
|
+
// 填充输入框 datalist:value=id(发送给内核),展示 label
|
|
807
|
+
function renderOcModelSel() {
|
|
808
|
+
const dl = document.getElementById('ocModelList');
|
|
809
|
+
// localStorage 'ocModel':null=从未选择过(自动优先挑免费模型);''=用户主动清空(用内核默认模型)
|
|
810
|
+
const savedRaw = localStorage.getItem('ocModel');
|
|
811
|
+
if (!curOcModel) {
|
|
812
|
+
if (savedRaw === null) {
|
|
813
|
+
const free = ocModels.find(m => /free/i.test(m.id) || /free/i.test(m.label || ''));
|
|
814
|
+
if (free) curOcModel = free.id;
|
|
815
|
+
} else {
|
|
816
|
+
const saved = savedRaw || '';
|
|
817
|
+
if (ocModels.some(m => m.id === saved)) curOcModel = saved;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
dl.innerHTML = ocModels.map(m => `<option value="${escAttr(m.id)}">${esc(m.label || m.id)}</option>`).join('');
|
|
821
|
+
syncOcModelInput();
|
|
822
|
+
}
|
|
823
|
+
function syncOcModelInput() {
|
|
824
|
+
const el = document.getElementById('ocModelInput');
|
|
825
|
+
if (!el) return;
|
|
826
|
+
if (document.activeElement !== el) el.value = curOcModel || '';
|
|
827
|
+
el.placeholder = ocModels.length ? '默认模型' : (ocModelDemo ? '默认模型(演示)' : '默认模型(未获取列表)');
|
|
828
|
+
}
|
|
829
|
+
// 输入/选择生效:精确匹配模型 id 或名称;或合法 provider/model 格式;否则回退
|
|
830
|
+
function onOcModelPick() {
|
|
831
|
+
const el = document.getElementById('ocModelInput');
|
|
832
|
+
if (!el) return;
|
|
833
|
+
const v = el.value.trim();
|
|
834
|
+
if (!v) { curOcModel = ''; localStorage.setItem('ocModel', ''); syncOcModelInput(); return; }
|
|
835
|
+
const hit = ocModels.find(m => m.id === v || m.label === v);
|
|
836
|
+
if (hit) { curOcModel = hit.id; }
|
|
837
|
+
else if (/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(v)) { curOcModel = v; }
|
|
838
|
+
else { toast('未匹配到模型,已回退为默认模型'); curOcModel = ''; }
|
|
839
|
+
localStorage.setItem('ocModel', curOcModel);
|
|
840
|
+
syncOcModelInput();
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// ---------- 会话管理 ----------
|
|
844
|
+
async function loadOcSessions() {
|
|
845
|
+
try {
|
|
846
|
+
const r = await api('/api/oc/sessions');
|
|
847
|
+
if (r.success) {
|
|
848
|
+
ocSessions = r.sessions || [];
|
|
849
|
+
if (chatMode === 'solo') renderSidebar();
|
|
850
|
+
}
|
|
851
|
+
} catch { /* ignore */ }
|
|
852
|
+
}
|
|
853
|
+
async function newOcSession() {
|
|
854
|
+
if (soloBusy) { toast('当前消息处理中,请等待或停止后再新建'); return; }
|
|
855
|
+
const r = await api('/api/oc/sessions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) });
|
|
856
|
+
if (r.success) {
|
|
857
|
+
ocSessions.unshift(r.session);
|
|
858
|
+
switchSoloSession(r.session.id);
|
|
859
|
+
toast('已新建单聊会话');
|
|
860
|
+
} else toast(r.error || '新建失败');
|
|
861
|
+
}
|
|
862
|
+
async function delOcSession(id) {
|
|
863
|
+
const s = ocSessions.find(x => x.id === id);
|
|
864
|
+
if (!confirm(`删除单聊会话「${s ? (s.title || '新会话') : ''}」及其消息?`)) return;
|
|
865
|
+
const r = await api('/api/oc/sessions/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) });
|
|
866
|
+
if (r.success) {
|
|
867
|
+
ocSessions = r.sessions || [];
|
|
868
|
+
sessions.delete(id);
|
|
869
|
+
if (curOcId === id) { curOcId = ''; renderHeader(); }
|
|
870
|
+
renderSidebar();
|
|
871
|
+
soloRenderMessages();
|
|
872
|
+
toast('会话已删除');
|
|
873
|
+
} else toast(r.error || '删除失败');
|
|
874
|
+
}
|
|
875
|
+
function switchSoloSession(id) {
|
|
876
|
+
if (chatMode !== 'solo') setChatMode('solo');
|
|
877
|
+
curOcId = id || '';
|
|
878
|
+
unreadSess.delete(id);
|
|
879
|
+
renderSidebar();
|
|
880
|
+
renderHeader();
|
|
881
|
+
soloRenderMessages();
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// ---------- 消息渲染 ----------
|
|
885
|
+
function soloScroll() { const b = document.getElementById('soloMessages'); if (b) b.scrollTop = b.scrollHeight; }
|
|
886
|
+
function soloAtBottom() { const b = document.getElementById('soloMessages'); return !b || (b.scrollHeight - b.scrollTop - b.clientHeight < 80); }
|
|
887
|
+
|
|
888
|
+
function soloMsgNode(m) {
|
|
889
|
+
if (m.role === 'sys') {
|
|
890
|
+
const d = document.createElement('div');
|
|
891
|
+
d.className = 'sys-tip';
|
|
892
|
+
d.innerHTML = `<span>${esc(m.content)}</span>`;
|
|
893
|
+
return d;
|
|
894
|
+
}
|
|
895
|
+
if (m.role === 'user') {
|
|
896
|
+
const row = document.createElement('div');
|
|
897
|
+
row.className = 'msg-row self';
|
|
898
|
+
row.innerHTML = `
|
|
899
|
+
<div class="avatar me">我</div>
|
|
900
|
+
<div class="msg-body"><div class="bubble right">${esc(m.content)}</div></div>`;
|
|
901
|
+
return row;
|
|
902
|
+
}
|
|
903
|
+
// assistant:Markdown 渲染(opencode 输出天然是 Markdown)
|
|
904
|
+
const row = document.createElement('div');
|
|
905
|
+
row.className = 'msg-row';
|
|
906
|
+
row.innerHTML = `
|
|
907
|
+
<div class="avatar solo-av">🤖</div>
|
|
908
|
+
<div class="msg-body">
|
|
909
|
+
<div class="msg-name">${esc(m.agentName || 'OpenCode')}</div>
|
|
910
|
+
<div class="bubble left solo-bubble"><div class="solo-part-md">${renderMd(m.content || '')}</div></div>
|
|
911
|
+
</div>`;
|
|
912
|
+
return row;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
function soloRenderMessages() {
|
|
916
|
+
const box = document.getElementById('soloMessages');
|
|
917
|
+
if (!curOcId) {
|
|
918
|
+
box.innerHTML = `<div class="solo-empty">👋 欢迎使用单聊<br>
|
|
919
|
+
直接在下方输入消息即可开始(自动创建新会话,同会话多轮续聊)<br>
|
|
920
|
+
顶部输入框可选择默认模型(未选择时自动优先免费模型)</div>`;
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
box.innerHTML = '';
|
|
924
|
+
for (const m of getSess(curOcId)) box.appendChild(soloMsgNode(m));
|
|
925
|
+
soloScroll();
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
function pushSoloMsg(key, m) {
|
|
929
|
+
getSess(key).push(m);
|
|
930
|
+
if (chatMode === 'solo' && key === curOcId) {
|
|
931
|
+
document.getElementById('soloMessages').appendChild(soloMsgNode(m));
|
|
932
|
+
soloScroll();
|
|
933
|
+
}
|
|
934
|
+
return m;
|
|
935
|
+
}
|
|
936
|
+
function addSoloSys(text, key) {
|
|
937
|
+
return pushSoloMsg(key || curOcId, { role: 'sys', content: text, ts: Date.now() });
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// ---------- 流式渲染(快照覆盖式) ----------
|
|
941
|
+
// soloLive: key -> { parts:Map(partId->{text,el}), order:[], tools:[], reasoning, row, domReady }
|
|
942
|
+
const soloLive = new Map();
|
|
943
|
+
|
|
944
|
+
function soloEnsureLive(key) {
|
|
945
|
+
let live = soloLive.get(key);
|
|
946
|
+
if (live) return live;
|
|
947
|
+
live = { parts: new Map(), order: [], tools: [], reasoning: '', row: null };
|
|
948
|
+
soloLive.set(key, live);
|
|
949
|
+
if (chatMode === 'solo' && key === curOcId) {
|
|
950
|
+
const row = document.createElement('div');
|
|
951
|
+
row.className = 'msg-row';
|
|
952
|
+
row.innerHTML = `
|
|
953
|
+
<div class="avatar solo-av">🤖</div>
|
|
954
|
+
<div class="msg-body">
|
|
955
|
+
<div class="msg-name">OpenCode</div>
|
|
956
|
+
<div class="bubble left solo-bubble">
|
|
957
|
+
<div class="solo-tools"></div>
|
|
958
|
+
<div class="solo-reasoning" style="display:none"><span class="sr-tag">💭 思考过程(点击展开/收起)</span><div class="sr-body" style="display:none"></div></div>
|
|
959
|
+
<div class="solo-stream-holder"></div>
|
|
960
|
+
</div>
|
|
961
|
+
</div>`;
|
|
962
|
+
document.getElementById('soloMessages').appendChild(row);
|
|
963
|
+
live.row = row;
|
|
964
|
+
live.holder = row.querySelector('.solo-stream-holder');
|
|
965
|
+
live.toolsEl = row.querySelector('.solo-tools');
|
|
966
|
+
live.reasoningEl = row.querySelector('.solo-reasoning');
|
|
967
|
+
soloScroll();
|
|
968
|
+
}
|
|
969
|
+
return live;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// 正文快照:同 partId 覆盖,新 partId 追加块
|
|
973
|
+
function soloStreamText(ev) {
|
|
974
|
+
const live = soloEnsureLive(ev.taskId || ev.sessionId || curOcId);
|
|
975
|
+
if (!live.order.includes(ev.partId)) live.order.push(ev.partId);
|
|
976
|
+
live.parts.set(ev.partId, ev.text);
|
|
977
|
+
if (live.holder) {
|
|
978
|
+
let el = live.parts.get(ev.partId + '|el');
|
|
979
|
+
if (!el) {
|
|
980
|
+
el = document.createElement('div');
|
|
981
|
+
el.className = 'solo-part-md';
|
|
982
|
+
live.holder.appendChild(el);
|
|
983
|
+
live.parts.set(ev.partId + '|el', el);
|
|
984
|
+
}
|
|
985
|
+
const stick = soloAtBottom();
|
|
986
|
+
el.innerHTML = renderMd(ev.text);
|
|
987
|
+
if (stick) soloScroll();
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// 思考快照:弱化展示(默认收起,点标签展开)
|
|
992
|
+
function soloStreamReasoning(ev) {
|
|
993
|
+
const live = soloEnsureLive(ev.sessionId || curOcId);
|
|
994
|
+
live.reasoning = ev.text;
|
|
995
|
+
if (live.reasoningEl) {
|
|
996
|
+
live.reasoningEl.style.display = '';
|
|
997
|
+
live.reasoningEl.querySelector('.sr-body').textContent = ev.text;
|
|
998
|
+
if (!live._rBound) {
|
|
999
|
+
live._rBound = true;
|
|
1000
|
+
live.reasoningEl.querySelector('.sr-tag').onclick = () => {
|
|
1001
|
+
const b = live.reasoningEl.querySelector('.sr-body');
|
|
1002
|
+
b.style.display = b.style.display === 'none' ? '' : 'none';
|
|
1003
|
+
};
|
|
1004
|
+
}
|
|
1005
|
+
if (soloAtBottom()) soloScroll();
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
// 工具调用:追加过程行
|
|
1010
|
+
function soloStreamTool(ev) {
|
|
1011
|
+
const live = soloEnsureLive(ev.sessionId || curOcId);
|
|
1012
|
+
live.tools.push(ev.summary || ev.name);
|
|
1013
|
+
if (live.toolsEl) {
|
|
1014
|
+
const div = document.createElement('div');
|
|
1015
|
+
div.textContent = ev.summary || `[工具] ${ev.name}`;
|
|
1016
|
+
live.toolsEl.appendChild(div);
|
|
1017
|
+
if (soloAtBottom()) soloScroll();
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// 封口:本轮结束,产出正式消息对象入会话数组(DOM 保留,避免重绘闪烁)
|
|
1022
|
+
function sealSoloStream(key) {
|
|
1023
|
+
const live = soloLive.get(key);
|
|
1024
|
+
if (!live) return;
|
|
1025
|
+
soloLive.delete(key);
|
|
1026
|
+
const content = live.order.map(id => live.parts.get(id)).filter(Boolean).join('\n\n').trim();
|
|
1027
|
+
if (content) {
|
|
1028
|
+
const m = { role: 'assistant', agentId: 'solo', agentName: 'OpenCode', phase: 'work', taskId: key, content, ts: Date.now() };
|
|
1029
|
+
getSess(key).push(m);
|
|
1030
|
+
m._dom = live.row; // 已在屏上:重绘时跳过(renderMessages 全量重绘不涉及 solo)
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// ---------- 对话发送 ----------
|
|
1035
|
+
function updateSoloSendBtn() {
|
|
1036
|
+
const btn = document.getElementById('soloSendBtn');
|
|
1037
|
+
if (soloBusy) {
|
|
1038
|
+
btn.textContent = soloStopAsked ? '停止中…' : '停止';
|
|
1039
|
+
btn.classList.add('stop');
|
|
1040
|
+
btn.disabled = soloStopAsked;
|
|
1041
|
+
} else {
|
|
1042
|
+
btn.textContent = '发送';
|
|
1043
|
+
btn.classList.remove('stop');
|
|
1044
|
+
btn.disabled = false;
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
async function onSoloSendBtn() {
|
|
1048
|
+
if (soloBusy) {
|
|
1049
|
+
soloStopAsked = true;
|
|
1050
|
+
updateSoloSendBtn();
|
|
1051
|
+
try {
|
|
1052
|
+
await api('/api/stop', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ scope: 'solo' }) });
|
|
1053
|
+
toast('已请求停止,正在等待进程结束…');
|
|
1054
|
+
} catch { toast('停止请求失败,请重试'); }
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
soloSend();
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
async function soloSend() {
|
|
1061
|
+
const el = document.getElementById('soloInput');
|
|
1062
|
+
const text = el.value.trim();
|
|
1063
|
+
if (!text) return;
|
|
1064
|
+
if (!curOcId) {
|
|
1065
|
+
// 未选会话时自动以首句为题新建,免手动点「✚ 新会话」
|
|
1066
|
+
try {
|
|
1067
|
+
const r = await api('/api/oc/sessions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: text.slice(0, 30) }) });
|
|
1068
|
+
if (!r.success) { toast(r.error || '自动新建会话失败'); return; }
|
|
1069
|
+
ocSessions.unshift(r.session);
|
|
1070
|
+
curOcId = r.session.id;
|
|
1071
|
+
renderSidebar();
|
|
1072
|
+
renderHeader();
|
|
1073
|
+
} catch { toast('自动新建会话失败'); return; }
|
|
1074
|
+
}
|
|
1075
|
+
if (soloBusy) return;
|
|
1076
|
+
// 发送前兜底:模型输入框可能刚被修改但 change 尚未触发(如回车直接发送),先同步生效
|
|
1077
|
+
onOcModelPick();
|
|
1078
|
+
soloBusy = true;
|
|
1079
|
+
soloStopAsked = false;
|
|
1080
|
+
updateSoloSendBtn();
|
|
1081
|
+
el.value = '';
|
|
1082
|
+
pushSoloMsg(curOcId, { role: 'user', content: text, ts: Date.now() });
|
|
1083
|
+
try {
|
|
1084
|
+
await streamSSE('/api/oc/chat', { sessionId: curOcId, message: text, model: curOcModel }, (e) => {
|
|
1085
|
+
if (e.type === 'text') {
|
|
1086
|
+
soloStreamText({ taskId: e.sessionId, partId: e.partId, text: e.text });
|
|
1087
|
+
} else if (e.type === 'reasoning') {
|
|
1088
|
+
soloStreamReasoning(e);
|
|
1089
|
+
} else if (e.type === 'tool') {
|
|
1090
|
+
soloStreamTool(e);
|
|
1091
|
+
} else if (e.type === 'session') {
|
|
1092
|
+
const s = ocSessions.find(x => x.id === e.sessionId);
|
|
1093
|
+
if (s && !s.ocSessionId) s.ocSessionId = e.ocSessionId;
|
|
1094
|
+
} else if (e.type === 'done') {
|
|
1095
|
+
sealSoloStream(e.sessionId);
|
|
1096
|
+
if (e.error) addSoloSys('执行出错: ' + e.error, e.sessionId);
|
|
1097
|
+
if (e.noResume) addSoloSys('ℹ 当前内核不支持会话续聊:每轮为独立对话(OpenCode 内核可多轮续聊)', e.sessionId);
|
|
1098
|
+
finalizeSoloLinks(e.sessionId);
|
|
1099
|
+
} else if (e.type === 'error') {
|
|
1100
|
+
addSoloSys('单聊异常: ' + e.content, e.sessionId || curOcId);
|
|
1101
|
+
}
|
|
1102
|
+
});
|
|
1103
|
+
} catch (err) {
|
|
1104
|
+
addSoloSys('处理异常:' + (err && err.message || err), curOcId);
|
|
1105
|
+
} finally {
|
|
1106
|
+
sealSoloStream(curOcId);
|
|
1107
|
+
soloBusy = false;
|
|
1108
|
+
soloStopAsked = false;
|
|
1109
|
+
updateSoloSendBtn();
|
|
1110
|
+
loadOcSessions(); // 侧栏标题/时间刷新
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
// 定稿消息中的 .md 路径变成可点击预览链接(solo 气泡内)
|
|
1115
|
+
function finalizeSoloLinks(key) {
|
|
1116
|
+
if (chatMode !== 'solo' || key !== curOcId) return;
|
|
1117
|
+
const arr = getSess(key);
|
|
1118
|
+
for (let i = arr.length - 1; i >= 0; i--) {
|
|
1119
|
+
const m = arr[i];
|
|
1120
|
+
if (m.role !== 'assistant' || m._soloLinked) continue;
|
|
1121
|
+
const dom = m._dom;
|
|
1122
|
+
if (!dom || !document.contains(dom)) { m._soloLinked = true; continue; }
|
|
1123
|
+
const tx = dom.querySelector('.solo-part-md');
|
|
1124
|
+
if (tx) {
|
|
1125
|
+
const e = esc(m.content || '');
|
|
1126
|
+
const h = linkifyMd(e);
|
|
1127
|
+
if (h !== e) tx.innerHTML = h;
|
|
1128
|
+
}
|
|
1129
|
+
m._soloLinked = true;
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
// ---------- 单聊任务执行(OpenCode 单体逐个完成) ----------
|
|
1134
|
+
async function soloExecTasks(taskIds) {
|
|
1135
|
+
if (taskBusy) { toast('正在执行中,请先停止当前批次'); return; }
|
|
1136
|
+
taskBusy = true;
|
|
1137
|
+
stopAsked = false;
|
|
1138
|
+
updateRunAllBtn();
|
|
1139
|
+
let gotAllDone = false;
|
|
1140
|
+
try {
|
|
1141
|
+
taskIds.forEach(id => { const t = tasks.find(x => x.id === id); if (t) t.status = 'running'; });
|
|
1142
|
+
renderSidebar();
|
|
1143
|
+
if (taskIds.length === 1) switchSoloSession(taskIds[0]);
|
|
1144
|
+
|
|
1145
|
+
await streamSSE('/api/tasks/run', { taskIds, scope: 'solo' }, (e) => {
|
|
1146
|
+
if (e.type === 'task_start') {
|
|
1147
|
+
switchSoloSession(e.taskId);
|
|
1148
|
+
addSoloSys(`开始执行(OpenCode):${e.title}`, e.taskId);
|
|
1149
|
+
} else if (e.type === 'text') {
|
|
1150
|
+
soloStreamText({ taskId: e.taskId, partId: e.partId, text: e.text });
|
|
1151
|
+
} else if (e.type === 'notice') {
|
|
1152
|
+
addSoloSys(e.content, e.taskId);
|
|
1153
|
+
} else if (e.type === 'task_done') {
|
|
1154
|
+
sealSoloStream(e.taskId);
|
|
1155
|
+
finalizeSoloLinks(e.taskId);
|
|
1156
|
+
const t = tasks.find(x => x.id === e.taskId);
|
|
1157
|
+
if (t) t.status = e.status;
|
|
1158
|
+
renderSidebar();
|
|
1159
|
+
addSoloSys(`任务${e.status === 'done' ? '完成' : e.status === 'pending' ? '已停止(可重新执行)' : '失败'}:${e.title}`, e.taskId);
|
|
1160
|
+
if (e.status === 'done') showDoneToast(e.taskId, `任务完成:${e.title}`);
|
|
1161
|
+
} else if (e.type === 'all_done') {
|
|
1162
|
+
gotAllDone = true;
|
|
1163
|
+
addSoloSys('全部单聊任务执行完毕', curOcId);
|
|
1164
|
+
} else if (e.type === 'error') {
|
|
1165
|
+
addSoloSys('执行出错: ' + e.content, e.taskId);
|
|
1166
|
+
}
|
|
1167
|
+
});
|
|
1168
|
+
if (!gotAllDone) addSoloSys('⚠ 与服务的连接中断,任务可能仍在后台执行;刷新页面可查看最新进度', curOcId);
|
|
1169
|
+
} catch (err) {
|
|
1170
|
+
addSoloSys('处理异常:' + (err && err.message || err), curOcId);
|
|
1171
|
+
} finally {
|
|
1172
|
+
taskBusy = false;
|
|
1173
|
+
stopAsked = false;
|
|
1174
|
+
updateRunAllBtn();
|
|
1175
|
+
}
|
|
1176
|
+
await loadTasks();
|
|
1177
|
+
renderHeader();
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
function renderMembers() {
|
|
1181
|
+
const el = document.getElementById('members');
|
|
1182
|
+
el.innerHTML = agents.map(a =>
|
|
1183
|
+
`<span class="member-avatar" style="background:${a.id === butlerId ? '#8a6fe8' : colorOf(a.id)};font-size:13px" title="${escAttr(a.name)}${a.locked ? '(内置)' : ''}">${esc(a.icon || agentIcon(a.id, a.name))}</span>`
|
|
1184
|
+
).join('');
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
// ---------- 侧栏(主会话 + 任务会话) ----------
|
|
1188
|
+
async function loadTasks(silent) {
|
|
1189
|
+
const data = await api('/api/tasks');
|
|
1190
|
+
tasks = data.tasks || [];
|
|
1191
|
+
detectBackgroundDone();
|
|
1192
|
+
renderSidebar();
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
// ---------- 后台完成检测:定时任务由服务端调度执行,前端轮询感知状态变化 ----------
|
|
1196
|
+
// running → done 且用户没在看该会话:弹提醒 + 绿点(busy 时不拉消息避免打断流式气泡,点开时再拉)
|
|
1197
|
+
const prevTaskStatus = new Map();
|
|
1198
|
+
function detectBackgroundDone() {
|
|
1199
|
+
for (const t of tasks) {
|
|
1200
|
+
const prev = prevTaskStatus.get(t.id);
|
|
1201
|
+
if (prev === 'running' && t.status === 'done' && t.id !== curTaskId) {
|
|
1202
|
+
const title = `任务完成:${t.title}`;
|
|
1203
|
+
if (!chatBusy && !taskBusy) {
|
|
1204
|
+
loadMessages().then(() => showDoneToast(t.id, title));
|
|
1205
|
+
} else {
|
|
1206
|
+
showDoneToast(t.id, title);
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
prevTaskStatus.set(t.id, t.status);
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
function statusText(s) {
|
|
1214
|
+
return { pending: '待执行', running: '执行中', done: '已完成', failed: '失败' }[s] || s;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
// 侧栏可见任务(顺序视图):仅顺序任务;定时任务在「⏰ 定时任务」视图中管理
|
|
1218
|
+
// 单聊任务(runner='solo')只在单聊模式侧栏显示,群聊侧栏反之
|
|
1219
|
+
function visibleTasks() {
|
|
1220
|
+
const solo = chatMode === 'solo';
|
|
1221
|
+
return tasks.filter(t => t.kind !== 'scheduled' && (solo ? t.runner === 'solo' : t.runner !== 'solo'));
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
// 侧栏渲染:上半会话区(sessArea)+ 下半任务区(taskList),两区各占一半高度
|
|
1225
|
+
function renderSidebar() {
|
|
1226
|
+
renderSessions();
|
|
1227
|
+
renderTasks();
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
// ---------- 会话区:当前模式的会话列表(约5条高度,其余滚动查看) ----------
|
|
1231
|
+
function renderSessions() {
|
|
1232
|
+
const el = document.getElementById('sessArea');
|
|
1233
|
+
if (chatMode === 'solo') {
|
|
1234
|
+
const sessHtml = ocSessions.length
|
|
1235
|
+
? ocSessions.map(s => `
|
|
1236
|
+
<div class="task-item ${curOcId === s.id ? 'active' : ''}" onclick="switchSoloSession('${escAttr(s.id)}')">
|
|
1237
|
+
${unreadSess.has(s.id) ? '<span class="unread-dot"></span>' : ''}
|
|
1238
|
+
<div class="task-avatar st-done" style="background:#10aeff">💬</div>
|
|
1239
|
+
<div class="task-main">
|
|
1240
|
+
<div class="task-title">${esc(s.title || '新会话')}</div>
|
|
1241
|
+
<div class="task-sub"><span>${esc(s.model || '默认模型')}</span><span>${fmtTime(s.updatedAt)}</span></div>
|
|
1242
|
+
</div>
|
|
1243
|
+
<button class="mini-btn danger" title="删除会话及其消息" onclick="event.stopPropagation();delOcSession('${escAttr(s.id)}')">✕</button>
|
|
1244
|
+
</div>`).join('')
|
|
1245
|
+
: '<div class="task-empty">暂无单聊会话<br>在输入框发消息即可自动创建</div>';
|
|
1246
|
+
el.innerHTML = '<div class="list-sep">单聊会话(OpenCode 多轮续聊)</div>' + sessHtml;
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1249
|
+
// 群聊会话:主会话 + 全部群聊任务会话(顺序/定时任务的对话记录)
|
|
1250
|
+
const groupTasks = tasks.filter(t => t.runner !== 'solo');
|
|
1251
|
+
const main = `<div class="task-item ${curTaskId === '' ? 'active' : ''}" onclick="switchSession('')">
|
|
1252
|
+
<div class="task-avatar" style="background:#07c160">👥</div>
|
|
1253
|
+
<div class="task-main">
|
|
1254
|
+
<div class="task-title">群聊主会话</div>
|
|
1255
|
+
<div class="task-sub"><span>多智能体协作 · @ 可点名</span></div>
|
|
1256
|
+
</div>
|
|
1257
|
+
</div>`;
|
|
1258
|
+
const sessHtml = groupTasks.length ? groupTasks.map(t => {
|
|
1259
|
+
const icon = t.status === 'done' ? '✓' : t.status === 'failed' ? '!' : t.status === 'running' ? '▶' : (t.kind === 'scheduled' ? '⏰' : '•');
|
|
1260
|
+
const assign = t.assign && t.assign !== butlerId ? ` · @${esc(assignName(t.assign))}` : '';
|
|
1261
|
+
return `<div class="task-item ${curTaskId === t.id ? 'active' : ''}" onclick="switchSession('${escAttr(t.id)}')">
|
|
1262
|
+
${unreadSess.has(t.id) ? '<span class="unread-dot"></span>' : ''}
|
|
1263
|
+
<div class="task-avatar st-${t.status === 'pending' && t.kind === 'scheduled' ? 'scheduled' : t.status}">${icon}</div>
|
|
1264
|
+
<div class="task-main">
|
|
1265
|
+
<div class="task-title">${esc(t.title)}</div>
|
|
1266
|
+
<div class="task-sub"><span>${statusText(t.status)}${t.kind === 'scheduled' ? ' · 定时' : ''}${assign}</span><span>${fmtTime(t.createdAt)}</span></div>
|
|
1267
|
+
</div>
|
|
1268
|
+
</div>`;
|
|
1269
|
+
}).join('') : '';
|
|
1270
|
+
el.innerHTML = '<div class="list-sep">群聊会话(主会话 + 任务会话)</div>' + main + sessHtml;
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
// ---------- 任务区:当前模式的任务列表(顺序/定时 tab,执行/删除/拖拽) ----------
|
|
1274
|
+
// 单聊任务的执行链徽标:- 续聊 / // 并行
|
|
1275
|
+
function linkBadge(t) {
|
|
1276
|
+
if (t.runner !== 'solo' || !t.link || t.link === 'new') return '';
|
|
1277
|
+
return t.link === 'continue'
|
|
1278
|
+
? '<span style="color:#8a6fe8;font-size:11px;margin-right:4px" title="在上一任务的会话中继续执行(同进程续聊)">↪ 续聊</span>'
|
|
1279
|
+
: '<span style="color:#0a742e;font-size:11px;margin-right:4px" title="与相邻并行任务同时执行(独立进程)">⚡ 并行</span>';
|
|
1280
|
+
}
|
|
1281
|
+
function renderTasks() {
|
|
1282
|
+
const el = document.getElementById('taskList');
|
|
1283
|
+
const badge = document.getElementById('pendingBadge');
|
|
1284
|
+
|
|
1285
|
+
// ---- 单聊模式:单聊任务 ----
|
|
1286
|
+
if (chatMode === 'solo') {
|
|
1287
|
+
let tasksHtml = '';
|
|
1288
|
+
if (taskView === 'sched') {
|
|
1289
|
+
const sched = tasks.filter(t => t.kind === 'scheduled' && t.runner === 'solo').sort((a, b) => (a.scheduledAt || 0) - (b.scheduledAt || 0));
|
|
1290
|
+
const pend = sched.filter(t => t.status === 'pending').length;
|
|
1291
|
+
badge.style.display = pend ? 'inline' : 'none';
|
|
1292
|
+
badge.textContent = pend;
|
|
1293
|
+
tasksHtml = sched.length ? sched.map(t => {
|
|
1294
|
+
const icon = t.status === 'done' ? '✓' : t.status === 'failed' ? '!' : t.status === 'running' ? '▶' : '⏰';
|
|
1295
|
+
const st = t.status === 'pending'
|
|
1296
|
+
? `<span class="sched-time">${fmtSched(t.scheduledAt || t.createdAt)}</span> · ${schedCountdown(t.scheduledAt)}`
|
|
1297
|
+
: statusText(t.status);
|
|
1298
|
+
return `<div class="task-item ${curOcId === t.id ? 'active' : ''}" ${t.status === 'pending' ? '' : `onclick="switchSoloSession('${escAttr(t.id)}')"`}>
|
|
1299
|
+
${unreadSess.has(t.id) ? '<span class="unread-dot"></span>' : ''}
|
|
1300
|
+
<div class="task-avatar st-${t.status === 'pending' ? 'scheduled' : t.status}">${icon}</div>
|
|
1301
|
+
<div class="task-main">
|
|
1302
|
+
<div class="task-title">${esc(t.title)}</div>
|
|
1303
|
+
<div class="task-sub"><span>${linkBadge(t)}${st}</span></div>
|
|
1304
|
+
</div>
|
|
1305
|
+
${t.status === 'pending' ? `<button class="mini-btn" title="不再等到点,立即执行" onclick="event.stopPropagation();runSchedNow('${escAttr(t.id)}')">立即执行</button>` : ''}
|
|
1306
|
+
${t.status === 'failed' ? `<button class="mini-btn" title="重新执行" onclick="event.stopPropagation();runSchedNow('${escAttr(t.id)}')">重跑</button>` : ''}
|
|
1307
|
+
<button class="mini-btn danger" title="删除任务及其会话" onclick="event.stopPropagation();delTask('${escAttr(t.id)}')">✕</button>
|
|
1308
|
+
</div>`;
|
|
1309
|
+
}).join('') : '<div class="task-empty">暂无定时任务<br>点上方「导入任务」→「定时任务」标签添加</div>';
|
|
1310
|
+
} else {
|
|
1311
|
+
const vis = visibleTasks();
|
|
1312
|
+
const pend = vis.filter(t => t.status === 'pending').length;
|
|
1313
|
+
badge.style.display = pend ? 'inline' : 'none';
|
|
1314
|
+
badge.textContent = pend;
|
|
1315
|
+
tasksHtml = vis.length ? vis.map(t => {
|
|
1316
|
+
const icon = t.status === 'done' ? '✓' : t.status === 'failed' ? '!' : t.status === 'running' ? '▶' : '•';
|
|
1317
|
+
return `<div class="task-item ${curOcId === t.id ? 'active' : ''}" draggable="${!taskBusy && t.status !== 'running'}" ondragstart="onDragStart('${escAttr(t.id)}',event)" ondragover="onDragOver(event)" ondragleave="this.classList.remove('drag-over')" ondrop="onDrop('${escAttr(t.id)}',event)" onclick="switchSoloSession('${escAttr(t.id)}')">
|
|
1318
|
+
${unreadSess.has(t.id) ? '<span class="unread-dot"></span>' : ''}
|
|
1319
|
+
<div class="task-avatar st-${t.status}">${icon}</div>
|
|
1320
|
+
<div class="task-main">
|
|
1321
|
+
<div class="task-title">${esc(t.title)}</div>
|
|
1322
|
+
<div class="task-sub"><span>${linkBadge(t)}${statusText(t.status)}</span><span>${fmtTime(t.createdAt)}</span></div>
|
|
1323
|
+
</div>
|
|
1324
|
+
${t.status === 'pending' || t.status === 'failed' ? `<button class="mini-btn" onclick="event.stopPropagation();runTask('${escAttr(t.id)}')">执行</button>` : ''}
|
|
1325
|
+
<button class="mini-btn danger" title="删除任务及其会话" onclick="event.stopPropagation();delTask('${escAttr(t.id)}')">✕</button>
|
|
1326
|
+
</div>`;
|
|
1327
|
+
}).join('') : '<div class="task-empty">暂无顺序任务<br>点上方「导入任务」添加<br>(单聊任务由 OpenCode 单体执行)</div>';
|
|
1328
|
+
}
|
|
1329
|
+
el.innerHTML = `<div class="list-sep">${taskView === 'sched' ? '定时任务(单聊 · 到点自动执行)' : '单聊任务(点击查看会话,可拖拽排序)'}</div>` + tasksHtml;
|
|
1330
|
+
return;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
// ---- 群聊模式:定时视图(全部定时任务,按启动时间排序) ----
|
|
1334
|
+
if (taskView === 'sched') {
|
|
1335
|
+
const sched = tasks.filter(t => t.kind === 'scheduled' && t.runner !== 'solo').sort((a, b) => (a.scheduledAt || 0) - (b.scheduledAt || 0));
|
|
1336
|
+
const pend = sched.filter(t => t.status === 'pending').length;
|
|
1337
|
+
badge.style.display = pend ? 'inline' : 'none';
|
|
1338
|
+
badge.textContent = pend;
|
|
1339
|
+
let html;
|
|
1340
|
+
if (!sched.length) {
|
|
1341
|
+
html = '<div class="task-empty">暂无定时任务<br>点上方「导入任务」→「定时任务」标签添加</div>';
|
|
1342
|
+
} else {
|
|
1343
|
+
html = `<div class="list-sep">定时任务(${schedEnabled ? '到点自动执行' : '调度已关闭,到点不执行'})</div>` + sched.map(t => {
|
|
1344
|
+
const icon = t.status === 'done' ? '✓' : t.status === 'failed' ? '!' : t.status === 'running' ? '▶' : '⏰';
|
|
1345
|
+
const assign = t.assign && t.assign !== butlerId ? ` · @${esc(assignName(t.assign))}` : '';
|
|
1346
|
+
const st = t.status === 'pending'
|
|
1347
|
+
? `<span class="sched-time">${fmtSched(t.scheduledAt || t.createdAt)}</span> · ${schedCountdown(t.scheduledAt)}`
|
|
1348
|
+
: statusText(t.status);
|
|
1349
|
+
return `<div class="task-item ${curTaskId === t.id ? 'active' : ''}" ${t.status === 'pending' ? '' : `onclick="switchSession('${escAttr(t.id)}')"`}>
|
|
1350
|
+
${unreadSess.has(t.id) ? '<span class="unread-dot"></span>' : ''}
|
|
1351
|
+
<div class="task-avatar st-${t.status === 'pending' ? 'scheduled' : t.status}">${icon}</div>
|
|
1352
|
+
<div class="task-main">
|
|
1353
|
+
<div class="task-title">${esc(t.title)}</div>
|
|
1354
|
+
<div class="task-sub"><span>${st}${assign}</span></div>
|
|
1355
|
+
</div>
|
|
1356
|
+
${t.status === 'pending' ? `<button class="mini-btn" title="不再等到点,立即执行" onclick="event.stopPropagation();runSchedNow('${escAttr(t.id)}')">立即执行</button>` : ''}
|
|
1357
|
+
${t.status === 'failed' ? `<button class="mini-btn" title="重新执行" onclick="event.stopPropagation();runSchedNow('${escAttr(t.id)}')">重跑</button>` : ''}
|
|
1358
|
+
<button class="mini-btn danger" title="删除任务及其会话" onclick="event.stopPropagation();delTask('${escAttr(t.id)}')">✕</button>
|
|
1359
|
+
</div>`;
|
|
1360
|
+
}).join('');
|
|
1361
|
+
}
|
|
1362
|
+
el.innerHTML = html;
|
|
1363
|
+
return;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
// ---- 群聊模式:顺序任务(可拖拽排序) ----
|
|
1367
|
+
const vis = visibleTasks();
|
|
1368
|
+
const pending = vis.filter(t => t.status === 'pending').length;
|
|
1369
|
+
badge.style.display = pending ? 'inline' : 'none';
|
|
1370
|
+
badge.textContent = pending;
|
|
1371
|
+
|
|
1372
|
+
let tasksHtml;
|
|
1373
|
+
if (vis.length === 0) {
|
|
1374
|
+
tasksHtml = '<div class="task-empty">暂无顺序任务<br>点上方「导入任务」添加<br>定时任务请切到上方「⏰ 定时任务」</div>';
|
|
1375
|
+
} else {
|
|
1376
|
+
tasksHtml = vis.map((t) => {
|
|
1377
|
+
const icon = t.status === 'done' ? '✓' : t.status === 'failed' ? '!' : t.status === 'running' ? '▶' : '•';
|
|
1378
|
+
const assign = t.assign && t.assign !== butlerId ? ` · @${esc(assignName(t.assign))}` : '';
|
|
1379
|
+
return `<div class="task-item ${curTaskId === t.id ? 'active' : ''}" draggable="${!taskBusy && t.status !== 'running'}" ondragstart="onDragStart('${escAttr(t.id)}',event)" ondragover="onDragOver(event)" ondragleave="this.classList.remove('drag-over')" ondrop="onDrop('${escAttr(t.id)}',event)" onclick="switchSession('${escAttr(t.id)}')">
|
|
1380
|
+
${unreadSess.has(t.id) ? '<span class="unread-dot"></span>' : ''}
|
|
1381
|
+
<div class="task-avatar st-${t.status}">${icon}</div>
|
|
1382
|
+
<div class="task-main">
|
|
1383
|
+
<div class="task-title">${esc(t.title)}</div>
|
|
1384
|
+
<div class="task-sub"><span>${statusText(t.status)}${assign}</span><span>${fmtTime(t.createdAt)}</span></div>
|
|
1385
|
+
</div>
|
|
1386
|
+
${t.status === 'pending' || t.status === 'failed' ? `<button class="mini-btn" onclick="event.stopPropagation();runTask('${escAttr(t.id)}')">执行</button>` : ''}
|
|
1387
|
+
<button class="mini-btn danger" title="删除任务及其会话" onclick="event.stopPropagation();delTask('${escAttr(t.id)}')">✕</button>
|
|
1388
|
+
</div>`;
|
|
1389
|
+
}).join('');
|
|
1390
|
+
}
|
|
1391
|
+
el.innerHTML = '<div class="list-sep">顺序任务(可拖拽排序,点击查看会话)</div>' + tasksHtml;
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
function assignName(id) {
|
|
1395
|
+
const a = agents.find(x => x.id === id);
|
|
1396
|
+
return a ? a.name : id;
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
// ---------- 任务拖拽排序 ----------
|
|
1400
|
+
let dragId = null;
|
|
1401
|
+
function onDragStart(id, e) { dragId = id; e.dataTransfer.effectAllowed = 'move'; try { e.dataTransfer.setData('text/plain', id); } catch {} }
|
|
1402
|
+
function onDragOver(e) { e.preventDefault(); e.currentTarget.classList.add('drag-over'); }
|
|
1403
|
+
function onDrop(targetId, e) {
|
|
1404
|
+
e.preventDefault();
|
|
1405
|
+
document.querySelectorAll('.task-item').forEach(el => el.classList.remove('drag-over'));
|
|
1406
|
+
if (!dragId || dragId === targetId) { dragId = null; return; }
|
|
1407
|
+
const vis = visibleTasks();
|
|
1408
|
+
const arr = vis.slice();
|
|
1409
|
+
const from = arr.findIndex(t => t.id === dragId);
|
|
1410
|
+
const to = arr.findIndex(t => t.id === targetId);
|
|
1411
|
+
if (from < 0 || to < 0) { dragId = null; return; }
|
|
1412
|
+
const [it] = arr.splice(from, 1);
|
|
1413
|
+
arr.splice(to, 0, it);
|
|
1414
|
+
dragId = null;
|
|
1415
|
+
tasks = arr; // 可见列表即时反映新顺序(未触发的定时任务仍收在定时弹窗)
|
|
1416
|
+
renderSidebar();
|
|
1417
|
+
saveOrder(arr.map(t => t.id));
|
|
1418
|
+
}
|
|
1419
|
+
async function saveOrder(ids) {
|
|
1420
|
+
const data = await api('/api/tasks/reorder', {
|
|
1421
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids })
|
|
1422
|
+
});
|
|
1423
|
+
if (!data.success) { toast(data.error || '排序保存失败'); await loadTasks(); }
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
async function delTask(id) {
|
|
1427
|
+
const t = tasks.find(x => x.id === id);
|
|
1428
|
+
if (!confirm(`删除任务「${t ? t.title : ''}」及其会话记录?`)) return;
|
|
1429
|
+
const data = await api('/api/tasks/delete', {
|
|
1430
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id })
|
|
1431
|
+
});
|
|
1432
|
+
if (data.success) {
|
|
1433
|
+
if (curTaskId === id) switchSession('');
|
|
1434
|
+
if (curOcId === id) { curOcId = ''; renderHeader(); soloRenderMessages(); }
|
|
1435
|
+
tasks = data.tasks || [];
|
|
1436
|
+
renderSidebar();
|
|
1437
|
+
await loadMessages(); // 会话列表同步移除被删任务的消息
|
|
1438
|
+
renderMessages();
|
|
1439
|
+
if (chatMode === 'solo') soloRenderMessages();
|
|
1440
|
+
toast('任务已删除');
|
|
1441
|
+
} else toast(data.error || '删除失败');
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
function switchSession(taskId) {
|
|
1445
|
+
if (chatMode !== 'group') setChatMode('group'); // 任务会话属于群聊模式,点击自动切回
|
|
1446
|
+
curTaskId = taskId || '';
|
|
1447
|
+
const wasUnread = unreadSess.has(curKey());
|
|
1448
|
+
unreadSess.delete(curKey()); // 切到该会话 = 已读,清除绿点
|
|
1449
|
+
renderSidebar();
|
|
1450
|
+
renderHeader();
|
|
1451
|
+
// 有未读(后台完成过)或本地空但任务已结束(定时调度后台跑完):从服务端拉最新消息
|
|
1452
|
+
const t = curTaskId ? tasks.find(x => x.id === curTaskId) : null;
|
|
1453
|
+
if (wasUnread || (t && (t.status === 'done' || t.status === 'failed') && getSess(curKey()).length === 0)) {
|
|
1454
|
+
loadMessages(); // async,内部会 renderMessages
|
|
1455
|
+
} else {
|
|
1456
|
+
renderMessages(); // 执行中会话保留本地流式状态,避免气泡分叉
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
// ---------- 完成提醒:右下角弹窗 + 侧栏未读绿点 ----------
|
|
1461
|
+
// 工作中的任务保持原有「执行中」运转标识;空闲且无未读的会话无任何标识
|
|
1462
|
+
const unreadSess = new Set(); // 会话 key 集合:有未查看的完成消息
|
|
1463
|
+
let doneToastTimer = null;
|
|
1464
|
+
|
|
1465
|
+
function markUnread(key) {
|
|
1466
|
+
if (key === curKey()) return;
|
|
1467
|
+
unreadSess.add(key);
|
|
1468
|
+
renderSidebar();
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
// 取某会话最后一条汇总消息文本(群聊取 report 阶段;单聊任务取最后一条 assistant),作为弹窗预览内容
|
|
1472
|
+
function lastReportText(taskId) {
|
|
1473
|
+
const arr = getSess(sessKey(taskId));
|
|
1474
|
+
for (let i = arr.length - 1; i >= 0; i--) {
|
|
1475
|
+
const m = arr[i];
|
|
1476
|
+
if (m.phase === 'report' && m.content) return String(m.content).replace(/\s+/g, ' ').trim();
|
|
1477
|
+
}
|
|
1478
|
+
for (let i = arr.length - 1; i >= 0; i--) {
|
|
1479
|
+
if (arr[i].role === 'assistant' && arr[i].content) return String(arr[i].content).replace(/\s+/g, ' ').trim();
|
|
1480
|
+
}
|
|
1481
|
+
return '';
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
// 右下角弹窗:显示最终回答部分内容,点击跳回原会话;不处理 3 秒后自动消失(保留绿点)
|
|
1485
|
+
function showDoneToast(taskId, title) {
|
|
1486
|
+
const key = sessKey(taskId || '');
|
|
1487
|
+
if (key === curKey() || (chatMode === 'solo' && key === curOcId)) return; // 用户正在看该会话,无需提醒
|
|
1488
|
+
markUnread(key);
|
|
1489
|
+
const old = document.querySelector('.done-toast');
|
|
1490
|
+
if (old) old.remove();
|
|
1491
|
+
if (doneToastTimer) { clearTimeout(doneToastTimer); doneToastTimer = null; }
|
|
1492
|
+
const text = lastReportText(taskId) || '任务已完成,点击查看结果';
|
|
1493
|
+
const t = document.createElement('div');
|
|
1494
|
+
t.className = 'done-toast';
|
|
1495
|
+
t.title = '点击查看完整对话';
|
|
1496
|
+
t.innerHTML = `<div class="dt-head">✅ ${esc(title || (taskId ? '任务完成' : '已回复'))}</div><div class="dt-body">${esc(text.slice(0, 120))}${text.length > 120 ? '…' : ''}</div>`;
|
|
1497
|
+
t.onclick = () => {
|
|
1498
|
+
jumpToSession(taskId || '');
|
|
1499
|
+
t.remove();
|
|
1500
|
+
if (doneToastTimer) { clearTimeout(doneToastTimer); doneToastTimer = null; }
|
|
1501
|
+
};
|
|
1502
|
+
document.body.appendChild(t);
|
|
1503
|
+
doneToastTimer = setTimeout(() => { t.remove(); doneToastTimer = null; }, 3000);
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
// 跳转到某会话:solo 任务进单聊视图,其余进群聊视图
|
|
1507
|
+
function jumpToSession(taskId) {
|
|
1508
|
+
const t = taskId ? tasks.find(x => x.id === taskId) : null;
|
|
1509
|
+
if (t && t.runner === 'solo') switchSoloSession(taskId);
|
|
1510
|
+
else switchSession(taskId);
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
function renderHeader() {
|
|
1514
|
+
// 顶栏按钮按模式显隐:单聊=模型输入框+新会话+历史+帮助(配置/流转属群聊功能)
|
|
1515
|
+
document.getElementById('ocModelBox').style.display = chatMode === 'solo' ? '' : 'none';
|
|
1516
|
+
document.getElementById('flowEntryBtn').style.display = chatMode === 'group' ? '' : 'none';
|
|
1517
|
+
document.getElementById('agentsCfgBtn').style.display = chatMode === 'group' ? '' : 'none';
|
|
1518
|
+
if (chatMode === 'solo') {
|
|
1519
|
+
const s = ocSessions.find(x => x.id === curOcId);
|
|
1520
|
+
const t = curOcId ? tasks.find(x => x.id === curOcId && x.runner === 'solo') : null;
|
|
1521
|
+
document.getElementById('chatTitle').textContent = t ? `单聊任务 · ${t.title}` : (s ? `单聊 · ${s.title || '新会话'}` : '单聊');
|
|
1522
|
+
document.getElementById('members').innerHTML = t
|
|
1523
|
+
? `<span style="font-size:11px;color:#b2b2b2">单聊任务会话 · ${statusText(t.status)}</span>`
|
|
1524
|
+
: '';
|
|
1525
|
+
document.getElementById('newSessBtn').style.display = '';
|
|
1526
|
+
document.getElementById('histBtn').style.display = '';
|
|
1527
|
+
return;
|
|
1528
|
+
}
|
|
1529
|
+
document.getElementById('chatTitle').textContent = curTaskId
|
|
1530
|
+
? (tasks.find(t => t.id === curTaskId) || {}).title || '任务会话'
|
|
1531
|
+
: 'Agents 群聊';
|
|
1532
|
+
document.getElementById('newSessBtn').style.display = curTaskId ? 'none' : '';
|
|
1533
|
+
document.getElementById('histBtn').style.display = curTaskId ? 'none' : '';
|
|
1534
|
+
const el = document.getElementById('members');
|
|
1535
|
+
if (curTaskId) {
|
|
1536
|
+
const t = tasks.find(x => x.id === curTaskId);
|
|
1537
|
+
el.innerHTML = `<span style="font-size:11px;color:#b2b2b2">任务会话 · ${statusText((t || {}).status || '')} · 上下文独立,可继续对话</span>`;
|
|
1538
|
+
} else {
|
|
1539
|
+
renderMembers();
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
// ---------- 消息渲染 ----------
|
|
1544
|
+
function scrollBottom() { const box = document.getElementById('messages'); box.scrollTop = box.scrollHeight; }
|
|
1545
|
+
|
|
1546
|
+
function planNode(m) {
|
|
1547
|
+
const row = document.createElement('div');
|
|
1548
|
+
row.className = 'msg-row';
|
|
1549
|
+
const p = m.plan;
|
|
1550
|
+
let inner;
|
|
1551
|
+
if (p && Array.isArray(p.phases)) {
|
|
1552
|
+
inner = `<div class="plan-thought">${esc(p.thought || '')}</div>` + p.phases.map((g, i) =>
|
|
1553
|
+
`<div class="plan-phase"><b>阶段 ${i + 1}</b>(${g.length > 1 ? g.length + ' 项并行' : '单执行'})
|
|
1554
|
+
${g.map(s => `<div class="plan-step">▸ <b>${esc(s.agentName)}</b>:${esc(s.instruction)}</div>`).join('')}
|
|
1555
|
+
</div>`).join('');
|
|
1556
|
+
} else {
|
|
1557
|
+
inner = esc(m.content);
|
|
1558
|
+
}
|
|
1559
|
+
row.innerHTML = `
|
|
1560
|
+
<div class="avatar butler">🎩</div>
|
|
1561
|
+
<div class="msg-body">
|
|
1562
|
+
<div class="msg-name">管家 · 调度规划</div>
|
|
1563
|
+
<div class="bubble left plan-card">${inner}</div>
|
|
1564
|
+
</div>`;
|
|
1565
|
+
return row;
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
// 折叠切换(历史拼接与实时渲染共用)
|
|
1569
|
+
function toggleFoldEl(el) {
|
|
1570
|
+
const wrap = el.previousElementSibling;
|
|
1571
|
+
const expanded = wrap.classList.toggle('expanded');
|
|
1572
|
+
el.textContent = expanded ? '收起 ▴' : '展开全文 ▾';
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
// 过程类消息(执行/验收/圆桌发言)默认折叠,仅显示约三行;最终交付(汇总)默认完整展示
|
|
1576
|
+
function isFoldPhase(phase) { return phase === 'work' || phase === 'review' || phase === 'talk'; }
|
|
1577
|
+
|
|
1578
|
+
// ---------- Markdown 预览:消息中的 .md 绝对路径 → 可点击链接 ----------
|
|
1579
|
+
function linkifyMd(escaped) {
|
|
1580
|
+
return escaped.replace(/(?:[A-Za-z]:[\\\/]|\/)[^\s'"`<>|*]+?\.md\b/gi, m =>
|
|
1581
|
+
`<span class="md-link" data-path="${escAttr(m)}">${m}</span>`);
|
|
1582
|
+
}
|
|
1583
|
+
// 消息定稿后链接化(流式期间保持 textContent 高频更新,saved 封口后一次性处理)
|
|
1584
|
+
function finalizeMsgLinks(m) {
|
|
1585
|
+
if (!m || !m._tx || !m._dom || !document.contains(m._dom)) return;
|
|
1586
|
+
const e = esc(m.content || '');
|
|
1587
|
+
const h = linkifyMd(e);
|
|
1588
|
+
if (h !== e) m._tx.innerHTML = h;
|
|
1589
|
+
}
|
|
1590
|
+
// 过程存档行:.md 文件可点击预览,其他类型仅展示路径
|
|
1591
|
+
function fileNodeText(p) {
|
|
1592
|
+
return /\.md$/i.test(p) ? `<span class="md-link" data-path="${escAttr(p)}">${esc(p)}</span>` : esc(p);
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
// 极简 Markdown 渲染(零依赖,支持标题/列表/代码块/引用/表格/链接/粗斜体)
|
|
1596
|
+
function renderMd(src) {
|
|
1597
|
+
const esc2 = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
1598
|
+
const inline = (s) => s
|
|
1599
|
+
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
1600
|
+
.replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>')
|
|
1601
|
+
.replace(/(^|[^*])\*([^*\s][^*]*?)\*/g, '$1<i>$2</i>')
|
|
1602
|
+
.replace(/\[([^\]]+)\]\((https?:[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
|
|
1603
|
+
const lines = String(src || '').split(/\r?\n/);
|
|
1604
|
+
const html = [];
|
|
1605
|
+
let inCode = false, codeBuf = [];
|
|
1606
|
+
let listType = null, listBuf = [];
|
|
1607
|
+
let tableBuf = [], tableHeader = false;
|
|
1608
|
+
const flushList = () => {
|
|
1609
|
+
if (!listType) return;
|
|
1610
|
+
html.push(`<${listType}>${listBuf.join('')}</${listType}>`);
|
|
1611
|
+
listType = null; listBuf = [];
|
|
1612
|
+
};
|
|
1613
|
+
const flushTable = () => {
|
|
1614
|
+
if (!tableBuf.length) { tableHeader = false; return; }
|
|
1615
|
+
let out = '<table>';
|
|
1616
|
+
tableBuf.forEach((cells, i) => {
|
|
1617
|
+
const tag = (i === 0 && tableHeader) ? 'th' : 'td';
|
|
1618
|
+
out += '<tr>' + cells.map(c => `<${tag}>${inline(esc2(c))}</${tag}>`).join('') + '</tr>';
|
|
1619
|
+
});
|
|
1620
|
+
html.push(out + '</table>');
|
|
1621
|
+
tableBuf = []; tableHeader = false;
|
|
1622
|
+
};
|
|
1623
|
+
for (const line of lines) {
|
|
1624
|
+
const fence = line.match(/^\s*```(\w*)\s*$/);
|
|
1625
|
+
if (fence) {
|
|
1626
|
+
if (inCode) { html.push('<pre><code>' + esc2(codeBuf.join('\n')) + '</code></pre>'); inCode = false; codeBuf = []; }
|
|
1627
|
+
else { flushList(); flushTable(); inCode = true; }
|
|
1628
|
+
continue;
|
|
1629
|
+
}
|
|
1630
|
+
if (inCode) { codeBuf.push(line); continue; }
|
|
1631
|
+
if (/^\s*\|.*\|\s*$/.test(line)) {
|
|
1632
|
+
flushList();
|
|
1633
|
+
const cells = line.trim().slice(1, -1).split('|').map(c => c.trim());
|
|
1634
|
+
if (cells.length && cells.every(c => /^:?-{3,}:?$/.test(c))) { tableHeader = true; continue; }
|
|
1635
|
+
tableBuf.push(cells);
|
|
1636
|
+
continue;
|
|
1637
|
+
}
|
|
1638
|
+
flushTable();
|
|
1639
|
+
if (/^\s*(-{3,}|\*{3,})\s*$/.test(line)) { flushList(); html.push('<hr>'); continue; }
|
|
1640
|
+
const h = line.match(/^(#{1,4})\s+(.*)$/);
|
|
1641
|
+
if (h) { flushList(); const n = h[1].length; html.push(`<h${n}>${inline(esc2(h[2]))}</h${n}>`); continue; }
|
|
1642
|
+
const ul = line.match(/^\s*[-*+]\s+(.*)$/);
|
|
1643
|
+
const ol = line.match(/^\s*\d+[.、)]\s+(.*)$/);
|
|
1644
|
+
if (ul || ol) {
|
|
1645
|
+
const t = ul ? 'ul' : 'ol';
|
|
1646
|
+
if (listType !== t) { flushList(); listType = t; }
|
|
1647
|
+
listBuf.push(`<li>${inline(esc2((ul || ol)[1]))}</li>`);
|
|
1648
|
+
continue;
|
|
1649
|
+
}
|
|
1650
|
+
const q = line.match(/^\s*>\s?(.*)$/);
|
|
1651
|
+
if (q) { flushList(); html.push('<blockquote>' + inline(esc2(q[1])) + '</blockquote>'); continue; }
|
|
1652
|
+
flushList();
|
|
1653
|
+
if (line.trim()) html.push('<p>' + inline(esc2(line)) + '</p>');
|
|
1654
|
+
}
|
|
1655
|
+
if (inCode) html.push('<pre><code>' + esc2(codeBuf.join('\n')) + '</code></pre>');
|
|
1656
|
+
flushList();
|
|
1657
|
+
flushTable();
|
|
1658
|
+
return html.join('\n');
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
async function openPreview(fp) {
|
|
1662
|
+
document.getElementById('pvName').textContent = String(fp).split(/[\\\/]/).pop();
|
|
1663
|
+
document.getElementById('pvPath').textContent = fp;
|
|
1664
|
+
const body = document.getElementById('pvBody');
|
|
1665
|
+
body.innerHTML = '<p style="color:#999">加载中…</p>';
|
|
1666
|
+
document.getElementById('previewMask').classList.add('show');
|
|
1667
|
+
try {
|
|
1668
|
+
const r = await api('/api/file?path=' + encodeURIComponent(fp));
|
|
1669
|
+
if (!r.success) throw new Error(r.error || '读取失败');
|
|
1670
|
+
body.innerHTML = /\.md$/i.test(fp) ? renderMd(r.content) : '<pre style="white-space:pre-wrap">' + esc(r.content) + '</pre>';
|
|
1671
|
+
body.scrollTop = 0;
|
|
1672
|
+
} catch (e) {
|
|
1673
|
+
body.innerHTML = '<p style="color:#c0392b">加载失败:' + esc(e.message) + '</p>';
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
function closePreview() { document.getElementById('previewMask').classList.remove('show'); }
|
|
1677
|
+
|
|
1678
|
+
function msgNode(m) {
|
|
1679
|
+
if (m.role === 'sys') {
|
|
1680
|
+
const d = document.createElement('div');
|
|
1681
|
+
d.className = 'sys-tip';
|
|
1682
|
+
d.innerHTML = `<span>${esc(m.content)}</span>`;
|
|
1683
|
+
return d;
|
|
1684
|
+
}
|
|
1685
|
+
if (m.role === 'approval') {
|
|
1686
|
+
// 人工审批卡片:等待用户放行/否决(刷新后经 /api/approvals 恢复)
|
|
1687
|
+
const d = document.createElement('div');
|
|
1688
|
+
d.className = 'approval-card';
|
|
1689
|
+
d.dataset.aid = m.approvalId;
|
|
1690
|
+
d.innerHTML = `
|
|
1691
|
+
<div class="ap-title">⏸ 人工审批(${m.kind === 'plan' ? '调度方案' : '交付确认'})</div>
|
|
1692
|
+
<div class="ap-label">${esc(m.label || '')}</div>
|
|
1693
|
+
<div class="ap-btns">
|
|
1694
|
+
<button class="ap-ok" onclick="approveCard('${escAttr(m.approvalId)}', true, this)">✔ 同意,继续</button>
|
|
1695
|
+
<button class="ap-no" onclick="approveCard('${escAttr(m.approvalId)}', false, this)">✘ 否决,终止</button>
|
|
1696
|
+
</div>`;
|
|
1697
|
+
return d;
|
|
1698
|
+
}
|
|
1699
|
+
if (m.role === 'working') {
|
|
1700
|
+
// 工作中动画指示条(不持久化,仅执行期间展示)
|
|
1701
|
+
const d = document.createElement('div');
|
|
1702
|
+
d.className = 'sys-tip';
|
|
1703
|
+
d.innerHTML = `<span class="working-bar"><span class="wb-ic">${esc(m.icon || '⏳')}</span><span class="wb-tx">${esc(m.agentName || '智能体')} 正在${PHASE_LABEL[m.phase] || '工作'}…</span></span>`;
|
|
1704
|
+
return d;
|
|
1705
|
+
}
|
|
1706
|
+
if (m.role === 'user') {
|
|
1707
|
+
const row = document.createElement('div');
|
|
1708
|
+
row.className = 'msg-row self';
|
|
1709
|
+
row.innerHTML = `
|
|
1710
|
+
<div class="avatar me">我</div>
|
|
1711
|
+
<div class="msg-body"><div class="bubble right">${esc(m.content)}</div></div>`;
|
|
1712
|
+
return row;
|
|
1713
|
+
}
|
|
1714
|
+
if (m.phase === 'plan') return planNode(m);
|
|
1715
|
+
const row = document.createElement('div');
|
|
1716
|
+
row.className = 'msg-row';
|
|
1717
|
+
const tag = m.phase && PHASE_LABEL[m.phase] ? `<span class="ph">【${PHASE_LABEL[m.phase]}】</span>` : '';
|
|
1718
|
+
const avIcon = esc(agentIcon(m.agentId, m.agentName));
|
|
1719
|
+
const bodyHtml = isFoldPhase(m.phase)
|
|
1720
|
+
? `<div class="bubble left"><div class="fold-wrap">${tag}<span class="tx"></span></div><div class="fold-toggle" onclick="toggleFoldEl(this)">展开全文 ▾</div></div>`
|
|
1721
|
+
: `<div class="bubble left">${tag}<span class="tx"></span></div>`;
|
|
1722
|
+
row.innerHTML = `
|
|
1723
|
+
<div class="avatar ${m.agentId === butlerId ? 'butler' : ''}" style="${m.agentId === butlerId ? '' : `background:${colorOf(m.agentId || 'x')}`}">${avIcon}</div>
|
|
1724
|
+
<div class="msg-body">
|
|
1725
|
+
<div class="msg-name">${esc(m.agentName || m.agentId || 'AI')}${m.agentId === butlerId ? ' · 管家' : ''}</div>
|
|
1726
|
+
${bodyHtml}
|
|
1727
|
+
${m.outputPath ? `<div class="msg-file" ${/\.md$/i.test(m.outputPath) ? `data-path="${escAttr(m.outputPath)}" title="点击预览该 Markdown 存档"` : `title="系统过程存档:该智能体本阶段完整输出(内部交接用)。用户最终要的成果文件在产出存档目录中,以管家交付清单里的完整路径为准"`}>📄 过程存档 ${fileNodeText(m.outputPath)}</div>` : ''}
|
|
1728
|
+
</div>`;
|
|
1729
|
+
row.querySelector('.tx').textContent = m.content;
|
|
1730
|
+
m._tx = row.querySelector('.tx');
|
|
1731
|
+
return row;
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
// 全量重绘当前会话(切换会话 / 载入历史时)
|
|
1735
|
+
function renderMessages() {
|
|
1736
|
+
const box = document.getElementById('messages');
|
|
1737
|
+
box.innerHTML = '';
|
|
1738
|
+
for (const m of getSess(curKey())) {
|
|
1739
|
+
const node = msgNode(m);
|
|
1740
|
+
m._dom = node;
|
|
1741
|
+
box.appendChild(node);
|
|
1742
|
+
finalizeMsgLinks(m); // 定稿消息中的 .md 路径变成可点击预览链接
|
|
1743
|
+
}
|
|
1744
|
+
scrollBottom();
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
// 追加或更新一条流式消息(仅当前会话操作 DOM)
|
|
1748
|
+
function ensureMsgDom(m) {
|
|
1749
|
+
const key = sessKey(m.taskId || curTaskId);
|
|
1750
|
+
if (key !== curKey()) return;
|
|
1751
|
+
if (m._dom && document.contains(m._dom)) { if (m._tx) m._tx.textContent = m.content; scrollBottom(); return; }
|
|
1752
|
+
const node = msgNode(m);
|
|
1753
|
+
m._dom = node;
|
|
1754
|
+
document.getElementById('messages').appendChild(node);
|
|
1755
|
+
scrollBottom();
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
function pushMsg(m) {
|
|
1759
|
+
const key = sessKey(m.taskId);
|
|
1760
|
+
getSess(key).push(m);
|
|
1761
|
+
if (key === curKey()) ensureMsgDom(m);
|
|
1762
|
+
return m;
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
function addSys(text, taskId) {
|
|
1766
|
+
return pushMsg({ role: 'sys', content: text, taskId: taskId || curTaskId, ts: Date.now() });
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
// ---------- 人工审批卡片 ----------
|
|
1770
|
+
async function approveCard(id, approved, btn) {
|
|
1771
|
+
try {
|
|
1772
|
+
const r = await api('/api/approval', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, approved }) });
|
|
1773
|
+
if (!r.success) throw new Error(r.error || '已处理');
|
|
1774
|
+
document.querySelectorAll(`.approval-card[data-aid="${CSS.escape(id)}"] .ap-btns`).forEach(el => {
|
|
1775
|
+
el.outerHTML = `<div class="ap-done">${approved ? '✔ 已同意,编排继续' : '✘ 已否决,编排终止'}</div>`;
|
|
1776
|
+
});
|
|
1777
|
+
} catch (e) {
|
|
1778
|
+
toast('审批失败:' + e.message);
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
// 断线/刷新恢复:把服务端仍在等待的审批卡片补回对应会话(同 ID 去重)
|
|
1783
|
+
async function restoreApprovals() {
|
|
1784
|
+
try {
|
|
1785
|
+
const r = await api('/api/approvals');
|
|
1786
|
+
const seen = new Set(getSess(curKey()).filter(m => m.role === 'approval').map(m => m.approvalId));
|
|
1787
|
+
for (const a of (r.approvals || [])) {
|
|
1788
|
+
if (seen.has(a.id)) continue;
|
|
1789
|
+
pushMsg({ role: 'approval', approvalId: a.id, kind: a.kind, label: a.label, taskId: a.taskId || curTaskId, ts: Date.now() });
|
|
1790
|
+
}
|
|
1791
|
+
} catch { /* 恢复失败不影响使用 */ }
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
function pushUser(text, taskId) {
|
|
1795
|
+
return pushMsg({ role: 'user', content: text, taskId: taskId || curTaskId, ts: Date.now() });
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
function pushPlan(ev, taskId) {
|
|
1799
|
+
return pushMsg({
|
|
1800
|
+
role: 'assistant', phase: 'plan', taskId: taskId || '',
|
|
1801
|
+
agentId: ev.agentId || butlerId, agentName: ev.agentName || '管家',
|
|
1802
|
+
plan: { thought: ev.thought, phases: ev.phases }, content: '',
|
|
1803
|
+
ts: Date.now()
|
|
1804
|
+
});
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
// 封口:某智能体同阶段再次开始(如圆桌第二轮发言)时,把上一条流式消息标记结束,
|
|
1808
|
+
// 后续文本会新建独立气泡(否则会被错误地追加进上一轮的气泡里)
|
|
1809
|
+
function sealLiveStreams(ev) {
|
|
1810
|
+
const key = sessKey(ev.taskId || '');
|
|
1811
|
+
const sk = ev.agentId + '|' + ev.phase + '|' + key;
|
|
1812
|
+
const arr = getSess(key);
|
|
1813
|
+
for (let i = arr.length - 1; i >= 0; i--) {
|
|
1814
|
+
if (arr[i]._sk === sk && arr[i]._live) arr[i]._live = false;
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
function onStreamText(ev) {
|
|
1819
|
+
const key = sessKey(ev.taskId);
|
|
1820
|
+
const sk = ev.agentId + '|' + ev.phase + '|' + key;
|
|
1821
|
+
const arr = getSess(key);
|
|
1822
|
+
let m = null;
|
|
1823
|
+
for (let i = arr.length - 1; i >= 0; i--) {
|
|
1824
|
+
if (arr[i]._sk === sk && arr[i]._live) { m = arr[i]; break; }
|
|
1825
|
+
}
|
|
1826
|
+
if (!m) {
|
|
1827
|
+
m = { role: 'assistant', content: '', agentId: ev.agentId, agentName: ev.agentName, phase: ev.phase, taskId: ev.taskId, _sk: sk, _live: true, ts: Date.now() };
|
|
1828
|
+
arr.push(m);
|
|
1829
|
+
}
|
|
1830
|
+
m.content += ev.content;
|
|
1831
|
+
ensureMsgDom(m);
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
// 正式产出已落盘:给对应消息气泡附上完整产出文件路径
|
|
1835
|
+
function onSaved(ev) {
|
|
1836
|
+
const key = sessKey(ev.taskId || '');
|
|
1837
|
+
const arr = getSess(key);
|
|
1838
|
+
for (let i = arr.length - 1; i >= 0; i--) {
|
|
1839
|
+
const m = arr[i];
|
|
1840
|
+
if (m._live && m.agentId === ev.agentId && m.phase === ev.phase && !m.outputPath) {
|
|
1841
|
+
m.outputPath = ev.path;
|
|
1842
|
+
m._live = false; // 封口:后续同名流不再追加进本条
|
|
1843
|
+
if (m._dom && document.contains(m._dom) && !m._dom.querySelector('.msg-file')) {
|
|
1844
|
+
const file = document.createElement('div');
|
|
1845
|
+
file.className = 'msg-file';
|
|
1846
|
+
if (/\.md$/i.test(ev.path)) {
|
|
1847
|
+
file.dataset.path = ev.path;
|
|
1848
|
+
file.title = '点击预览该 Markdown 存档';
|
|
1849
|
+
} else {
|
|
1850
|
+
file.title = '系统过程存档:该智能体本阶段完整输出(内部交接用)。用户最终要的成果文件在产出存档目录中,以管家交付清单里的完整路径为准';
|
|
1851
|
+
}
|
|
1852
|
+
file.innerHTML = '📄 过程存档 ' + fileNodeText(ev.path);
|
|
1853
|
+
m._dom.querySelector('.msg-body').appendChild(file);
|
|
1854
|
+
scrollBottom();
|
|
1855
|
+
}
|
|
1856
|
+
finalizeMsgLinks(m); // 交付文本中的成果文件路径也变成可点击链接
|
|
1857
|
+
return;
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
function removeMsg(m) {
|
|
1863
|
+
if (!m) return;
|
|
1864
|
+
const arr = getSess(sessKey(m.taskId));
|
|
1865
|
+
const i = arr.indexOf(m);
|
|
1866
|
+
if (i >= 0) arr.splice(i, 1);
|
|
1867
|
+
if (m._dom && m._dom.parentNode) m._dom.parentNode.removeChild(m._dom);
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
// ---------- SSE 流 ----------
|
|
1871
|
+
async function streamSSE(path, body, onEvent) {
|
|
1872
|
+
let resp;
|
|
1873
|
+
try {
|
|
1874
|
+
resp = await fetch(path, {
|
|
1875
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
|
|
1876
|
+
});
|
|
1877
|
+
} catch (err) {
|
|
1878
|
+
addSys('请求失败:' + (err && err.message || err));
|
|
1879
|
+
return;
|
|
1880
|
+
}
|
|
1881
|
+
if (!resp.ok) {
|
|
1882
|
+
const data = await resp.json().catch(() => ({}));
|
|
1883
|
+
addSys('错误: ' + (data.error || resp.status));
|
|
1884
|
+
return;
|
|
1885
|
+
}
|
|
1886
|
+
const reader = resp.body.getReader();
|
|
1887
|
+
const dec = new TextDecoder();
|
|
1888
|
+
let buf = '';
|
|
1889
|
+
while (true) {
|
|
1890
|
+
const { done, value } = await reader.read();
|
|
1891
|
+
if (done) break;
|
|
1892
|
+
buf += dec.decode(value, { stream: true });
|
|
1893
|
+
const parts = buf.split('\n\n');
|
|
1894
|
+
buf = parts.pop() || '';
|
|
1895
|
+
for (const part of parts) {
|
|
1896
|
+
for (const line of part.split('\n')) {
|
|
1897
|
+
if (!line.startsWith('data: ')) continue;
|
|
1898
|
+
try { onEvent(JSON.parse(line.slice(6))); } catch {}
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
// ---------- 聊天 ----------
|
|
1905
|
+
// 发送按钮双重身份:空闲=发送;处理中=停止当前编排
|
|
1906
|
+
function setSendBtn() {
|
|
1907
|
+
const btn = document.getElementById('sendBtn');
|
|
1908
|
+
if (chatBusy) {
|
|
1909
|
+
btn.textContent = stopAsked ? '停止中…' : '停止';
|
|
1910
|
+
btn.classList.add('stop');
|
|
1911
|
+
btn.disabled = stopAsked;
|
|
1912
|
+
} else {
|
|
1913
|
+
btn.textContent = '发送';
|
|
1914
|
+
btn.classList.remove('stop');
|
|
1915
|
+
btn.disabled = false;
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
async function onSendBtn() {
|
|
1920
|
+
if (chatBusy) { await stopRun('chat'); return; }
|
|
1921
|
+
send();
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
// 圆桌讨论模式:发言席上所有被 @ 智能体(未 @ 则全体)围绕主题轮流发言,管家主持
|
|
1925
|
+
function toggleRoundtable() {
|
|
1926
|
+
roundtableOn = !roundtableOn;
|
|
1927
|
+
const btn = document.getElementById('rtBtn');
|
|
1928
|
+
btn.classList.toggle('on', roundtableOn);
|
|
1929
|
+
btn.textContent = roundtableOn ? '💬 圆桌 · 开' : '💬 圆桌';
|
|
1930
|
+
input.placeholder = roundtableOn ? '圆桌讨论:@ 参与者(不 @ 则全体子智能体),发送即开始…' : '输入消息,@ 可点名智能体…';
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
async function stopRun(scope) {
|
|
1934
|
+
stopAsked = true;
|
|
1935
|
+
setSendBtn();
|
|
1936
|
+
updateRunAllBtn();
|
|
1937
|
+
try {
|
|
1938
|
+
await api('/api/stop', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ scope }) });
|
|
1939
|
+
toast('已请求停止,正在等待进程结束…');
|
|
1940
|
+
} catch { toast('停止请求失败,请重试'); }
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
async function send() {
|
|
1944
|
+
const text = input.value.trim();
|
|
1945
|
+
if (!text || chatBusy) return;
|
|
1946
|
+
chatBusy = true;
|
|
1947
|
+
stopAsked = false;
|
|
1948
|
+
setSendBtn();
|
|
1949
|
+
let typing = null;
|
|
1950
|
+
try {
|
|
1951
|
+
input.value = '';
|
|
1952
|
+
hideAt();
|
|
1953
|
+
pushUser(text);
|
|
1954
|
+
typing = addSys('智能体处理中…');
|
|
1955
|
+
|
|
1956
|
+
await streamSSE('/api/chat', { message: text, taskId: curTaskId, mode: roundtableOn ? 'roundtable' : '' }, (e) => {
|
|
1957
|
+
if (typing) { removeMsg(typing); typing = null; }
|
|
1958
|
+
handleStreamEvent(e);
|
|
1959
|
+
});
|
|
1960
|
+
} catch (err) {
|
|
1961
|
+
addSys('处理异常:' + (err && err.message || err));
|
|
1962
|
+
} finally {
|
|
1963
|
+
if (typing) removeMsg(typing);
|
|
1964
|
+
chatBusy = false;
|
|
1965
|
+
stopAsked = false;
|
|
1966
|
+
setSendBtn();
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
// ---------- 工作中动画指示条(stage 起、首条文本/saved 止) ----------
|
|
1971
|
+
const workingBars = new Map(); // agentId|phase|taskId -> 前端消息对象
|
|
1972
|
+
function workingKey(ev) { return `${ev.agentId}|${ev.phase || ''}|${ev.taskId || ''}`; }
|
|
1973
|
+
function addWorking(ev) {
|
|
1974
|
+
const key = workingKey(ev);
|
|
1975
|
+
removeWorkingByKey(key);
|
|
1976
|
+
const m = pushMsg({
|
|
1977
|
+
role: 'working', content: '', icon: agentIcon(ev.agentId, ev.agentName),
|
|
1978
|
+
agentId: ev.agentId, agentName: ev.agentName, phase: ev.phase, taskId: ev.taskId || '', ts: Date.now()
|
|
1979
|
+
});
|
|
1980
|
+
workingBars.set(key, m);
|
|
1981
|
+
}
|
|
1982
|
+
function removeWorkingByKey(key) {
|
|
1983
|
+
const m = workingBars.get(key);
|
|
1984
|
+
if (m) { removeMsg(m); workingBars.delete(key); }
|
|
1985
|
+
}
|
|
1986
|
+
function removeWorkingForTask(taskId, phase) {
|
|
1987
|
+
for (const [key, m] of workingBars) {
|
|
1988
|
+
if (m.taskId === (taskId || '') && (!phase || m.phase === phase)) removeWorkingByKey(key);
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
// 聊天/任务共用的流事件处理(调度过程仅展示,不进上下文由后端保证)
|
|
1993
|
+
function handleStreamEvent(e) {
|
|
1994
|
+
if (e.type === 'stage') {
|
|
1995
|
+
sealLiveStreams(e); // 同一智能体同阶段的新一轮开始:封口上一条流式消息(圆桌第 N 轮各自成条)
|
|
1996
|
+
addWorking(e);
|
|
1997
|
+
} else if (e.type === 'phase') {
|
|
1998
|
+
addSys(`▶ 阶段 ${e.index}/${e.total}(${e.parallel ? '并行' : '串行'}):${e.names}`, e.taskId);
|
|
1999
|
+
} else if (e.type === 'plan') {
|
|
2000
|
+
pushPlan(e, e.taskId);
|
|
2001
|
+
} else if (e.type === 'verify') {
|
|
2002
|
+
removeWorkingForTask(e.taskId, 'review');
|
|
2003
|
+
addSys(e.accepted ? `✅ 验收通过(第 ${e.round} 轮)` : `⚠ ${e.note || '验收未通过'}`, e.taskId);
|
|
2004
|
+
} else if (e.type === 'text') {
|
|
2005
|
+
removeWorkingByKey(workingKey(e));
|
|
2006
|
+
onStreamText(e);
|
|
2007
|
+
} else if (e.type === 'saved') {
|
|
2008
|
+
removeWorkingByKey(workingKey(e));
|
|
2009
|
+
onSaved(e);
|
|
2010
|
+
sealLiveStreams(e); // 本轮产出已落盘归档,流式消息封口
|
|
2011
|
+
if (e.phase === 'report') showDoneToast(e.taskId || '', ''); // 最终交付落盘:用户已切走时右下角提醒
|
|
2012
|
+
} else if (e.type === 'notice') {
|
|
2013
|
+
addSys(e.content, e.taskId);
|
|
2014
|
+
} else if (e.type === 'approval_required') {
|
|
2015
|
+
pushMsg({ role: 'approval', approvalId: e.approvalId, kind: e.kind, label: e.label, taskId: e.taskId || curTaskId, ts: Date.now() });
|
|
2016
|
+
} else if (e.type === 'error') {
|
|
2017
|
+
addSys('执行出错: ' + e.content, e.taskId);
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
// ---------- 任务执行 ----------
|
|
2022
|
+
async function runTask(taskId) {
|
|
2023
|
+
const t = tasks.find(x => x.id === taskId);
|
|
2024
|
+
if (chatMode === 'solo' || (t && t.runner === 'solo')) { await soloExecTasks([taskId]); return; }
|
|
2025
|
+
await execTasks([taskId]);
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
async function runAllTasks() {
|
|
2029
|
+
// 按当前模式执行对应待办:单聊=OpenCode 单体任务;群聊=管家编排任务
|
|
2030
|
+
// 定时任务由定时调度器/立即执行按钮负责
|
|
2031
|
+
if (chatMode === 'solo') {
|
|
2032
|
+
const pending = visibleTasks().filter(t => t.status === 'pending' || t.status === 'failed');
|
|
2033
|
+
if (pending.length === 0) { toast('没有待执行的单聊任务'); return; }
|
|
2034
|
+
await soloExecTasks(pending.map(t => t.id));
|
|
2035
|
+
return;
|
|
2036
|
+
}
|
|
2037
|
+
const pending = visibleTasks().filter(t => t.status === 'pending' || t.status === 'failed');
|
|
2038
|
+
if (pending.length === 0) { toast('没有待执行的顺序任务'); return; }
|
|
2039
|
+
await execTasks(pending.map(t => t.id));
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
// 执行按钮双重身份:空闲=执行全部;执行中=停止
|
|
2043
|
+
function updateRunAllBtn() {
|
|
2044
|
+
const btn = document.getElementById('runAllBtn');
|
|
2045
|
+
if (taskBusy) {
|
|
2046
|
+
btn.textContent = stopAsked ? '停止中…' : '停止执行';
|
|
2047
|
+
btn.classList.add('stop');
|
|
2048
|
+
btn.disabled = stopAsked;
|
|
2049
|
+
} else {
|
|
2050
|
+
btn.textContent = '按时间顺序执行全部待办';
|
|
2051
|
+
btn.classList.remove('stop');
|
|
2052
|
+
btn.disabled = false;
|
|
2053
|
+
}
|
|
2054
|
+
}
|
|
2055
|
+
|
|
2056
|
+
async function onRunAllBtn() {
|
|
2057
|
+
if (taskBusy) { await stopRun('tasks'); return; }
|
|
2058
|
+
runAllTasks();
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
async function execTasks(taskIds) {
|
|
2062
|
+
if (taskBusy) { toast('正在执行中,请先停止当前批次'); return; }
|
|
2063
|
+
taskBusy = true;
|
|
2064
|
+
stopAsked = false;
|
|
2065
|
+
updateRunAllBtn();
|
|
2066
|
+
let gotAllDone = false; // 流意外中断(服务重启/网络断开)时向用户明确说明
|
|
2067
|
+
try {
|
|
2068
|
+
taskIds.forEach(id => { const t = tasks.find(x => x.id === id); if (t) t.status = 'running'; });
|
|
2069
|
+
renderSidebar();
|
|
2070
|
+
if (taskIds.length === 1) switchSession(taskIds[0]);
|
|
2071
|
+
|
|
2072
|
+
await streamSSE('/api/tasks/run', { taskIds }, (e) => {
|
|
2073
|
+
if (e.type === 'task_start') {
|
|
2074
|
+
switchSession(e.taskId);
|
|
2075
|
+
addSys(`开始执行:${e.title}(${esc(e.agentName || '管家')} 负责,本会话独立)`, e.taskId);
|
|
2076
|
+
} else if (e.type === 'task_done') {
|
|
2077
|
+
removeWorkingForTask(e.taskId);
|
|
2078
|
+
const t = tasks.find(x => x.id === e.taskId);
|
|
2079
|
+
if (t) t.status = e.status;
|
|
2080
|
+
renderSidebar();
|
|
2081
|
+
addSys(`任务${e.status === 'done' ? '完成' : e.status === 'pending' ? '已停止(可重新执行)' : '失败'}:${e.title}`, e.taskId);
|
|
2082
|
+
if (e.status === 'done') showDoneToast(e.taskId, `任务完成:${e.title}`); // 不在该会话时右下角提醒
|
|
2083
|
+
} else if (e.type === 'all_done') {
|
|
2084
|
+
gotAllDone = true;
|
|
2085
|
+
addSys('全部任务执行完毕');
|
|
2086
|
+
} else {
|
|
2087
|
+
handleStreamEvent(e);
|
|
2088
|
+
}
|
|
2089
|
+
});
|
|
2090
|
+
if (!gotAllDone) addSys('⚠ 与服务的连接中断,任务可能仍在后台执行;刷新页面可查看最新进度');
|
|
2091
|
+
} catch (err) {
|
|
2092
|
+
addSys('处理异常:' + (err && err.message || err));
|
|
2093
|
+
} finally {
|
|
2094
|
+
taskBusy = false;
|
|
2095
|
+
stopAsked = false;
|
|
2096
|
+
updateRunAllBtn();
|
|
2097
|
+
}
|
|
2098
|
+
await loadTasks();
|
|
2099
|
+
renderHeader();
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
// ---------- 开启新会话 ----------
|
|
2103
|
+
// 群聊:清空当前聊天显示(旧内容可在「历史」中查看,不再纳入上下文)
|
|
2104
|
+
// 单聊:新建一个 OpenCode 单聊会话
|
|
2105
|
+
async function newSession() {
|
|
2106
|
+
if (chatMode === 'solo') { newOcSession(); return; }
|
|
2107
|
+
if (chatBusy) { toast('当前消息处理中,请等待或停止后再开新会话'); return; }
|
|
2108
|
+
const data = await api('/api/session/new', { method: 'POST' });
|
|
2109
|
+
if (data.success) {
|
|
2110
|
+
sessions.set('main', []);
|
|
2111
|
+
renderMessages();
|
|
2112
|
+
toast('已开启新会话');
|
|
2113
|
+
} else toast(data.error || '操作失败');
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
// ---------- 聊天历史(主会话全部记录,只读) ----------
|
|
2117
|
+
async function openHistory() {
|
|
2118
|
+
const data = await api('/api/messages');
|
|
2119
|
+
const msgs = (data.messages || []).filter(m => !m.taskId);
|
|
2120
|
+
const el = document.getElementById('historyList');
|
|
2121
|
+
if (!msgs.length) {
|
|
2122
|
+
el.innerHTML = '<div class="task-empty">暂无历史记录</div>';
|
|
2123
|
+
} else {
|
|
2124
|
+
let html = '';
|
|
2125
|
+
let lastEpoch = null;
|
|
2126
|
+
for (const m of msgs) {
|
|
2127
|
+
if (lastEpoch !== null && m.epoch !== undefined && m.epoch !== lastEpoch) {
|
|
2128
|
+
html += '<div class="sys-tip"><span>── 更早的会话 ──</span></div>';
|
|
2129
|
+
}
|
|
2130
|
+
if (m.epoch !== undefined) lastEpoch = m.epoch;
|
|
2131
|
+
if (m.role === 'sys') {
|
|
2132
|
+
html += `<div class="sys-tip"><span>${esc(m.content)}</span></div>`;
|
|
2133
|
+
} else if (m.role === 'user') {
|
|
2134
|
+
html += `<div class="msg-row self"><div class="avatar me">我</div><div class="msg-body"><div class="bubble right">${esc(m.content)}</div></div></div>`;
|
|
2135
|
+
} else if (m.phase === 'plan' && m.plan) {
|
|
2136
|
+
html += `<div class="msg-row"><div class="avatar butler">🎩</div><div class="msg-body"><div class="msg-name">${esc(m.agentName || '管家')} · 调度规划</div><div class="bubble left plan-card"><div class="plan-thought">${esc(m.plan.thought || '')}</div>${(m.plan.phases || []).map((g, gi) => `<div class="plan-phase"><b>阶段 ${gi + 1}</b>(${g.length > 1 ? g.length + ' 项并行' : '单执行'})${g.map(s => `<div class="plan-step">▸ <b>${esc(s.agentName)}</b>:${esc(s.instruction)}</div>`).join('')}</div>`).join('')}</div></div></div>`;
|
|
2137
|
+
} else {
|
|
2138
|
+
const tag = m.phase && PHASE_LABEL[m.phase] ? `<span class="ph">【${PHASE_LABEL[m.phase]}】</span>` : '';
|
|
2139
|
+
const fileTag = m.outputPath ? `<div class="msg-file" ${/\.md$/i.test(m.outputPath) ? `data-path="${escAttr(m.outputPath)}" title="点击预览"` : ''}>📄 过程存档 ${fileNodeText(m.outputPath)}</div>` : '';
|
|
2140
|
+
const contentHtml = isFoldPhase(m.phase)
|
|
2141
|
+
? `<div class="bubble left"><div class="fold-wrap">${tag}${linkifyMd(esc(m.content))}</div><div class="fold-toggle" onclick="toggleFoldEl(this)">展开全文 ▾</div></div>`
|
|
2142
|
+
: `<div class="bubble left">${tag}${linkifyMd(esc(m.content))}</div>`;
|
|
2143
|
+
html += `<div class="msg-row"><div class="avatar ${m.agentId === butlerId ? 'butler' : ''}" style="${m.agentId === butlerId ? '' : `background:${colorOf(m.agentId || 'x')}`}">${esc(agentIcon(m.agentId, m.agentName))}</div><div class="msg-body"><div class="msg-name">${esc(m.agentName || m.agentId || 'AI')}</div>${contentHtml}${fileTag}</div></div>`;
|
|
2144
|
+
}
|
|
2145
|
+
}
|
|
2146
|
+
el.innerHTML = html;
|
|
2147
|
+
}
|
|
2148
|
+
document.getElementById('historyMask').classList.add('show');
|
|
2149
|
+
el.scrollTop = 0;
|
|
2150
|
+
}
|
|
2151
|
+
function closeHistory() { document.getElementById('historyMask').classList.remove('show'); }
|
|
2152
|
+
|
|
2153
|
+
// ---------- 历史管理:一键导出 sessions.md / 一键清空全部会话 ----------
|
|
2154
|
+
async function exportHistory() {
|
|
2155
|
+
try {
|
|
2156
|
+
const r = await fetch('/api/history/export');
|
|
2157
|
+
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
2158
|
+
const blob = await r.blob();
|
|
2159
|
+
const a = document.createElement('a');
|
|
2160
|
+
a.href = URL.createObjectURL(blob);
|
|
2161
|
+
a.download = 'sessions.md';
|
|
2162
|
+
a.click();
|
|
2163
|
+
setTimeout(() => URL.revokeObjectURL(a.href), 5000);
|
|
2164
|
+
toast('已导出 sessions.md(全部会话含任务会话)');
|
|
2165
|
+
} catch (e) { toast('导出失败:' + (e && e.message || e)); }
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
async function clearAllHistory() {
|
|
2169
|
+
if (!confirm('确定清空全部会话记录?\n\n将删除:\n· 全部聊天消息(主会话 + 任务会话 + 单聊会话)\n· 单聊会话列表\n· 会话产出文件(看板 / 过程存档)\n· 流转日志\n\n任务列表本身保留。此操作不可恢复。')) return;
|
|
2170
|
+
const data = await api('/api/history/clear', { method: 'POST' });
|
|
2171
|
+
if (data.success) {
|
|
2172
|
+
sessions.clear();
|
|
2173
|
+
curTaskId = '';
|
|
2174
|
+
curOcId = '';
|
|
2175
|
+
ocSessions = [];
|
|
2176
|
+
await loadMessages();
|
|
2177
|
+
closeHistory();
|
|
2178
|
+
renderHeader();
|
|
2179
|
+
renderSidebar();
|
|
2180
|
+
soloRenderMessages();
|
|
2181
|
+
toast(`已清空:${data.messages} 条消息、${data.outputDirs} 个会话产出目录`);
|
|
2182
|
+
} else toast(data.error || '清空失败');
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
// ---------- 导入任务 ----------
|
|
2186
|
+
let importMode = 'sequential';
|
|
2187
|
+
let importAt = null; // { start, end, items, active }
|
|
2188
|
+
|
|
2189
|
+
const IMPORT_TIP = {
|
|
2190
|
+
sequential: '每行一个任务,使用 <code>1.</code> <code>2.</code> 编号;行末可 <code>@智能体</code> 指派(输入 @ 有补全)——不指派则由管家调度,指派子智能体则独立完成。任务按编号顺序执行。',
|
|
2191
|
+
scheduled: '每行一个定时任务:行首写启动时间(格式 <code>20260818-1307</code>),行首输入 <code>[]</code> 自动填入当前日期时间(可修改);行末可 <code>@智能体</code> 指派。到点自动执行。'
|
|
2192
|
+
};
|
|
2193
|
+
// 单聊模式导入语法:- 接续上一任务会话;// 并行独立进程
|
|
2194
|
+
const IMPORT_TIP_SOLO = {
|
|
2195
|
+
sequential: '单聊任务语法(OpenCode 直接执行,不经管家):<br>· <code>1.任务</code> 编号开头,无前缀 = 新会话独立执行<br>· <code>2.-任务</code> <b>-</b> 前缀 = 在上一任务的会话中继续(同进程续聊,AI 记得前面的成果)<br>· <code>3.//任务</code> <b>//</b> 前缀 = 并行执行:连续多个 <b>//</b> 任务各自独立进程同时跑<br>执行顺序:串行链按编号顺序,遇到连续 <b>//</b> 任务并行执行,之后再继续后续任务',
|
|
2196
|
+
scheduled: '单聊定时任务:行首写启动时间(格式 <code>20260818-1307</code>,输入 <code>[]</code> 自动填入当前时间);同样支持 <code>-</code> 续聊与 <code>//</code> 并行前缀(编号之后)。到点自动执行。'
|
|
2197
|
+
};
|
|
2198
|
+
const IMPORT_PH = {
|
|
2199
|
+
sequential: '1. 检查邮件并回复客户 @管家\n2. 编写周报初稿 @文案\n3. 修复登录页 bug @工程师',
|
|
2200
|
+
scheduled: '20260818-1307 生成今日日报 @文案\n20260818-1800 检查部署日志 @工程师'
|
|
2201
|
+
};
|
|
2202
|
+
const IMPORT_PH_SOLO = {
|
|
2203
|
+
sequential: '1. 写个个人网页的文档\n2. -将网页做成html网页\n3. //查查惠州的天气\n4. //查查北京的天气',
|
|
2204
|
+
scheduled: '20260818-1307 1. 生成今日日报\n20260818-1307 2. //检查部署日志\n20260818-1307 3. //汇总邮件摘要'
|
|
2205
|
+
};
|
|
2206
|
+
|
|
2207
|
+
function openImport() {
|
|
2208
|
+
setImportMode(importMode);
|
|
2209
|
+
document.getElementById('modalMask').classList.add('show');
|
|
2210
|
+
}
|
|
2211
|
+
function closeImport() {
|
|
2212
|
+
document.getElementById('modalMask').classList.remove('show');
|
|
2213
|
+
hideImportAt();
|
|
2214
|
+
}
|
|
2215
|
+
|
|
2216
|
+
function setImportMode(mode) {
|
|
2217
|
+
importMode = mode === 'scheduled' ? 'scheduled' : 'sequential';
|
|
2218
|
+
document.getElementById('tabSeq').className = 'tab' + (importMode === 'sequential' ? ' active' : '');
|
|
2219
|
+
document.getElementById('tabSch').className = 'tab' + (importMode === 'scheduled' ? ' active' : '');
|
|
2220
|
+
const solo = chatMode === 'solo';
|
|
2221
|
+
document.getElementById('importTip').innerHTML = (solo ? IMPORT_TIP_SOLO : IMPORT_TIP)[importMode];
|
|
2222
|
+
const ta = document.getElementById('importText');
|
|
2223
|
+
ta.placeholder = (solo ? IMPORT_PH_SOLO : IMPORT_PH)[importMode];
|
|
2224
|
+
hideImportAt();
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
// 导入框 @ 智能体补全(textarea 版)
|
|
2228
|
+
const importText = document.getElementById('importText');
|
|
2229
|
+
importText.addEventListener('input', onImportInput);
|
|
2230
|
+
importText.addEventListener('blur', () => setTimeout(hideImportAt, 150));
|
|
2231
|
+
|
|
2232
|
+
function onImportInput() {
|
|
2233
|
+
// 定时模式:行首/行中刚输入 [] → 自动替换为当前日期时间(20260818-1307),用户可修改
|
|
2234
|
+
if (importMode === 'scheduled') {
|
|
2235
|
+
const pos = importText.selectionStart;
|
|
2236
|
+
const before = importText.value.slice(0, pos);
|
|
2237
|
+
if (before.endsWith('[]')) {
|
|
2238
|
+
const ts = fmtSchedNow();
|
|
2239
|
+
importText.value = before.slice(0, -2) + ts + importText.value.slice(pos);
|
|
2240
|
+
const np = pos - 2 + ts.length;
|
|
2241
|
+
importText.setSelectionRange(np, np);
|
|
2242
|
+
hideImportAt();
|
|
2243
|
+
return;
|
|
2244
|
+
}
|
|
2245
|
+
}
|
|
2246
|
+
// @ 补全:匹配光标前正在输入的 @词
|
|
2247
|
+
const pos = importText.selectionStart;
|
|
2248
|
+
const before = importText.value.slice(0, pos);
|
|
2249
|
+
const m = before.match(/@([^\s@,。,;;]*)$/);
|
|
2250
|
+
if (!m) { hideImportAt(); return; }
|
|
2251
|
+
const q = m[1].toLowerCase();
|
|
2252
|
+
const items = agents.filter(a =>
|
|
2253
|
+
!q || a.name.toLowerCase().includes(q) || a.id.toLowerCase().includes(q) || String(a.desc || '').toLowerCase().includes(q)
|
|
2254
|
+
).slice(0, 8);
|
|
2255
|
+
if (!items.length) { hideImportAt(); return; }
|
|
2256
|
+
importAt = { start: pos - m[0].length, end: pos, items, active: 0 };
|
|
2257
|
+
renderImportAt();
|
|
2258
|
+
}
|
|
2259
|
+
|
|
2260
|
+
function renderImportAt() {
|
|
2261
|
+
const menu = document.getElementById('importAtMenu');
|
|
2262
|
+
menu.innerHTML = importAt.items.map((a, i) => `
|
|
2263
|
+
<div class="at-item ${i === importAt.active ? 'active' : ''}" data-i="${i}">
|
|
2264
|
+
<span class="at-av" style="background:${a.id === butlerId ? '#8a6fe8' : colorOf(a.id)}">${esc(a.icon || agentIcon(a.id, a.name))}</span>
|
|
2265
|
+
<span class="at-tx"><b>${esc(a.name)}</b>${a.locked ? ' 🔒' : ''}<i>${esc(String(a.desc || a.id || '').slice(0, 40))}</i></span>
|
|
2266
|
+
</div>`).join('');
|
|
2267
|
+
menu.style.display = 'block';
|
|
2268
|
+
menu.querySelectorAll('.at-item').forEach(el => {
|
|
2269
|
+
el.onclick = () => pickImportAt(Number(el.dataset.i));
|
|
2270
|
+
el.onmousedown = (e) => e.preventDefault();
|
|
2271
|
+
});
|
|
2272
|
+
}
|
|
2273
|
+
|
|
2274
|
+
function pickImportAt(i) {
|
|
2275
|
+
if (!importAt) return;
|
|
2276
|
+
const a = importAt.items[i];
|
|
2277
|
+
const v = importText.value;
|
|
2278
|
+
importText.value = v.slice(0, importAt.start) + '@' + a.name + ' ' + v.slice(importAt.end);
|
|
2279
|
+
const np = importAt.start + a.name.length + 2;
|
|
2280
|
+
importText.focus();
|
|
2281
|
+
importText.setSelectionRange(np, np);
|
|
2282
|
+
hideImportAt();
|
|
2283
|
+
}
|
|
2284
|
+
|
|
2285
|
+
function hideImportAt() {
|
|
2286
|
+
importAt = null;
|
|
2287
|
+
document.getElementById('importAtMenu').style.display = 'none';
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
function importKeydown(e) {
|
|
2291
|
+
if (!importAt) return;
|
|
2292
|
+
if (e.key === 'ArrowDown') { e.preventDefault(); importAt.active = (importAt.active + 1) % importAt.items.length; renderImportAt(); }
|
|
2293
|
+
else if (e.key === 'ArrowUp') { e.preventDefault(); importAt.active = (importAt.active - 1 + importAt.items.length) % importAt.items.length; renderImportAt(); }
|
|
2294
|
+
else if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); pickImportAt(importAt.active); }
|
|
2295
|
+
else if (e.key === 'Escape') { hideImportAt(); }
|
|
2296
|
+
}
|
|
2297
|
+
|
|
2298
|
+
async function doImport() {
|
|
2299
|
+
const text = importText.value;
|
|
2300
|
+
if (!text.trim()) { toast('请先粘贴任务文本'); return; }
|
|
2301
|
+
// 任务归属当前模式:单聊模式导入的任务由 OpenCode 单体执行,群聊模式走管家编排;
|
|
2302
|
+
// 单聊导入时记录当前所选模型,定时触发时用它执行(页面关闭也不丢)
|
|
2303
|
+
const data = await api('/api/tasks/import', {
|
|
2304
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
2305
|
+
body: JSON.stringify({ text, mode: importMode, runner: chatMode === 'solo' ? 'solo' : '', model: chatMode === 'solo' ? (curOcModel || '') : '' })
|
|
2306
|
+
});
|
|
2307
|
+
if (data.success) {
|
|
2308
|
+
tasks = data.tasks || [];
|
|
2309
|
+
renderSidebar();
|
|
2310
|
+
if (chatMode === 'solo') {
|
|
2311
|
+
addSoloSys(importMode === 'scheduled'
|
|
2312
|
+
? `已添加 ${data.added} 个单聊定时任务(到点由 OpenCode 自动执行)`
|
|
2313
|
+
: `已提取 ${data.added} 个单聊任务(OpenCode 逐个执行)`);
|
|
2314
|
+
} else {
|
|
2315
|
+
addSys(importMode === 'scheduled'
|
|
2316
|
+
? `已添加 ${data.added} 个定时任务(可在「⏰ 定时任务」中管理)`
|
|
2317
|
+
: `已提取 ${data.added} 个顺序任务`);
|
|
2318
|
+
}
|
|
2319
|
+
(data.warnings || []).forEach(w => chatMode === 'solo' ? addSoloSys('⚠ ' + w) : addSys('⚠ ' + w));
|
|
2320
|
+
closeImport();
|
|
2321
|
+
importText.value = '';
|
|
2322
|
+
if (importMode === 'scheduled' && schedOpen) renderSchedList();
|
|
2323
|
+
} else toast(data.error || '导入失败');
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
async function clearTasks() {
|
|
2327
|
+
if (!confirm('确定清空全部任务?(任务会话的对话记录也会从列表移除)')) return;
|
|
2328
|
+
await api('/api/tasks/clear', { method: 'POST' });
|
|
2329
|
+
if (curTaskId) switchSession('');
|
|
2330
|
+
if (curOcId && !ocSessions.some(s => s.id === curOcId)) { curOcId = ''; renderHeader(); soloRenderMessages(); }
|
|
2331
|
+
await loadTasks();
|
|
2332
|
+
}
|
|
2333
|
+
|
|
2334
|
+
// ---------- 批量管理任务(全部顺序/定时任务,含单聊与群聊) ----------
|
|
2335
|
+
function openBatchMgr() {
|
|
2336
|
+
renderBatchList();
|
|
2337
|
+
document.getElementById('batchMask').classList.add('show');
|
|
2338
|
+
}
|
|
2339
|
+
function closeBatchMgr() {
|
|
2340
|
+
document.getElementById('batchMask').classList.remove('show');
|
|
2341
|
+
}
|
|
2342
|
+
function renderBatchList() {
|
|
2343
|
+
const el = document.getElementById('batchList');
|
|
2344
|
+
document.getElementById('batchAll').checked = false;
|
|
2345
|
+
const groups = [
|
|
2346
|
+
{ label: '顺序任务 · 群聊', list: tasks.filter(t => t.kind !== 'scheduled' && t.runner !== 'solo') },
|
|
2347
|
+
{ label: '顺序任务 · 单聊', list: tasks.filter(t => t.kind !== 'scheduled' && t.runner === 'solo') },
|
|
2348
|
+
{ label: '定时任务 · 群聊', list: tasks.filter(t => t.kind === 'scheduled' && t.runner !== 'solo') },
|
|
2349
|
+
{ label: '定时任务 · 单聊', list: tasks.filter(t => t.kind === 'scheduled' && t.runner === 'solo') }
|
|
2350
|
+
].filter(g => g.list.length);
|
|
2351
|
+
if (!groups.length) {
|
|
2352
|
+
el.innerHTML = '<div class="batch-empty">暂无任务<br>点侧栏「导入任务」添加</div>';
|
|
2353
|
+
return;
|
|
2354
|
+
}
|
|
2355
|
+
el.innerHTML = groups.map(g => `
|
|
2356
|
+
<div class="batch-group">${g.label}(${g.list.length})</div>
|
|
2357
|
+
${g.list.map(t => `
|
|
2358
|
+
<div class="batch-item">
|
|
2359
|
+
<input type="checkbox" class="batch-ck" value="${escAttr(t.id)}">
|
|
2360
|
+
<span class="bi-state ${t.status}">${statusText(t.status)}</span>
|
|
2361
|
+
<div class="bi-title" title="${escAttr(t.title)}">${esc(t.title)}</div>
|
|
2362
|
+
<span class="bi-sub">${t.kind === 'scheduled' ? fmtSched(t.scheduledAt || t.createdAt) : fmtTime(t.createdAt)}</span>
|
|
2363
|
+
</div>`).join('')}`).join('');
|
|
2364
|
+
}
|
|
2365
|
+
function toggleBatchAll(on) {
|
|
2366
|
+
document.querySelectorAll('.batch-ck').forEach(c => { c.checked = on; });
|
|
2367
|
+
}
|
|
2368
|
+
function batchSelIds() {
|
|
2369
|
+
return [...document.querySelectorAll('.batch-ck:checked')].map(c => c.value);
|
|
2370
|
+
}
|
|
2371
|
+
async function batchDeleteSel() {
|
|
2372
|
+
const ids = batchSelIds();
|
|
2373
|
+
if (!ids.length) { toast('请先勾选任务'); return; }
|
|
2374
|
+
if (!confirm(`确定删除选中的 ${ids.length} 个任务及其会话记录?`)) return;
|
|
2375
|
+
let n = 0;
|
|
2376
|
+
for (const id of ids) {
|
|
2377
|
+
const r = await api('/api/tasks/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) });
|
|
2378
|
+
if (r.success) n++;
|
|
2379
|
+
}
|
|
2380
|
+
if (curTaskId && ids.includes(curTaskId)) switchSession('');
|
|
2381
|
+
if (curOcId && ids.includes(curOcId) && !ocSessions.some(s => s.id === curOcId)) { curOcId = ''; renderHeader(); soloRenderMessages(); }
|
|
2382
|
+
await loadTasks();
|
|
2383
|
+
await loadMessages();
|
|
2384
|
+
renderMessages();
|
|
2385
|
+
if (chatMode === 'solo') soloRenderMessages();
|
|
2386
|
+
renderBatchList();
|
|
2387
|
+
toast(`已删除 ${n} 个任务`);
|
|
2388
|
+
}
|
|
2389
|
+
async function batchRunSel() {
|
|
2390
|
+
const ids = batchSelIds();
|
|
2391
|
+
if (!ids.length) { toast('请先勾选任务'); return; }
|
|
2392
|
+
// 只执行顺序任务(定时任务按其触发时间自动执行,也可在侧栏单独「立即执行」)
|
|
2393
|
+
const seq = ids.map(id => tasks.find(t => t.id === id)).filter(t => t && t.kind !== 'scheduled' && t.status !== 'running');
|
|
2394
|
+
if (!seq.length) { toast('选中项没有可执行的顺序任务'); return; }
|
|
2395
|
+
const groups = seq.filter(t => t.runner !== 'solo').map(t => t.id);
|
|
2396
|
+
const solos = seq.filter(t => t.runner === 'solo').map(t => t.id);
|
|
2397
|
+
closeBatchMgr();
|
|
2398
|
+
if (groups.length) await execTasks(groups);
|
|
2399
|
+
if (solos.length) await soloExecTasks(solos);
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
// ---------- 定时任务(侧栏「⏰ 定时任务」视图) ----------
|
|
2403
|
+
let taskView = 'seq'; // 'seq' 顺序任务 | 'sched' 定时任务(同一位置切换显示)
|
|
2404
|
+
let schedEnabled = true; // 定时调度总开关(服务端持久化)
|
|
2405
|
+
|
|
2406
|
+
function setTaskView(v) {
|
|
2407
|
+
taskView = v;
|
|
2408
|
+
document.getElementById('ttabSeq').classList.toggle('active', v === 'seq');
|
|
2409
|
+
document.getElementById('ttabSched').classList.toggle('active', v === 'sched');
|
|
2410
|
+
renderSidebar();
|
|
2411
|
+
updateFooterBtns();
|
|
2412
|
+
}
|
|
2413
|
+
|
|
2414
|
+
// 底部主按钮:顺序视图=执行全部/停止;定时视图=启动/关闭定时调度(流转入口在顶栏「🔭 流转」)
|
|
2415
|
+
function updateFooterBtns() {
|
|
2416
|
+
const run = document.getElementById('runAllBtn');
|
|
2417
|
+
const sp = document.getElementById('schedPowerBtn');
|
|
2418
|
+
const isSeq = taskView === 'seq';
|
|
2419
|
+
run.style.display = isSeq ? '' : 'none';
|
|
2420
|
+
sp.style.display = isSeq ? 'none' : '';
|
|
2421
|
+
if (!isSeq) {
|
|
2422
|
+
sp.textContent = schedEnabled ? '⏸ 关闭定时任务' : '▶ 启动定时任务';
|
|
2423
|
+
sp.classList.toggle('off', !schedEnabled);
|
|
2424
|
+
sp.disabled = false;
|
|
2425
|
+
}
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
async function toggleSchedPower() {
|
|
2429
|
+
const data = await api('/api/sched/toggle', {
|
|
2430
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: !schedEnabled })
|
|
2431
|
+
});
|
|
2432
|
+
if (data.success) {
|
|
2433
|
+
schedEnabled = !!data.enabled;
|
|
2434
|
+
updateFooterBtns();
|
|
2435
|
+
toast(schedEnabled ? '定时任务已启动:到点的任务将自动执行' : '定时任务已关闭:到点不再自动执行(已触发的照常运行)');
|
|
2436
|
+
} else toast(data.error || '操作失败');
|
|
2437
|
+
}
|
|
2438
|
+
|
|
2439
|
+
async function loadSchedState() {
|
|
2440
|
+
try {
|
|
2441
|
+
const data = await api('/api/sched');
|
|
2442
|
+
if (data.success) { schedEnabled = !!data.enabled; updateFooterBtns(); }
|
|
2443
|
+
} catch { /* ignore */ }
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2446
|
+
function schedCountdown(ts) {
|
|
2447
|
+
const diff = ts - Date.now();
|
|
2448
|
+
if (diff <= 0) return '已到点';
|
|
2449
|
+
const min = Math.ceil(diff / 60000);
|
|
2450
|
+
if (min < 60) return `${min} 分钟后`;
|
|
2451
|
+
const h = Math.floor(min / 60), m = min % 60;
|
|
2452
|
+
if (h < 24) return `${h} 小时 ${m} 分后`;
|
|
2453
|
+
return `${Math.floor(h / 24)} 天 ${h % 24} 小时后`;
|
|
2454
|
+
}
|
|
2455
|
+
|
|
2456
|
+
async function runSchedNow(id) {
|
|
2457
|
+
if (taskBusy) { toast('正在执行中,请先停止当前批次'); return; }
|
|
2458
|
+
const t = tasks.find(x => x.id === id);
|
|
2459
|
+
if (chatMode === 'solo' || (t && t.runner === 'solo')) { await soloExecTasks([id]); return; }
|
|
2460
|
+
await execTasks([id]);
|
|
2461
|
+
}
|
|
2462
|
+
|
|
2463
|
+
// ---------- 流转视图(智能体工作状态与信息传递可视化) ----------
|
|
2464
|
+
let flowTimer = null; // 自动刷新定时器
|
|
2465
|
+
let flowEvents = []; // 当前 run 的事件
|
|
2466
|
+
let flowRunId = ''; // 当前查看的 run
|
|
2467
|
+
const FLOW_TYPE_LABEL = { start: '开始', plan: '调度规划', dispatch: '派发', done: '完成', handoff: '交接', rework: '返工', verify: '验收', autocheck: '自动核查', moderate: '主持人判定', finish: '汇总交付' };
|
|
2468
|
+
|
|
2469
|
+
async function openFlow() {
|
|
2470
|
+
document.getElementById('flowMask').classList.add('show');
|
|
2471
|
+
await loadFlowRuns();
|
|
2472
|
+
startFlowTimer();
|
|
2473
|
+
}
|
|
2474
|
+
function closeFlow() {
|
|
2475
|
+
document.getElementById('flowMask').classList.remove('show');
|
|
2476
|
+
const m = document.querySelector('#flowMask .modal.flow');
|
|
2477
|
+
if (m) m.classList.remove('fs');
|
|
2478
|
+
const fb = document.getElementById('flowFsBtn');
|
|
2479
|
+
if (fb) fb.textContent = '⤢ 全屏';
|
|
2480
|
+
stopFlowTimer();
|
|
2481
|
+
}
|
|
2482
|
+
// 全屏切换:泳道图看不全时一键放大到几乎整屏
|
|
2483
|
+
function toggleFlowFs() {
|
|
2484
|
+
const m = document.querySelector('#flowMask .modal.flow');
|
|
2485
|
+
const on = m.classList.toggle('fs');
|
|
2486
|
+
document.getElementById('flowFsBtn').textContent = on ? '⤡ 退出全屏' : '⤢ 全屏';
|
|
2487
|
+
}
|
|
2488
|
+
function stopFlowTimer() { if (flowTimer) { clearInterval(flowTimer); flowTimer = null; } }
|
|
2489
|
+
function startFlowTimer() {
|
|
2490
|
+
stopFlowTimer();
|
|
2491
|
+
if (!document.getElementById('flowAutoChk').checked) return;
|
|
2492
|
+
flowTimer = setInterval(() => { loadFlowEvents(true); if (flowRunId) loadFlowRuns(true); }, 3000);
|
|
2493
|
+
}
|
|
2494
|
+
function onFlowAutoChange() { if (document.getElementById('flowAutoChk').checked) startFlowTimer(); else stopFlowTimer(); }
|
|
2495
|
+
|
|
2496
|
+
async function loadFlowRuns(quiet) {
|
|
2497
|
+
const data = await api('/api/flow/runs');
|
|
2498
|
+
if (!data.success) { if (!quiet) toast(data.error || '加载编排列表失败'); return; }
|
|
2499
|
+
const sel = document.getElementById('flowRunSel');
|
|
2500
|
+
const cur = flowRunId || (data.runs[0] && data.runs[0].run) || '';
|
|
2501
|
+
sel.innerHTML = data.runs.map(r => {
|
|
2502
|
+
const t = r.start ? new Date(r.start) : new Date();
|
|
2503
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
2504
|
+
const label = `${p(t.getMonth()+1)}-${p(t.getDate())} ${p(t.getHours())}:${p(t.getMinutes())} · ${r.agents.slice(0,4).join('、')}${r.agents.length>4?'等':''} · ${r.finished?'已完成':'进行中'} · ${r.summary.slice(0,26)}`;
|
|
2505
|
+
return `<option value="${r.run}" ${r.run === cur ? 'selected' : ''}>${esc(label)}</option>`;
|
|
2506
|
+
}).join('') || '<option value="">(暂无编排记录,发起一次对话或任务后即可看到)</option>';
|
|
2507
|
+
if (cur !== flowRunId) { flowRunId = cur; await loadFlowEvents(quiet); }
|
|
2508
|
+
}
|
|
2509
|
+
|
|
2510
|
+
function onFlowRunChange() {
|
|
2511
|
+
flowRunId = document.getElementById('flowRunSel').value;
|
|
2512
|
+
loadFlowEvents();
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2515
|
+
async function loadFlowEvents(quiet) {
|
|
2516
|
+
if (!flowRunId) { renderFlowEmpty(); return; }
|
|
2517
|
+
const data = await api('/api/flow?run=' + encodeURIComponent(flowRunId));
|
|
2518
|
+
if (!data.success) { if (!quiet) toast(data.error || '加载流转事件失败'); return; }
|
|
2519
|
+
flowEvents = data.events || [];
|
|
2520
|
+
renderFlowSVG();
|
|
2521
|
+
}
|
|
2522
|
+
|
|
2523
|
+
function renderFlowEmpty() {
|
|
2524
|
+
document.getElementById('flowCanvas').innerHTML = '<div style="padding:40px;text-align:center;color:#999;font-size:13px">暂无编排记录<br><br>发起一次对话或执行任务后,这里会展示智能体之间的派发、交接与返工全过程</div>';
|
|
2525
|
+
document.getElementById('flowDetail').style.display = 'none';
|
|
2526
|
+
}
|
|
2527
|
+
|
|
2528
|
+
// SVG 泳道时间线渲染
|
|
2529
|
+
function renderFlowSVG() {
|
|
2530
|
+
const evs = flowEvents;
|
|
2531
|
+
if (!evs.length) { renderFlowEmpty(); return; }
|
|
2532
|
+
// 1) 泳道:按事件中 from/to 首次出现顺序(start.from 通常为管家,天然置顶)
|
|
2533
|
+
const lanes = [];
|
|
2534
|
+
const laneIdx = new Map();
|
|
2535
|
+
for (const e of evs) {
|
|
2536
|
+
for (const name of [e.from, e.to]) {
|
|
2537
|
+
if (!name || laneIdx.has(name)) continue;
|
|
2538
|
+
laneIdx.set(name, lanes.length);
|
|
2539
|
+
lanes.push(name);
|
|
2540
|
+
}
|
|
2541
|
+
}
|
|
2542
|
+
// 2) 时间轴
|
|
2543
|
+
const ts = evs.map(e => new Date(e.t).getTime()).filter(t => !isNaN(t));
|
|
2544
|
+
const now = Date.now();
|
|
2545
|
+
const t0 = Math.min(...ts);
|
|
2546
|
+
const t1 = Math.max(Math.max(...ts), lanes.length ? now : 0);
|
|
2547
|
+
const span = Math.max(t1 - t0, 1000);
|
|
2548
|
+
// 3) 尺寸
|
|
2549
|
+
const LANE_H = 64, PAD_T = 26, PAD_L = 128, PAD_R = 60;
|
|
2550
|
+
const H = PAD_T + lanes.length * LANE_H + 20;
|
|
2551
|
+
const W = Math.max(920, PAD_L + evs.length * 85 + PAD_R);
|
|
2552
|
+
const xOf = (t) => PAD_L + Math.min(1, Math.max(0, (new Date(t).getTime() - t0) / span)) * (W - PAD_L - PAD_R);
|
|
2553
|
+
const yOf = (name) => { const i = laneIdx.get(name); return i === undefined ? null : PAD_T + i * LANE_H + LANE_H / 2; };
|
|
2554
|
+
|
|
2555
|
+
// 4) dispatch→done 配对(同泳道串行块不重叠:起x至少 = 上一块终x+8)
|
|
2556
|
+
const laneLastEnd = new Array(lanes.length).fill(0);
|
|
2557
|
+
const pairDone = new Set();
|
|
2558
|
+
const blocks = [];
|
|
2559
|
+
const dots = [];
|
|
2560
|
+
const edges = [];
|
|
2561
|
+
let seq = 0;
|
|
2562
|
+
for (const e of evs) {
|
|
2563
|
+
const x = xOf(e.t);
|
|
2564
|
+
if (e.type === 'dispatch') {
|
|
2565
|
+
let done = null;
|
|
2566
|
+
for (const d of evs) {
|
|
2567
|
+
if (d.type === 'done' && d.to === e.to && !pairDone.has(d) && new Date(d.t) >= new Date(e.t)) { done = d; break; }
|
|
2568
|
+
}
|
|
2569
|
+
if (done) pairDone.add(done);
|
|
2570
|
+
const y = yOf(e.to);
|
|
2571
|
+
if (y !== null) {
|
|
2572
|
+
const li = laneIdx.get(e.to);
|
|
2573
|
+
let x2 = done ? xOf(done.t) : xOf(now);
|
|
2574
|
+
x2 = Math.max(x2, x + 26);
|
|
2575
|
+
const x1 = Math.max(x, laneLastEnd[li] + 8);
|
|
2576
|
+
if (x1 + 20 > x2) x2 = x1 + 20;
|
|
2577
|
+
laneLastEnd[li] = x2;
|
|
2578
|
+
blocks.push({ x: x1, y: y - 14, w: x2 - x1, h: 28, ev: e, doneEv: done, running: !done, err: done && done.detail && done.detail.ok === false, label: e.to, i: seq++ });
|
|
2579
|
+
}
|
|
2580
|
+
} else if (e.type === 'handoff' || e.type === 'rework' || e.type === 'dispatch') {
|
|
2581
|
+
// dispatch 边在块生成处跳过(块已表达),handoff/rework 画曲线
|
|
2582
|
+
}
|
|
2583
|
+
if (e.type === 'handoff' || e.type === 'rework') {
|
|
2584
|
+
const y1 = yOf(e.from), y2 = yOf(e.to);
|
|
2585
|
+
if (y1 !== null && y2 !== null) edges.push({ x1: x, y1, x2: x, y2, kind: e.type, ev: e, i: seq++ });
|
|
2586
|
+
}
|
|
2587
|
+
if (['start', 'plan', 'verify', 'autocheck', 'moderate', 'finish'].includes(e.type)) {
|
|
2588
|
+
const y = yOf(e.from) !== null ? yOf(e.from) : PAD_T;
|
|
2589
|
+
dots.push({ x, y: yOf(e.from), kind: e.type, ev: e, i: seq++ });
|
|
2590
|
+
}
|
|
2591
|
+
}
|
|
2592
|
+
evIndex = evs; // 供详情查询
|
|
2593
|
+
|
|
2594
|
+
// 5) 拼 SVG
|
|
2595
|
+
const p2 = (n) => String(n).padStart(2, '0');
|
|
2596
|
+
const fmtT = (t) => { const d = new Date(t); return `${p2(d.getHours())}:${p2(d.getMinutes())}:${p2(d.getSeconds())}`; };
|
|
2597
|
+
let s = `<svg class="flow-svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg">`;
|
|
2598
|
+
s += `<defs><marker id="fArrow" markerWidth="7" markerHeight="7" refX="6" refY="3.5" orient="auto"><path d="M0,0 L7,3.5 L0,7 Z" fill="#9aa8c7"/></marker>
|
|
2599
|
+
<marker id="fArrowH" markerWidth="7" markerHeight="7" refX="6" refY="3.5" orient="auto"><path d="M0,0 L7,3.5 L0,7 Z" fill="#4a6cf7"/></marker>
|
|
2600
|
+
<marker id="fArrowR" markerWidth="7" markerHeight="7" refX="6" refY="3.5" orient="auto"><path d="M0,0 L7,3.5 L0,7 Z" fill="#e8a33d"/></marker></defs>`;
|
|
2601
|
+
// 泳道
|
|
2602
|
+
lanes.forEach((name, i) => {
|
|
2603
|
+
const y = PAD_T + i * LANE_H + LANE_H / 2;
|
|
2604
|
+
const icon = name === '用户' ? '👤' : (agents.find(a => a.name === name) || {}).icon || '';
|
|
2605
|
+
s += `<line class="lane-line" x1="${PAD_L - 100}" y1="${y}" x2="${W - 20}" y2="${y}"/>`;
|
|
2606
|
+
s += `<text class="lane-label" x="${PAD_L - 104}" y="${y - 2}" text-anchor="end">${esc((icon ? icon + ' ' : '') + name)}</text>`;
|
|
2607
|
+
s += `<text class="lane-sub" x="${PAD_L - 104}" y="${y + 12}" text-anchor="end">智能体 ${i + 1}</text>`;
|
|
2608
|
+
});
|
|
2609
|
+
// 时间刻度(起/中/终)
|
|
2610
|
+
for (const frac of [0, 0.5, 1]) {
|
|
2611
|
+
const x = PAD_L + frac * (W - PAD_L - PAD_R);
|
|
2612
|
+
const t = new Date(t0 + frac * span);
|
|
2613
|
+
s += `<text class="lane-sub" x="${x}" y="${PAD_T - 10}" text-anchor="middle">${fmtT(t)}</text>`;
|
|
2614
|
+
}
|
|
2615
|
+
// 边(先画,块叠上面)
|
|
2616
|
+
for (const ed of edges) {
|
|
2617
|
+
const color = ed.kind === 'handoff' ? '#4a6cf7' : '#e8a33d';
|
|
2618
|
+
const marker = ed.kind === 'handoff' ? 'fArrowH' : 'fArrowR';
|
|
2619
|
+
const dx = Math.max(30, Math.abs(ed.x2 - ed.x1) / 2);
|
|
2620
|
+
const mx = ed.x1, my = (ed.y1 + ed.y2) / 2;
|
|
2621
|
+
s += `<path class="edge ${ed.kind}" data-ev="${evs.indexOf(ed.ev)}" d="M ${ed.x1},${ed.y1} C ${ed.x1 + dx},${ed.y1} ${ed.x2 - dx},${ed.y2} ${ed.x2},${ed.y2}" ${ed.y1 === ed.y2 ? '' : `marker-end="url(#${marker})"`}/>`;
|
|
2622
|
+
if (ed.ev.summary) {
|
|
2623
|
+
const txt = String(ed.ev.summary).replace(/\s+/g, ' ').slice(0, 22);
|
|
2624
|
+
s += `<text class="edge-label" x="${mx + 6}" y="${my - 4}" style="paint-order:stroke;stroke:#fbfcfe;stroke-width:3px" fill="${color}">${esc(txt)}</text>`;
|
|
2625
|
+
}
|
|
2626
|
+
}
|
|
2627
|
+
// 执行块
|
|
2628
|
+
for (const b of blocks) {
|
|
2629
|
+
const cls = 'ev-block' + (b.err ? ' err' : '') + (b.running ? ' running' : '');
|
|
2630
|
+
s += `<g data-ev="${evs.indexOf(b.ev)}"><rect class="${cls}" x="${b.x}" y="${b.y}" width="${Math.max(b.w, 18)}" height="${b.h}" rx="7"/>
|
|
2631
|
+
<text x="${b.x + Math.max(b.w,18)/2}" y="${b.y + 18}" text-anchor="middle" font-size="11" fill="${b.err ? '#c0392b' : '#0a6b3d'}">${b.running ? '执行中…' : (b.err ? '✗ 失败' : '✓ 完成')}</text>
|
|
2632
|
+
<title>${esc(fmtT(b.ev.t) + ' 派发给 ' + b.ev.to + ':' + String(b.ev.summary || ''))}</title></g>`;
|
|
2633
|
+
}
|
|
2634
|
+
// 节点圆点
|
|
2635
|
+
const DOT_STYLE = {
|
|
2636
|
+
start: { c: '#07c160', label: '▶' },
|
|
2637
|
+
plan: { c: '#8a6fe8', label: '📋' },
|
|
2638
|
+
verify: { c: '#e8a33d', label: '' },
|
|
2639
|
+
autocheck: { c: '#10aeff', label: '🔬' },
|
|
2640
|
+
moderate: { c: '#8a6fe8', label: '⚖' },
|
|
2641
|
+
finish: { c: '#7d5ac8', label: '🏁' }
|
|
2642
|
+
};
|
|
2643
|
+
for (const d of dots) {
|
|
2644
|
+
const st = DOT_STYLE[d.kind] || { c: '#999' };
|
|
2645
|
+
if (d.y === null) continue;
|
|
2646
|
+
const ok = d.kind === 'verify' ? /通过/.test(d.ev.summary || '') : null;
|
|
2647
|
+
const fill = d.kind === 'verify' ? (ok ? '#07c160' : '#e8a33d') : st.c;
|
|
2648
|
+
const text = d.kind === 'verify' ? (ok ? '✓' : '✗') : (st.label || '•');
|
|
2649
|
+
s += `<g class="ev-dot" data-ev="${evs.indexOf(d.ev)}"><circle cx="${d.x}" cy="${d.y - 26}" r="11" fill="${fill}" opacity="0.92"/><text x="${d.x}" y="${d.y - 22}" text-anchor="middle" font-size="11" fill="#fff">${text}</text><title>${esc((FLOW_TYPE_LABEL[d.kind] || d.kind) + ' ' + fmtT(d.ev.t) + ':' + String(d.ev.summary || ''))}</title></g>`;
|
|
2650
|
+
}
|
|
2651
|
+
s += '</svg>';
|
|
2652
|
+
const canvas = document.getElementById('flowCanvas');
|
|
2653
|
+
canvas.innerHTML = s;
|
|
2654
|
+
canvas.onclick = (evClick) => {
|
|
2655
|
+
const g = evClick.target.closest('[data-ev]');
|
|
2656
|
+
if (!g) return;
|
|
2657
|
+
showFlowDetail(Number(g.dataset.ev));
|
|
2658
|
+
};
|
|
2659
|
+
}
|
|
2660
|
+
|
|
2661
|
+
// 点击元素显示事件详情
|
|
2662
|
+
let evIndex = [];
|
|
2663
|
+
function rerunBtnHTML(stage, extra) {
|
|
2664
|
+
return `<button class="mini-btn" data-rerun-stage="${stage}" title="${escAttr(extra || '该阶段起重新执行,前序产出复用')}">↻ 从阶段 ${stage} 重跑</button>`;
|
|
2665
|
+
}
|
|
2666
|
+
function showFlowDetail(i) {
|
|
2667
|
+
const e = evIndex[i];
|
|
2668
|
+
if (!e) return;
|
|
2669
|
+
const el = document.getElementById('flowDetail');
|
|
2670
|
+
const p2 = (n) => String(n).padStart(2, '0');
|
|
2671
|
+
const d = new Date(e.t);
|
|
2672
|
+
const tm = `${d.getFullYear()}-${p2(d.getMonth()+1)}-${p2(d.getDate())} ${p2(d.getHours())}:${p2(d.getMinutes())}:${p2(d.getSeconds())}`;
|
|
2673
|
+
let html = `<div class="fd-title">${FLOW_TYPE_LABEL[e.type] || e.type} · ${tm}${e.from ? ` · ${esc(e.from)}` : ''}${e.to ? ` → ${esc(e.to)}` : ''}${e.stage ? ` · 阶段 ${e.stage}` : ''}${e.round ? ` · 第 ${e.round} 轮` : ''}<span class="fd-close" title="收起详情" onclick="this.closest('.flow-detail').style.display='none'">✕</span></div>`;
|
|
2674
|
+
if (e.summary) html += `<div>${esc(e.summary)}</div>`;
|
|
2675
|
+
if (e.files && e.files.length) html += `<div class="fd-files" style="margin-top:6px">📄 成果文件:<br>${e.files.map(f => esc(f)).join('<br>')}</div>`;
|
|
2676
|
+
if (e.detail && Object.keys(e.detail).length) {
|
|
2677
|
+
const dj = JSON.stringify(e.detail, null, 2);
|
|
2678
|
+
if (dj !== '{}') html += `<div style="margin-top:6px;color:#888">detail:${esc(dj.slice(0, 1500))}</div>`;
|
|
2679
|
+
}
|
|
2680
|
+
// 断点重跑入口:plan 事件(列各阶段)与带阶段号的执行事件
|
|
2681
|
+
let rerunStages = [];
|
|
2682
|
+
if (e.type === 'plan' && e.detail && Array.isArray(e.detail.phases) && e.detail.phases.length) {
|
|
2683
|
+
rerunStages = e.detail.phases.map((g, idx) => ({ stage: idx + 1, label: Array.isArray(g) ? g.map(s => s.agentName).join('、') : '' }));
|
|
2684
|
+
} else if ((e.type === 'dispatch' || e.type === 'done') && e.stage > 0) {
|
|
2685
|
+
rerunStages = [{ stage: e.stage, label: e.to || '' }];
|
|
2686
|
+
}
|
|
2687
|
+
if (rerunStages.length) {
|
|
2688
|
+
html += `<div style="margin-top:8px;padding-top:8px;border-top:1px dashed #ddd">` +
|
|
2689
|
+
rerunStages.map(s => `<div style="margin:4px 0">${s.label ? `<span style="font-size:11px;color:#888;margin-right:6px">${esc('阶段' + s.stage + ':' + s.label)}</span>` : ''}${rerunBtnHTML(s.stage)}</div>`).join('') +
|
|
2690
|
+
`</div>`;
|
|
2691
|
+
} else if (e.type === 'plan') {
|
|
2692
|
+
html += `<div style="margin-top:8px;color:#bbb;font-size:11px">该记录为旧版本格式(调度指令不完整),不支持断点重跑</div>`;
|
|
2693
|
+
}
|
|
2694
|
+
el.innerHTML = html;
|
|
2695
|
+
el.style.display = 'block';
|
|
2696
|
+
el.querySelectorAll('[data-rerun-stage]').forEach(btn => {
|
|
2697
|
+
btn.onclick = () => rerunFrom(flowRunId, Number(btn.dataset.rerunStage));
|
|
2698
|
+
});
|
|
2699
|
+
}
|
|
2700
|
+
|
|
2701
|
+
// ---------- 断点重跑:从流转视图一键回到任意阶段重新执行 ----------
|
|
2702
|
+
async function rerunFrom(run, stage) {
|
|
2703
|
+
if (chatBusy) { toast('当前有编排进行中,请先完成或停止'); return; }
|
|
2704
|
+
if (!confirm(`从第 ${stage} 阶段重跑?\n\n该阶段及其后的执行与验收将重新进行,之前阶段的产出作为背景复用。`)) return;
|
|
2705
|
+
const startEv = flowEvents.find(e => e.type === 'start');
|
|
2706
|
+
const tid = (startEv && startEv.detail && startEv.detail.taskId) || '';
|
|
2707
|
+
closeFlow();
|
|
2708
|
+
if (tid) switchSession(tid); // 重定向到原会话(主会话或对应任务会话),过程实时展示
|
|
2709
|
+
chatBusy = true;
|
|
2710
|
+
stopAsked = false;
|
|
2711
|
+
setSendBtn();
|
|
2712
|
+
let typing = addSys(`↻ 断点重跑中:从第 ${stage} 阶段开始…`, tid);
|
|
2713
|
+
try {
|
|
2714
|
+
await streamSSE('/api/flow/rerun', { run, fromStage: stage }, (e) => {
|
|
2715
|
+
if (typing) { removeMsg(typing); typing = null; }
|
|
2716
|
+
handleStreamEvent(e);
|
|
2717
|
+
});
|
|
2718
|
+
} catch (err) {
|
|
2719
|
+
addSys('重跑异常:' + (err && err.message || err), tid);
|
|
2720
|
+
} finally {
|
|
2721
|
+
if (typing) removeMsg(typing);
|
|
2722
|
+
chatBusy = false;
|
|
2723
|
+
stopAsked = false;
|
|
2724
|
+
setSendBtn();
|
|
2725
|
+
}
|
|
2726
|
+
}
|
|
2727
|
+
|
|
2728
|
+
// ---------- 历史 ----------
|
|
2729
|
+
async function loadMessages() {
|
|
2730
|
+
const data = await api('/api/messages');
|
|
2731
|
+
sessions.clear();
|
|
2732
|
+
for (const m of data.messages || []) {
|
|
2733
|
+
getSess(sessKey(m.taskId)).push({
|
|
2734
|
+
role: m.role, content: m.content, agentId: m.agentId, agentName: m.agentName,
|
|
2735
|
+
phase: m.phase, plan: m.plan, taskId: m.taskId, outputPath: m.outputPath, ts: m.timestamp
|
|
2736
|
+
});
|
|
2737
|
+
}
|
|
2738
|
+
renderMessages();
|
|
2739
|
+
}
|
|
2740
|
+
|
|
2741
|
+
// ---------- @ 智能体补全 ----------
|
|
2742
|
+
let atState = null; // { start, end, items, active }
|
|
2743
|
+
|
|
2744
|
+
function handleAtInput() {
|
|
2745
|
+
const pos = input.selectionStart;
|
|
2746
|
+
const before = input.value.slice(0, pos);
|
|
2747
|
+
const m = before.match(/@([^\s@]*)$/);
|
|
2748
|
+
if (!m) { hideAt(); return; }
|
|
2749
|
+
const q = m[1].toLowerCase();
|
|
2750
|
+
const items = agents.filter(a =>
|
|
2751
|
+
!q || a.name.toLowerCase().includes(q) || a.id.toLowerCase().includes(q) || String(a.desc || '').toLowerCase().includes(q)
|
|
2752
|
+
).slice(0, 8);
|
|
2753
|
+
if (!items.length) { hideAt(); return; }
|
|
2754
|
+
atState = { start: pos - m[0].length, end: pos, items, active: 0 };
|
|
2755
|
+
renderAt();
|
|
2756
|
+
}
|
|
2757
|
+
|
|
2758
|
+
function renderAt() {
|
|
2759
|
+
const menu = document.getElementById('atMenu');
|
|
2760
|
+
menu.innerHTML = atState.items.map((a, i) => `
|
|
2761
|
+
<div class="at-item ${i === atState.active ? 'active' : ''}" data-i="${i}">
|
|
2762
|
+
<span class="at-av" style="background:${a.id === butlerId ? '#8a6fe8' : colorOf(a.id)}">${esc(a.icon || agentIcon(a.id, a.name))}</span>
|
|
2763
|
+
<span class="at-tx"><b>${esc(a.name)}</b>${a.locked ? ' 🔒' : ''}<i>${esc(String(a.desc || a.id || '').slice(0, 40))}</i></span>
|
|
2764
|
+
</div>`).join('');
|
|
2765
|
+
menu.style.display = 'block';
|
|
2766
|
+
menu.querySelectorAll('.at-item').forEach(el => {
|
|
2767
|
+
el.onclick = () => pickAt(Number(el.dataset.i));
|
|
2768
|
+
el.onmousedown = (e) => e.preventDefault(); // 防止 input blur 先触发
|
|
2769
|
+
});
|
|
2770
|
+
}
|
|
2771
|
+
|
|
2772
|
+
function pickAt(i) {
|
|
2773
|
+
if (!atState) return;
|
|
2774
|
+
const a = atState.items[i];
|
|
2775
|
+
const v = input.value;
|
|
2776
|
+
input.value = v.slice(0, atState.start) + '@' + a.name + ' ' + v.slice(atState.end);
|
|
2777
|
+
const np = atState.start + a.name.length + 2;
|
|
2778
|
+
input.focus();
|
|
2779
|
+
input.setSelectionRange(np, np);
|
|
2780
|
+
hideAt();
|
|
2781
|
+
}
|
|
2782
|
+
|
|
2783
|
+
function hideAt() {
|
|
2784
|
+
atState = null;
|
|
2785
|
+
document.getElementById('atMenu').style.display = 'none';
|
|
2786
|
+
}
|
|
2787
|
+
|
|
2788
|
+
// ---------- 智能体配置 ----------
|
|
2789
|
+
let editing = [];
|
|
2790
|
+
|
|
2791
|
+
function openAgents() {
|
|
2792
|
+
const butler = agents.find(a => a.id === butlerId) || {};
|
|
2793
|
+
document.getElementById('butlerRo').innerHTML =
|
|
2794
|
+
`<b>${esc(butler.name || '管家')}</b><span class="lock">🔒 内置 · 不可修改</span><br>${esc(butler.desc || '')}`;
|
|
2795
|
+
editing = agents.filter(a => a.id !== butlerId).map(a => ({ ...a }));
|
|
2796
|
+
document.getElementById('globalCwdInput').value = globalCwd || '';
|
|
2797
|
+
document.getElementById('approvalSel').value = configApproval || 'off';
|
|
2798
|
+
renderKernelSel();
|
|
2799
|
+
renderAgentCards();
|
|
2800
|
+
document.getElementById('agentsMask').classList.add('show');
|
|
2801
|
+
}
|
|
2802
|
+
|
|
2803
|
+
function closeAgents() { document.getElementById('agentsMask').classList.remove('show'); }
|
|
2804
|
+
|
|
2805
|
+
// ---------- 团队仓库:经典团队预设,一键应用 ----------
|
|
2806
|
+
async function openTeams() {
|
|
2807
|
+
if (!teamPresets.length) {
|
|
2808
|
+
const data = await api('/api/teams');
|
|
2809
|
+
if (!data.success) { toast(data.error || '团队仓库加载失败'); return; }
|
|
2810
|
+
teamPresets = data.teams || [];
|
|
2811
|
+
}
|
|
2812
|
+
renderTeams();
|
|
2813
|
+
document.getElementById('teamsMask').classList.add('show');
|
|
2814
|
+
}
|
|
2815
|
+
function closeTeams() { document.getElementById('teamsMask').classList.remove('show'); }
|
|
2816
|
+
|
|
2817
|
+
// 管家记忆:查看 / 手动编辑(一行一条;两仓各有字数上限,保存时服务端负责容量策略)
|
|
2818
|
+
async function openMem() {
|
|
2819
|
+
document.getElementById('memMask').classList.add('show');
|
|
2820
|
+
try {
|
|
2821
|
+
const r = await api('/api/memory');
|
|
2822
|
+
if (!r.success) throw new Error(r.error || '加载失败');
|
|
2823
|
+
document.getElementById('memMemoryText').value = (r.memory || []).join('\n');
|
|
2824
|
+
document.getElementById('memUserText').value = (r.user || []).join('\n');
|
|
2825
|
+
renderMemUsage(r.usage, r.memory || [], r.user || []);
|
|
2826
|
+
} catch (e) {
|
|
2827
|
+
document.getElementById('memMemoryText').value = '';
|
|
2828
|
+
document.getElementById('memUserText').value = '';
|
|
2829
|
+
renderMemUsage(null, [], []);
|
|
2830
|
+
toast('记忆加载失败:' + e.message);
|
|
2831
|
+
}
|
|
2832
|
+
}
|
|
2833
|
+
function renderMemUsage(usage, memArr, userArr) {
|
|
2834
|
+
const line = (u, arr, name, limitDefault) => {
|
|
2835
|
+
const used = u ? u.used : arr.join('').length;
|
|
2836
|
+
const limit = u ? u.limit : limitDefault;
|
|
2837
|
+
const pct = Math.min(100, Math.round(used / limit * 100));
|
|
2838
|
+
return `${name}:${used} / ${limit} 字(${pct}%)`;
|
|
2839
|
+
};
|
|
2840
|
+
document.getElementById('memMemoryUsage').textContent = line(usage && usage.memory, memArr, '笔记', 2000);
|
|
2841
|
+
document.getElementById('memUserUsage').textContent = line(usage && usage.user, userArr, '画像', 1200);
|
|
2842
|
+
}
|
|
2843
|
+
async function saveMem() {
|
|
2844
|
+
const split = (id) => document.getElementById(id).value.split('\n').map(s => s.trim()).filter(Boolean);
|
|
2845
|
+
try {
|
|
2846
|
+
const r = await api('/api/memory', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ memory: split('memMemoryText'), user: split('memUserText') }) });
|
|
2847
|
+
if (!r.success) throw new Error(r.error || '保存失败');
|
|
2848
|
+
toast('管家记忆已保存');
|
|
2849
|
+
renderMemUsage(r.usage, split('memMemoryText'), split('memUserText'));
|
|
2850
|
+
} catch (e) { toast('保存失败:' + e.message); }
|
|
2851
|
+
}
|
|
2852
|
+
function closeMem() { document.getElementById('memMask').classList.remove('show'); }
|
|
2853
|
+
|
|
2854
|
+
function renderTeams() {
|
|
2855
|
+
const el = document.getElementById('teamsList');
|
|
2856
|
+
if (!teamPresets.length) {
|
|
2857
|
+
el.innerHTML = '<div class="task-empty" style="padding:16px">团队仓库为空</div>';
|
|
2858
|
+
return;
|
|
2859
|
+
}
|
|
2860
|
+
el.innerHTML = teamPresets.map((t, i) => `
|
|
2861
|
+
<div class="team-card">
|
|
2862
|
+
<div class="tc-head">${esc(t.icon || '')} <b>${esc(t.name)}</b> <span class="tc-src">源自 ${esc(t.source || '社区经典')}</span></div>
|
|
2863
|
+
<div class="tc-desc">${esc(t.desc || '')}</div>
|
|
2864
|
+
<div class="tc-members">${(t.agents || []).map(a => `<span class="tc-member">${esc(a.icon || '')} ${esc(a.name)}:<span style="color:#888">${esc(String(a.desc || '').split(':')[0])}</span></span>`).join('')}</div>
|
|
2865
|
+
${t.usage ? `<div class="tc-usage">试试:${esc(t.usage)}</div>` : ''}
|
|
2866
|
+
<div class="tc-actions">
|
|
2867
|
+
<button class="mini-btn" onclick="applyTeamPreset(${i}, 'append')">+ 追加到现有名单</button>
|
|
2868
|
+
<button class="mini-btn" style="background:#07c160;color:#fff" onclick="applyTeamPreset(${i}, 'replace')">替换当前名单</button>
|
|
2869
|
+
</div>
|
|
2870
|
+
</div>`).join('');
|
|
2871
|
+
}
|
|
2872
|
+
|
|
2873
|
+
// 应用团队:mode=replace 换掉全部子智能体;append 追加(重名自动加序号,避免 @ 点名歧义)
|
|
2874
|
+
function applyTeamPreset(i, mode) {
|
|
2875
|
+
const t = teamPresets[i];
|
|
2876
|
+
if (!t || !Array.isArray(t.agents) || !t.agents.length) { toast('该团队定义无效'); return; }
|
|
2877
|
+
const incoming = t.agents.map(a => ({
|
|
2878
|
+
id: '', behavior: 'echo',
|
|
2879
|
+
name: String(a.name || '').trim(),
|
|
2880
|
+
icon: String(a.icon || ''),
|
|
2881
|
+
desc: String(a.desc || ''),
|
|
2882
|
+
systemPrompt: String(a.systemPrompt || '')
|
|
2883
|
+
})).filter(a => a.name);
|
|
2884
|
+
if (!incoming.length) { toast('该团队没有有效成员'); return; }
|
|
2885
|
+
if (mode === 'replace') {
|
|
2886
|
+
editing = incoming;
|
|
2887
|
+
} else {
|
|
2888
|
+
const names = new Set(['管家', 'butler', ...editing.map(a => String(a.name || '').trim())]);
|
|
2889
|
+
for (const a of incoming) {
|
|
2890
|
+
let final = a.name, n = 2;
|
|
2891
|
+
while (names.has(final)) final = `${a.name}${n++}`;
|
|
2892
|
+
a.name = final;
|
|
2893
|
+
names.add(final);
|
|
2894
|
+
editing.push(a);
|
|
2895
|
+
}
|
|
2896
|
+
}
|
|
2897
|
+
renderAgentCards();
|
|
2898
|
+
closeTeams();
|
|
2899
|
+
toast(`已应用「${t.name}」(${mode === 'replace' ? '替换' : '追加'} ${incoming.length} 个智能体),点「保存」后生效`);
|
|
2900
|
+
}
|
|
2901
|
+
|
|
2902
|
+
function iconPicker(i) {
|
|
2903
|
+
const cur = editing[i].icon || '';
|
|
2904
|
+
return '<div class="icon-row">' + ICON_PRESETS.map(ic =>
|
|
2905
|
+
`<div class="icon-pick ${cur === ic ? 'active' : ''}" title="选择图标" onclick="pickIcon(${i}, '${ic}')">${ic}</div>`
|
|
2906
|
+
).join('') + '</div>';
|
|
2907
|
+
}
|
|
2908
|
+
|
|
2909
|
+
function pickIcon(i, ic) {
|
|
2910
|
+
editing[i].icon = editing[i].icon === ic ? '' : ic; // 再点一次取消选择
|
|
2911
|
+
renderAgentCards();
|
|
2912
|
+
}
|
|
2913
|
+
|
|
2914
|
+
function renderAgentCards() {
|
|
2915
|
+
const el = document.getElementById('agentCards');
|
|
2916
|
+
if (editing.length === 0) {
|
|
2917
|
+
el.innerHTML = '<div class="task-empty" style="padding:16px">暂无子智能体,点下方按钮添加</div>';
|
|
2918
|
+
return;
|
|
2919
|
+
}
|
|
2920
|
+
el.innerHTML = editing.map((a, i) => `
|
|
2921
|
+
<div class="ag-card">
|
|
2922
|
+
<div class="ag-row">
|
|
2923
|
+
<input value="${escAttr(a.name)}" placeholder="名称(用于 @ 点名)" oninput="editing[${i}].name=this.value">
|
|
2924
|
+
<button class="mini-btn" title="导出名片 JSON 文件,可分享给其他 Agents Chat 用户" onclick="exportAgentCard(${i})">名片</button>
|
|
2925
|
+
<button class="mini-btn gray" onclick="editing.splice(${i},1);renderAgentCards()">删除</button>
|
|
2926
|
+
</div>
|
|
2927
|
+
${iconPicker(i)}
|
|
2928
|
+
<input value="${escAttr(a.desc || '')}" placeholder="技能说明(管家据此判断调度给谁)" oninput="editing[${i}].desc=this.value">
|
|
2929
|
+
<textarea placeholder="自定义提示词(任务下发时附加在用户提示词前面,例如:你是资深工程师,输出必须可直接运行)" oninput="editing[${i}].systemPrompt=this.value">${esc(a.systemPrompt || '')}</textarea>
|
|
2930
|
+
</div>`).join('');
|
|
2931
|
+
}
|
|
2932
|
+
|
|
2933
|
+
// 全局工作目录选择:浏览器安全限制拿不到绝对路径,选完自动填入文件夹名并提示用户补全
|
|
2934
|
+
let dirPickTarget = null;
|
|
2935
|
+
const dirInput = document.createElement('input');
|
|
2936
|
+
dirInput.type = 'file';
|
|
2937
|
+
dirInput.webkitdirectory = true;
|
|
2938
|
+
dirInput.style.display = 'none';
|
|
2939
|
+
document.body.appendChild(dirInput);
|
|
2940
|
+
dirInput.addEventListener('change', () => {
|
|
2941
|
+
const f = dirInput.files && dirInput.files[0];
|
|
2942
|
+
if (f && dirPickTarget) {
|
|
2943
|
+
const top = String(f.webkitRelativePath || '').split('/')[0] || '';
|
|
2944
|
+
if (top) {
|
|
2945
|
+
dirPickTarget.value = top;
|
|
2946
|
+
toast('浏览器限制无法读取完整路径,已填入文件夹名「' + top + '」,请补全前面的完整地址');
|
|
2947
|
+
}
|
|
2948
|
+
}
|
|
2949
|
+
});
|
|
2950
|
+
function pickGlobalDir() { dirPickTarget = document.getElementById('globalCwdInput'); dirInput.value = ''; dirInput.click(); }
|
|
2951
|
+
|
|
2952
|
+
// 内核下拉框:列出全部支持内核,已安装可选(标 ✓),未安装禁选(标 未安装)
|
|
2953
|
+
function renderKernelSel() {
|
|
2954
|
+
const sel = document.getElementById('kernelSel');
|
|
2955
|
+
const kernels = runnerState.kernels || [];
|
|
2956
|
+
if (!kernels.length) { sel.innerHTML = '<option value="auto">自动(未获取到检测状态)</option>'; return; }
|
|
2957
|
+
sel.innerHTML = `<option value="auto">自动(按 OpenCode → Claude Code → Codex → pi 顺序选择已安装的)</option>` +
|
|
2958
|
+
kernels.map(k =>
|
|
2959
|
+
k.ok
|
|
2960
|
+
? `<option value="${k.id}" ${configKernel === k.id ? 'selected' : ''}>${esc(k.label)} ✓(已安装)</option>`
|
|
2961
|
+
: `<option value="${k.id}" disabled>${esc(k.label)}(未安装:npm install -g …)</option>`
|
|
2962
|
+
).join('');
|
|
2963
|
+
if (configKernel === 'auto') sel.value = 'auto';
|
|
2964
|
+
sel.onchange = () => {
|
|
2965
|
+
if (sel.value !== 'auto') toast('保存后生效:' + (kernels.find(k => k.id === sel.value) || {}).label);
|
|
2966
|
+
};
|
|
2967
|
+
}
|
|
2968
|
+
|
|
2969
|
+
function addAgentRow() {
|
|
2970
|
+
editing.push({ id: '', name: '', icon: '', desc: '', systemPrompt: '' });
|
|
2971
|
+
renderAgentCards();
|
|
2972
|
+
const cards = document.querySelectorAll('#agentCards .ag-card');
|
|
2973
|
+
const last = cards[cards.length - 1];
|
|
2974
|
+
if (last) last.querySelector('input').focus();
|
|
2975
|
+
}
|
|
2976
|
+
|
|
2977
|
+
// ---------- 智能体名片:导出/导入(借鉴 A2A Agent Card 的能力名片思想,本机文件版) ----------
|
|
2978
|
+
function agentCardJSON(a) {
|
|
2979
|
+
return JSON.stringify({
|
|
2980
|
+
type: 'agents-chat-card', version: 1, exportedAt: new Date().toISOString(),
|
|
2981
|
+
name: String(a.name || ''), icon: String(a.icon || ''),
|
|
2982
|
+
desc: String(a.desc || ''), systemPrompt: String(a.systemPrompt || '')
|
|
2983
|
+
}, null, 2);
|
|
2984
|
+
}
|
|
2985
|
+
|
|
2986
|
+
function exportAgentCard(i) {
|
|
2987
|
+
const a = editing[i];
|
|
2988
|
+
if (!a) return;
|
|
2989
|
+
if (!String(a.name || '').trim()) { toast('请先给这个智能体起个名字再导出'); return; }
|
|
2990
|
+
const safe = String(a.name).replace(/[\\/:*?"<>|\s]/g, '_').slice(0, 30);
|
|
2991
|
+
const blob = new Blob([agentCardJSON(a)], { type: 'application/json' });
|
|
2992
|
+
const el = document.createElement('a');
|
|
2993
|
+
el.href = URL.createObjectURL(blob);
|
|
2994
|
+
el.download = `agent-card-${safe}.json`;
|
|
2995
|
+
el.click();
|
|
2996
|
+
setTimeout(() => URL.revokeObjectURL(el.href), 5000);
|
|
2997
|
+
toast('名片已导出为 JSON 文件,可分享给其他用户导入');
|
|
2998
|
+
}
|
|
2999
|
+
|
|
3000
|
+
// 名片内容归一化:必须有名称;其余字段缺省补空(提示词为空时导入后仍可补写)
|
|
3001
|
+
function normalizeCard(obj) {
|
|
3002
|
+
if (!obj || typeof obj !== 'object') return null;
|
|
3003
|
+
const name = String(obj.name || '').trim();
|
|
3004
|
+
if (!name) return null;
|
|
3005
|
+
return {
|
|
3006
|
+
name: name.slice(0, 20),
|
|
3007
|
+
icon: String(obj.icon || '').trim().slice(0, 8),
|
|
3008
|
+
desc: String(obj.desc || '').slice(0, 100),
|
|
3009
|
+
systemPrompt: String(obj.systemPrompt || '').slice(0, 8000)
|
|
3010
|
+
};
|
|
3011
|
+
}
|
|
3012
|
+
|
|
3013
|
+
function openCardImport() {
|
|
3014
|
+
document.getElementById('cardText').value = '';
|
|
3015
|
+
document.getElementById('cardMask').classList.add('show');
|
|
3016
|
+
}
|
|
3017
|
+
function closeCardImport() { document.getElementById('cardMask').classList.remove('show'); }
|
|
3018
|
+
|
|
3019
|
+
let cardFileInput = null;
|
|
3020
|
+
function pickCardFile() {
|
|
3021
|
+
if (!cardFileInput) {
|
|
3022
|
+
cardFileInput = document.createElement('input');
|
|
3023
|
+
cardFileInput.type = 'file';
|
|
3024
|
+
cardFileInput.accept = '.json,.txt';
|
|
3025
|
+
cardFileInput.style.display = 'none';
|
|
3026
|
+
document.body.appendChild(cardFileInput);
|
|
3027
|
+
cardFileInput.addEventListener('change', () => {
|
|
3028
|
+
const f = cardFileInput.files && cardFileInput.files[0];
|
|
3029
|
+
if (!f) return;
|
|
3030
|
+
const r = new FileReader();
|
|
3031
|
+
r.onload = () => { document.getElementById('cardText').value = String(r.result || ''); };
|
|
3032
|
+
r.readAsText(f);
|
|
3033
|
+
});
|
|
3034
|
+
}
|
|
3035
|
+
cardFileInput.value = '';
|
|
3036
|
+
cardFileInput.click();
|
|
3037
|
+
}
|
|
3038
|
+
|
|
3039
|
+
function applyCardImport() {
|
|
3040
|
+
let obj;
|
|
3041
|
+
try { obj = JSON.parse(document.getElementById('cardText').value); } catch { toast('JSON 解析失败,请检查名片内容'); return; }
|
|
3042
|
+
const c = normalizeCard(obj);
|
|
3043
|
+
if (!c) { toast('不是有效的智能体名片(至少需要 name 字段)'); return; }
|
|
3044
|
+
editing.push({ id: '', behavior: 'echo', ...c });
|
|
3045
|
+
renderAgentCards();
|
|
3046
|
+
closeCardImport();
|
|
3047
|
+
toast(`已导入名片「${c.name}」,点「保存」后生效`);
|
|
3048
|
+
}
|
|
3049
|
+
// 一键清理超期历史数据(任务/消息/会话/产出文件/流转日志;管家记忆与配置不动)
|
|
3050
|
+
async function pruneHistory() {
|
|
3051
|
+
if (!confirm('将永久清理 15 天前的历史数据(已完结任务、过期消息与会话、产出存档、流转日志)。\n未完结任务、待触发定时任务、管家记忆与配置都会保留。确定清理?')) return;
|
|
3052
|
+
try {
|
|
3053
|
+
const r = await api('/api/maintenance/prune', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ days: 15 }) });
|
|
3054
|
+
if (r.success) {
|
|
3055
|
+
const s = r.stat || {};
|
|
3056
|
+
const parts = [
|
|
3057
|
+
s.tasks ? `${s.tasks} 个任务` : '',
|
|
3058
|
+
s.messages ? `${s.messages} 条消息` : '',
|
|
3059
|
+
s.ocSessions ? `${s.ocSessions} 个会话` : '',
|
|
3060
|
+
s.outputs ? `${s.outputs} 个产出目录` : '',
|
|
3061
|
+
s.flowEvents ? `${s.flowEvents} 条流转日志` : ''
|
|
3062
|
+
].filter(Boolean);
|
|
3063
|
+
toast(parts.length ? `已清理:${parts.join('、')}` : '没有需要清理的数据');
|
|
3064
|
+
await loadTasks().catch(() => {});
|
|
3065
|
+
renderSidebar();
|
|
3066
|
+
} else toast(r.error || '清理失败');
|
|
3067
|
+
} catch (e) { toast('清理失败:' + e.message); }
|
|
3068
|
+
}
|
|
3069
|
+
async function saveAgentsCfg() {
|
|
3070
|
+
const noName = editing.some(a => !String(a.name || '').trim());
|
|
3071
|
+
if (noName) { toast('每个智能体都需要名称'); return; }
|
|
3072
|
+
const data = await api('/api/agents', {
|
|
3073
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
3074
|
+
body: JSON.stringify({ agents: editing, globalCwd: document.getElementById('globalCwdInput').value, kernel: document.getElementById('kernelSel').value || 'auto', approval: document.getElementById('approvalSel').value || 'off' })
|
|
3075
|
+
});
|
|
3076
|
+
if (data.success) {
|
|
3077
|
+
agents = data.agents;
|
|
3078
|
+
globalCwd = data.globalCwd || '';
|
|
3079
|
+
configKernel = data.kernel || 'auto';
|
|
3080
|
+
configApproval = data.approval || 'off';
|
|
3081
|
+
renderMembers();
|
|
3082
|
+
renderSidebar();
|
|
3083
|
+
closeAgents();
|
|
3084
|
+
toast('配置已保存');
|
|
3085
|
+
(data.warnings || []).forEach(w => toast(w));
|
|
3086
|
+
} else toast(data.error || '保存失败');
|
|
3087
|
+
}
|
|
3088
|
+
|
|
3089
|
+
// ---------- 使用帮助 ----------
|
|
3090
|
+
const KERNEL_INSTALL = {
|
|
3091
|
+
opencode: 'npm install -g opencode-ai(登录:opencode auth login)',
|
|
3092
|
+
claude: 'npm install -g @anthropic-ai/claude-code(Anthropic 订阅或 API Key)',
|
|
3093
|
+
codex: 'npm install -g @openai/codex(ChatGPT 订阅或 OPENAI_API_KEY)',
|
|
3094
|
+
pi: 'npm install -g @earendil-works/pi-coding-agent(pi 内 /login 配置密钥)'
|
|
3095
|
+
};
|
|
3096
|
+
|
|
3097
|
+
function kernelBanner() {
|
|
3098
|
+
const kernels = runnerState.kernels || [];
|
|
3099
|
+
const avail = kernels.filter(k => k.ok).map(k => k.label).join('、') || '无';
|
|
3100
|
+
if (runnerState.kind === 'demo') {
|
|
3101
|
+
return `<div class="help-kernel demo">⚠ 当前为<b>演示模式</b>(AGENTS_CHAT_MOCK=1):所有输出均为模拟结果。删除 .env 中该配置并安装任一内核后即为真实执行</div>`;
|
|
3102
|
+
}
|
|
3103
|
+
if (runnerState.kind === 'missing') {
|
|
3104
|
+
const lines = Object.entries(KERNEL_INSTALL).map(([id, hint]) => ` ${kernels.find(k => k.id === id) && kernels.find(k => k.id === id).ok ? '✓' : '·'} ${hint}`);
|
|
3105
|
+
return `<div class="help-kernel missing">❌ <b>未检测到可用内核</b>,任务将无法真实执行。本机检测:${avail}。任选其一安装后重启本程序:<br><code>${lines.join('<br>')}</code></div>`;
|
|
3106
|
+
}
|
|
3107
|
+
return `<div class="help-kernel ok">✅ 运行内核:<b>${esc(runnerState.label)}</b> 真实执行${runnerState.cmd ? `(${esc(runnerState.cmd)})` : ''},智能体具备读写文件、执行命令、联网检索等真实能力。本机可用:${esc(avail || '仅当前内核')},可在「⚙ 智能体配置」中切换</div>`;
|
|
3108
|
+
}
|
|
3109
|
+
|
|
3110
|
+
function openCards() {
|
|
3111
|
+
// 多任务编排(卡牌)控制台:独立页面,新标签打开以免丢失当前聊天上下文
|
|
3112
|
+
window.open('/cards', '_blank');
|
|
3113
|
+
}
|
|
3114
|
+
|
|
3115
|
+
function openHelp() {
|
|
3116
|
+
document.getElementById('helpBody').innerHTML = kernelBanner() + `
|
|
3117
|
+
<div class="help-sec"><h4>🚀 快速开始</h4>
|
|
3118
|
+
<ol>
|
|
3119
|
+
<li>本机安装任一执行内核并登录模型(模型与密钥由内核自己管理,本程序不做任何 API 配置):<br>
|
|
3120
|
+
<code>${KERNEL_INSTALL.opencode}</code><br><code>${KERNEL_INSTALL.claude}</code><br><code>${KERNEL_INSTALL.codex}</code><br><code>${KERNEL_INSTALL.pi}</code></li>
|
|
3121
|
+
<li>启动本程序后浏览器打开 <code>http://localhost:3456</code>,会自动识别已安装的内核</li>
|
|
3122
|
+
<li>直接在输入框说话即可开始;粘贴任务清单可批量执行</li>
|
|
3123
|
+
</ol>
|
|
3124
|
+
</div>
|
|
3125
|
+
<div class="help-sec"><h4>🧠 执行内核</h4>
|
|
3126
|
+
<ul>
|
|
3127
|
+
<li>支持四种内核:<b>OpenCode</b>(多 Provider)、<b>Claude Code</b>(Anthropic)、<b>Codex</b>(OpenAI)、<b>pi</b>(多 Provider),安装即可被自动识别</li>
|
|
3128
|
+
<li>「⚙ 智能体配置」顶部的下拉框选择内核:「自动」按检测顺序取第一个已安装的,或指定某一内核</li>
|
|
3129
|
+
<li>切换内核后所有智能体(含管家)统一使用新内核执行;认证、模型、密钥都在各内核自己的 CLI 里配置</li>
|
|
3130
|
+
<li>各内核能力一致:读写文件、执行命令、联网检索,工作记录与产出统一保存在「产出存档目录」</li>
|
|
3131
|
+
</ul>
|
|
3132
|
+
</div>
|
|
3133
|
+
<div class="help-sec"><h4>💬 聊天与 @ 点名</h4>
|
|
3134
|
+
<ul>
|
|
3135
|
+
<li>直接说话 → 由「管家」理解需求、拆解任务、调度子智能体完成</li>
|
|
3136
|
+
<li><code>@工程师 你好</code> → 点名某个智能体;<code>@工程师 @研究员 做个调研</code> → 按顺序串行流水线(前者产出自动作为后者背景)</li>
|
|
3137
|
+
<li>输入 <code>@</code> 会有智能体补全菜单(方向键选择、回车确认)</li>
|
|
3138
|
+
<li>「✚ 新会话」开启全新上下文,旧记录可在「🕘 历史」查看</li>
|
|
3139
|
+
</ul>
|
|
3140
|
+
</div>
|
|
3141
|
+
<div class="help-sec"><h4>🎩 管家调度是怎么工作的</h4>
|
|
3142
|
+
<ol>
|
|
3143
|
+
<li><b>规划</b>:管家输出调度方案卡片(哪些阶段、谁并行谁串行)</li>
|
|
3144
|
+
<li><b>执行</b>:子智能体分阶段干活(阶段内并行、阶段间串行);过程内容默认折叠只显示三行,点「展开全文」可看全过程</li>
|
|
3145
|
+
<li><b>验收</b>:验收前系统先跑「自动核查」(产出文件存在性、JS 代码块语法、JSON 可解析、占位符残留、自定义验证命令),核查结果作为事实交给管家;管家核对成果,不合格的打回返工(带改进建议,默认最多 2 轮)</li>
|
|
3146
|
+
<li><b>汇总</b>:管家给出结果概要;有成果文件时会列出完整路径,可直接打开查看</li>
|
|
3147
|
+
</ol>
|
|
3148
|
+
<p>每个智能体执行时聊天流会显示「正在执行…」动画条;工具调用过程(读写文件、执行命令)也会实时展示。</p>
|
|
3149
|
+
</div>
|
|
3150
|
+
<div class="help-sec"><h4>📋 顺序任务 / ⏰ 定时任务(同一位置切换)</h4>
|
|
3151
|
+
<ul>
|
|
3152
|
+
<li>左侧任务栏顶部开关:「📋 顺序任务」与「⏰ 定时任务」两个标签页在同一位置切换显示</li>
|
|
3153
|
+
<li>顺序任务视图下,底部按钮为「按顺序执行全部待办」(执行中变红色「停止执行」)</li>
|
|
3154
|
+
<li>定时任务视图下,底部按钮变为「▶ 启动 / ⏸ 关闭定时任务」(总开关,关闭后到点不再自动执行)</li>
|
|
3155
|
+
<li><b>顺序任务</b>:「导入任务」→ 顺序任务标签,每行 <code>1.</code> <code>2.</code> 编号,行末可 <code>@智能体</code> 指派;任务按编号顺序执行,列表可拖拽调序,每个任务 = 独立会话</li>
|
|
3156
|
+
<li><b>定时任务</b>:「导入任务」→ 定时任务标签,行首写启动时间(<code>20260818-1307</code>),输入 <code>[]</code> 自动填入当前时间;到点自动执行(需程序保持运行)</li>
|
|
3157
|
+
<li>定时任务视图显示启动时间与倒计时,支持「立即执行」「重跑」「删除」;点击已触发的任务可查看其会话</li>
|
|
3158
|
+
<li><b>单聊任务链式语法</b>(单聊模式导入时生效):<code>2.-任务</code> <b>-</b> 前缀 = 接续上一任务的会话继续执行(同进程续聊,AI 记得前面的成果);<code>3.//任务</code> <b>//</b> 前缀 = 并行执行(连续多个 <b>//</b> 任务各自独立进程同时跑);无前缀 = 新会话独立执行。例:<code>1.写文档</code> → <code>2.-做成网页</code>(同一进程接续)→ <code>3.//查惠州天气</code> + <code>4.//查北京天气</code>(两个进程并行)</li>
|
|
3159
|
+
<li><b>完成提醒</b>:任务完成时右下角弹窗显示最终回答部分内容,点击跳回该会话;不处理 3 秒后自动消失,会话左侧留绿色小圆点标记未读(执行中的任务显示「执行中」运转标识;空闲且无未读则无标识)</li>
|
|
3160
|
+
</ul>
|
|
3161
|
+
</div>
|
|
3162
|
+
<div class="help-sec"><h4>🔭 流转视图(智能体协作可视化)</h4>
|
|
3163
|
+
<ul>
|
|
3164
|
+
<li>左侧底部「🔭 智能体流转」打开泳道时间线:每个智能体一条泳道,展示派发、执行、交接、返工全过程</li>
|
|
3165
|
+
<li>绿色块=执行完成,红色块=失败,虚线动画块=执行中;蓝色曲线=交接(产出交给下游),橙色曲线=返工</li>
|
|
3166
|
+
<li>点击执行块 / 连线 / 圆点查看详情:任务指令、交接说明、成果文件路径</li>
|
|
3167
|
+
<li>顶部下拉切换历史编排记录,勾选「自动刷新」可实时观察进行中的任务;弹窗较小看不全时点「⤢ 全屏」放大到整屏(也可横向滚动画布)</li>
|
|
3168
|
+
<li><b>断点重跑</b>:点击执行块或规划卡片详情中的「↻ 从阶段 N 重跑」,该阶段起重新执行与验收,之前阶段的产出自动作为背景复用(适合某环节翻车后不用整轮重来)</li>
|
|
3169
|
+
</ul>
|
|
3170
|
+
</div>
|
|
3171
|
+
<div class="help-sec"><h4>🤝 智能体之间的交接</h4>
|
|
3172
|
+
<ul>
|
|
3173
|
+
<li>多智能体协作时,上游智能体会留一份结构化交接文档:任务、进度、产出摘要、成果文件位置、给下游的建议</li>
|
|
3174
|
+
<li>下游智能体先读交接文档和成果文件全文再开工,避免仅凭摘要行事</li>
|
|
3175
|
+
<li>交接内容与全过程可在「🔭 流转」视图中追溯</li>
|
|
3176
|
+
</ul>
|
|
3177
|
+
</div>
|
|
3178
|
+
<div class="help-sec"><h4>💬 圆桌讨论</h4>
|
|
3179
|
+
<ul>
|
|
3180
|
+
<li>输入栏「💬 圆桌」按钮开启后发送 = 发起圆桌:被 <code>@</code> 的智能体上桌(不 <code>@</code> 则全体子智能体),管家当主持人</li>
|
|
3181
|
+
<li>成员轮流发言,能看到彼此观点并反驳;每轮结束主持人判定是否收敛,未收敛会给出下一轮聚焦问题引导讨论</li>
|
|
3182
|
+
<li>每轮每位智能体的发言都以独立气泡显示,默认折叠为约三行(点「展开全文」查看),只有管家的最终总结完整展示</li>
|
|
3183
|
+
<li>默认最多 2 轮(.env 中 <code>AGENTS_CHAT_ROUNDTABLE_ROUNDS</code> 可调)、每次发言限 600 字,防止讨论失控</li>
|
|
3184
|
+
<li>结束后管家输出总结:<b>共识 / 分歧(注明持方)/ 建议行动</b>;发言与总结都存档,流转视图可回溯(紫色 ⚖ 圆点为主持人判定)</li>
|
|
3185
|
+
</ul>
|
|
3186
|
+
</div>
|
|
3187
|
+
<div class="help-sec"><h4>👤 单聊 / 👥 群聊(左上角开关)</h4>
|
|
3188
|
+
<ul>
|
|
3189
|
+
<li>左上角开关一键切换:<b>群聊</b> = 多智能体协作主界面;<b>单聊</b> = 与一个 AI 一对一对话</li>
|
|
3190
|
+
<li>单聊直接输入消息即可,同一会话多轮续聊(上下文连续);回复以 Markdown 渲染,思考过程与工具调用实时展示(思考默认收起,点标题展开)</li>
|
|
3191
|
+
<li><b>默认模型</b>:顶部输入框点开下拉选择(列表来自 <code>opencode models</code>,点击时自动刷新),也可直接输入 <code>provider/model</code>;清空则用内核默认模型,选择会记忆</li>
|
|
3192
|
+
<li><b>单聊任务</b>:单聊模式下「导入任务」创建的任务由 AI 逐个执行(顺序/定时与群聊同款用法),与群聊任务互不干扰;群聊任务走管家编排</li>
|
|
3193
|
+
<li>非 OpenCode 内核(Claude Code / Codex / pi)下单聊退化为一次性对话(无续聊与模型列表),功能不受影响</li>
|
|
3194
|
+
</ul>
|
|
3195
|
+
</div>
|
|
3196
|
+
<div class="help-sec"><h4>🗂 批量管理任务</h4>
|
|
3197
|
+
<ul>
|
|
3198
|
+
<li>侧栏「批量管理」打开任务总览:全部顺序任务与定时任务(含单聊/群聊两类)分组展示</li>
|
|
3199
|
+
<li>勾选后可<b>批量删除</b>;顺序任务可<b>批量执行</b>(先群聊后单聊,依次完成)</li>
|
|
3200
|
+
</ul>
|
|
3201
|
+
</div>
|
|
3202
|
+
<div class="help-sec"><h4>📄 Markdown 预览</h4>
|
|
3203
|
+
<ul>
|
|
3204
|
+
<li>聊天内容与最终交付中出现的 <code>.md</code> 文件完整路径会显示为紫色链接,点击即在弹出页预览渲染后的 Markdown</li>
|
|
3205
|
+
<li>消息下方的「📄 过程存档」为 .md 时同样可点击预览;预览页右上角「✕ 关闭」或按 Esc 退出</li>
|
|
3206
|
+
<li>本机任意路径的文本文件均可预览(限文本扩展名,≤2MB)</li>
|
|
3207
|
+
</ul>
|
|
3208
|
+
</div>
|
|
3209
|
+
<div class="help-sec"><h4>🕘 历史与数据管理</h4>
|
|
3210
|
+
<ul>
|
|
3211
|
+
<li>「🕘 历史」弹窗底部:<b>⬇ 导出 sessions.md</b> 把全部会话(含任务会话与单聊会话)导出为一个 Markdown 文件;<b>🗑 清空全部会话</b> 一键删除消息、单聊会话、会话产出文件与流转日志(任务列表保留)</li>
|
|
3212
|
+
<li>全部页面关闭且无任务执行/审批等待时,后台服务空闲约 1 分钟自动退出(需要定时任务到点执行请保持页面打开;<code>.env AGENTS_CHAT_AUTOSTOP=0</code> 可关闭),需要时重新运行 start 即可</li>
|
|
3213
|
+
</ul>
|
|
3214
|
+
</div>
|
|
3215
|
+
<div class="help-sec"><h4>🛂 协作通道(看板 / 委派 / 审批)</h4>
|
|
3216
|
+
<ul>
|
|
3217
|
+
<li><b>共享看板</b>:每轮任务自动维护一份全体智能体共享的看板(进展 / 关键决定 / 风险提醒,存于会话产出目录 BOARD.md);开工前自动注入,智能体可用「【看板更新】」向全体协作者同步要点,并行协作也能看到彼此进展</li>
|
|
3218
|
+
<li><b>中途委派</b>:子智能体发现职责错配时可输出 <code>{"handoff":"目标智能体","reason":"原因"}</code> 转交任务(最多 2 次,防推诿循环);聊天调度与 @点名流水线均支持,转交过程在流转视图可追溯</li>
|
|
3219
|
+
<li><b>人工审批关卡</b>:配置页「🛂 人工审批关卡」可选方案后 / 交付前 / 全部暂停等「同意」;等待中点「停止」或超时 10 分钟视为否决;刷新后审批卡片自动恢复(<code>.env AGENTS_CHAT_APPROVAL</code> 可设默认值)</li>
|
|
3220
|
+
</ul>
|
|
3221
|
+
</div>
|
|
3222
|
+
<div class="help-sec"><h4>💾 管家记忆(跨会话长期记忆)</h4>
|
|
3223
|
+
<ul>
|
|
3224
|
+
<li>管家跨会话记住两类信息:<b>工作笔记</b>(项目事实、环境约定、教训)与<b>用户画像</b>(偏好、习惯),规划与验收、圆桌讨论时自动参考</li>
|
|
3225
|
+
<li>任务完成后由辅助小模型自动提炼沉淀(聊天区提示「💾 管家记忆已更新」);配置页可随时查看 / 手动编辑</li>
|
|
3226
|
+
<li>两仓各有字数上限(笔记 2000 字 / 画像 1200 字):写满自动让辅助小模型合并压缩,失败则淘汰最旧条目,绝不报错卡流程</li>
|
|
3227
|
+
<li>规划前还会按关键词检索历史消息,把「以前聊过的相关内容」作为背景注入(历史回忆)</li>
|
|
3228
|
+
<li>在 .env 配置 <code>AGENTS_CHAT_AUX_BASE_URL / MODEL / API_KEY</code>(任意 OpenAI 兼容接口,免费小模型即可)启用整理与压缩;不配置时记忆照常工作,仅跳过模型整理。<code>AGENTS_CHAT_MEMORY=0</code> 可整体关闭</li>
|
|
3229
|
+
</ul>
|
|
3230
|
+
</div>
|
|
3231
|
+
<div class="help-sec"><h4>📦 团队仓库</h4>
|
|
3232
|
+
<ul>
|
|
3233
|
+
<li>「⚙ 智能体配置」→「📦 团队仓库」:内置多套开源社区验证过的经典团队,一键应用</li>
|
|
3234
|
+
<li><b>软件开发团队</b>(源自 MetaGPT / ChatDev 虚拟软件公司):产品经理 → 架构师 → 工程师 → 测试工程师,从一句话需求到可运行软件</li>
|
|
3235
|
+
<li><b>内容创作团队</b>(源自 CrewAI 内容营销经典):研究员 → 作家 → 编辑,专业内容生产流水线</li>
|
|
3236
|
+
<li><b>深度调研团队</b>(源自 CrewAI Research Crew):研究员 → 分析师,输出摘要/洞察/建议的分析报告</li>
|
|
3237
|
+
<li><b>翻译校对团队</b>(源自 CrewAI 入门经典):翻译员 → 审校员,对准确性要求高的双人精翻</li>
|
|
3238
|
+
<li>应用方式:<b>替换</b>(换掉当前全部子智能体)或 <b>追加</b>(重名自动加序号);应用后仍可自由增删改,保存后生效</li>
|
|
3239
|
+
</ul>
|
|
3240
|
+
</div>
|
|
3241
|
+
<div class="help-sec"><h4>📇 智能体名片</h4>
|
|
3242
|
+
<ul>
|
|
3243
|
+
<li>「⚙ 智能体配置」里每个智能体可点「名片」导出为 JSON 文件(名称/图标/技能说明/提示词)</li>
|
|
3244
|
+
<li>「📥 导入名片」粘贴 JSON 或选择名片文件,即可把别人分享的智能体加入名单,点「保存」后生效</li>
|
|
3245
|
+
<li>名片只含角色定义,不含内核与密钥——接收方用自己的内核运行</li>
|
|
3246
|
+
</ul>
|
|
3247
|
+
</div>
|
|
3248
|
+
<div class="help-sec"><h4>🛑 停止与中断</h4>
|
|
3249
|
+
<ul>
|
|
3250
|
+
<li>聊天处理中「发送」按钮变红色「停止」;任务批次执行中底部按钮变「停止执行」</li>
|
|
3251
|
+
<li>被停止的任务回到「待执行」可重跑;任务执行期间仍可正常聊天</li>
|
|
3252
|
+
<li>程序异常退出后,「执行中」任务会在下次启动时自动复位,不会卡死</li>
|
|
3253
|
+
</ul>
|
|
3254
|
+
</div>
|
|
3255
|
+
<div class="help-sec"><h4>📄 产出文件与数据</h4>
|
|
3256
|
+
<ul>
|
|
3257
|
+
<li><b>成果文件</b>(用户最终要的东西):智能体在「产出存档目录」中真实创建的文件;管家最终交付时会列出每个成果文件的完整绝对路径</li>
|
|
3258
|
+
<li><b>过程存档</b>:每份正式输出完整保存在 <code>.data/outputs/<会话>/</code>,消息下方紫色的「📄 过程存档」路径指向它,供核验与内部交接,非最终成果</li>
|
|
3259
|
+
<li>全部数据在 <code>.data/</code> 目录(聊天、任务、配置);网页里可「清空」,删除 .data/ = 恢复出厂</li>
|
|
3260
|
+
<li>智能体读写文件的默认位置:配置页设置的「产出存档目录」(可选),或 <code>.data/workspace/</code></li>
|
|
3261
|
+
</ul>
|
|
3262
|
+
</div>
|
|
3263
|
+
<div class="help-sec"><h4>❓ 常见问题</h4>
|
|
3264
|
+
<ul>
|
|
3265
|
+
<li>换端口:编辑启动脚本里的 PORT;单任务超时:.env 中调大 <code>AGENTS_CHAT_TIMEOUT_MS</code>(默认 10 分钟)</li>
|
|
3266
|
+
<li>自动核查定制:.env 中 <code>AGENTS_CHAT_VERIFY_CMD="npm test"</code> 可让每轮验收前自动跑测试/构建(在产出存档目录执行);<code>AGENTS_CHAT_AUTOVERIFY=0</code> 关闭全部自动核查</li>
|
|
3267
|
+
<li>加/改智能体:「⚙ 智能体配置」可设名称、图标、技能说明、提示词;管家内置不可改;换内核也在该页顶部下拉框</li>
|
|
3268
|
+
<li>指定内核完整路径:.env 中设 <code>AGENTS_CHAT_OPENCODE_CMD</code> / <code>AGENTS_CHAT_CLAUDE_CMD</code> / <code>AGENTS_CHAT_CODEX_CMD</code> / <code>AGENTS_CHAT_PI_CMD</code></li>
|
|
3269
|
+
<li>页面行为异常:Ctrl+F5 强制刷新(版本不一致时会有提示)</li>
|
|
3270
|
+
</ul>
|
|
3271
|
+
</div>`;
|
|
3272
|
+
document.getElementById('helpMask').classList.add('show');
|
|
3273
|
+
}
|
|
3274
|
+
function closeHelp() { document.getElementById('helpMask').classList.remove('show'); }
|
|
3275
|
+
|
|
3276
|
+
init();
|
|
3277
|
+
</script>
|
|
3278
|
+
</body>
|
|
3279
|
+
</html>
|