@templmf/temp-solf-lmf 0.0.162 → 0.0.164

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.
@@ -1,683 +0,0 @@
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>opencode 多用户网关 · 演示</title>
7
- <script src="https://cdn.bootcdn.net/ajax/libs/vue/3.4.31/vue.global.prod.js"></script>
8
- <style>
9
- :root {
10
- --bg: #14161a;
11
- --panel: #1b1e24;
12
- --panel-2: #21252c;
13
- --border: #2a2e37;
14
- --text: #e6e8eb;
15
- --muted: #8b8f99;
16
- --accent: #e8a33d;
17
- --accent-dim: #a97a34;
18
- --danger: #d9695f;
19
- --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
20
- --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, system-ui, sans-serif;
21
- }
22
- * { box-sizing: border-box; }
23
- html, body { height: 100%; margin: 0; }
24
- body {
25
- background: var(--bg);
26
- color: var(--text);
27
- font-family: var(--sans);
28
- font-size: 14px;
29
- line-height: 1.5;
30
- }
31
- #app { height: 100vh; display: flex; flex-direction: column; }
32
-
33
- .topbar {
34
- display: flex; align-items: center; justify-content: space-between;
35
- padding: 10px 16px;
36
- border-bottom: 1px solid var(--border);
37
- background: var(--panel);
38
- flex-shrink: 0;
39
- }
40
- .topbar .brand { font-weight: 600; letter-spacing: 0.2px; }
41
- .topbar .brand span { color: var(--accent); }
42
- .identity {
43
- display: flex; align-items: center; gap: 8px;
44
- font-family: var(--mono); font-size: 12px; color: var(--muted);
45
- }
46
- .identity .dot {
47
- width: 7px; height: 7px; border-radius: 50%;
48
- background: #4ade80; box-shadow: 0 0 6px #4ade8080;
49
- }
50
- .identity .dot.off { background: #6b7280; box-shadow: none; }
51
- .identity code {
52
- background: var(--panel-2); border: 1px solid var(--border);
53
- padding: 2px 6px; border-radius: 4px; color: var(--text);
54
- }
55
-
56
- .layout { flex: 1; display: flex; min-height: 0; }
57
-
58
- .sidebar {
59
- width: 280px; flex-shrink: 0;
60
- border-right: 1px solid var(--border);
61
- background: var(--panel);
62
- display: flex; flex-direction: column;
63
- }
64
- .sidebar-head {
65
- padding: 14px 16px 10px; display: flex; align-items: center; justify-content: space-between;
66
- }
67
- .sidebar-head h2 { font-size: 12px; text-transform: none; letter-spacing: 0.3px; color: var(--muted); margin: 0; font-weight: 500; }
68
- .new-btn {
69
- background: var(--accent); color: #1a1305; border: none;
70
- padding: 5px 10px; border-radius: 6px; font-size: 12px; font-weight: 600;
71
- cursor: pointer;
72
- }
73
- .new-btn:hover { background: #f0ae4c; }
74
- .new-btn:disabled { opacity: 0.5; cursor: default; }
75
-
76
- .session-list { flex: 1; overflow-y: auto; padding: 4px 8px; }
77
- .session-item {
78
- padding: 10px 10px; border-radius: 8px; cursor: pointer;
79
- border: 1px solid transparent;
80
- margin-bottom: 2px;
81
- }
82
- .session-item:hover { background: var(--panel-2); }
83
- .session-item.active { background: var(--panel-2); border-color: var(--border); }
84
- .session-item .title { font-size: 13px; }
85
- .session-item .id {
86
- font-family: var(--mono); font-size: 11px; color: var(--muted); margin-top: 2px;
87
- }
88
- .empty-hint { padding: 24px 16px; color: var(--muted); font-size: 12.5px; }
89
-
90
- .chat { flex: 1; display: flex; flex-direction: column; min-width: 0; }
91
- .chat-empty {
92
- flex: 1; display: flex; align-items: center; justify-content: center;
93
- color: var(--muted); font-size: 13px;
94
- }
95
- .messages { flex: 1; overflow-y: auto; padding: 20px 24px; }
96
- .msg { max-width: 640px; margin-bottom: 16px; display: flex; }
97
- .msg.user { justify-content: flex-end; margin-left: auto; }
98
- .msg.assistant { flex-direction: column; }
99
-
100
- .bubble {
101
- padding: 10px 14px; border-radius: 10px; white-space: pre-wrap; word-break: break-word;
102
- }
103
- .msg.user .bubble { background: var(--accent-dim); color: #fff2de; border-bottom-right-radius: 2px; }
104
- .msg.assistant .bubble { background: var(--panel-2); border: 1px solid var(--border); border-bottom-left-radius: 2px; }
105
- .msg .role {
106
- font-family: var(--mono); font-size: 10.5px; color: var(--muted);
107
- margin-bottom: 4px;
108
- }
109
-
110
- /* 思考过程:默认折叠,用原生 <details>,不需要额外 JS 状态 */
111
- .reasoning {
112
- max-width: 640px; margin-bottom: 6px;
113
- border: 1px dashed var(--border); border-radius: 8px;
114
- background: #191c22;
115
- }
116
- .reasoning summary {
117
- cursor: pointer; list-style: none; padding: 7px 12px;
118
- font-size: 12px; color: var(--muted); font-family: var(--mono);
119
- display: flex; align-items: center; gap: 6px;
120
- }
121
- .reasoning summary::-webkit-details-marker { display: none; }
122
- .reasoning summary::before { content: "▸"; transition: transform 0.15s; }
123
- .reasoning[open] summary::before { transform: rotate(90deg); }
124
- .reasoning-text {
125
- padding: 0 12px 10px; white-space: pre-wrap; word-break: break-word;
126
- font-size: 12.5px; color: #a9adb6; border-top: 1px solid var(--border); padding-top: 8px;
127
- }
128
-
129
- .tool-line {
130
- max-width: 640px; margin-bottom: 6px; font-size: 12px; color: var(--muted);
131
- font-family: var(--mono); padding: 4px 12px; display: flex; align-items: center; gap: 6px;
132
- }
133
- .tool-line .status-running { color: var(--accent); }
134
- .tool-line .status-error { color: var(--danger); }
135
- .tool-line .status-completed { color: #6bbf87; }
136
-
137
- .cursor {
138
- display: inline-block; width: 7px; height: 14px; background: var(--accent);
139
- margin-left: 2px; vertical-align: text-bottom; animation: blink 1s step-start infinite;
140
- }
141
- @keyframes blink { 50% { opacity: 0; } }
142
-
143
- .status-pill {
144
- font-family: var(--mono); font-size: 10px; color: var(--accent);
145
- margin-left: 6px; letter-spacing: 0.2px;
146
- }
147
- .thinking-dots { display: inline-flex; align-items: center; gap: 5px; }
148
- .thinking-dot {
149
- width: 5px; height: 5px; border-radius: 50%; background: var(--muted);
150
- animation: pulse 1.2s ease-in-out infinite;
151
- }
152
- .thinking-dot:nth-child(2) { animation-delay: 0.15s; }
153
- .thinking-dot:nth-child(3) { animation-delay: 0.3s; }
154
- @keyframes pulse { 0%, 60%, 100% { opacity: 0.25; transform: scale(0.85); } 30% { opacity: 1; transform: scale(1); } }
155
-
156
- /* "正在处理" 占位气泡的淡入淡出,避免切换到真实内容时的突兀感 */
157
- .thinking-fade-enter-active { transition: opacity 0.25s ease; }
158
- .thinking-fade-leave-active { transition: opacity 0.45s ease; }
159
- .thinking-fade-enter-from,
160
- .thinking-fade-leave-to { opacity: 0; }
161
-
162
- .composer {
163
- border-top: 1px solid var(--border);
164
- padding: 14px 24px 18px;
165
- display: flex; flex-direction: column; gap: 10px; flex-shrink: 0;
166
- background: var(--panel);
167
- }
168
- .composer-row { display: flex; gap: 10px; }
169
- .model-select {
170
- background: var(--panel-2); border: 1px solid var(--border); border-radius: 6px;
171
- color: var(--text); font-size: 12px; padding: 5px 8px; font-family: var(--mono);
172
- max-width: 320px;
173
- }
174
- .model-select:focus { outline: none; border-color: var(--accent-dim); }
175
- .composer textarea {
176
- flex: 1; resize: none; min-height: 42px; max-height: 160px;
177
- background: var(--panel-2); border: 1px solid var(--border); border-radius: 8px;
178
- color: var(--text); padding: 10px 12px; font-family: var(--sans); font-size: 13.5px;
179
- }
180
- .composer textarea:focus { outline: none; border-color: var(--accent-dim); }
181
- .send-btn {
182
- background: var(--accent); color: #1a1305; border: none;
183
- padding: 0 18px; border-radius: 8px; font-weight: 600; cursor: pointer;
184
- }
185
- .send-btn:disabled { opacity: 0.4; cursor: default; }
186
- .stop-btn {
187
- background: transparent; color: var(--danger); border: 1px solid var(--danger);
188
- padding: 0 16px; border-radius: 8px; font-weight: 600; cursor: pointer;
189
- }
190
- .stop-btn:hover { background: #d9695f1a; }
191
-
192
- .session-item { position: relative; }
193
- .session-item .close-btn {
194
- position: absolute; right: 8px; top: 8px;
195
- width: 18px; height: 18px; border-radius: 4px; border: none;
196
- background: transparent; color: var(--muted); font-size: 13px; line-height: 1;
197
- cursor: pointer; display: none; align-items: center; justify-content: center;
198
- }
199
- .session-item:hover .close-btn { display: flex; }
200
- .session-item .close-btn:hover { background: #d9695f33; color: var(--danger); }
201
- </style>
202
- </head>
203
- <body>
204
- <div id="app"></div>
205
-
206
- <script type="module">
207
- const { createApp, ref, onMounted, nextTick } = Vue;
208
-
209
- createApp({
210
- setup() {
211
- const me = ref(null);
212
- const sessions = ref([]);
213
- const activeId = ref(null);
214
- const messages = ref([]); // [{ id, role, done, parts: [{id, type, text}] }]
215
- const draft = ref("");
216
- const sending = ref(false);
217
- const creating = ref(false);
218
- const modelOptions = ref([]); // [{ providerID, modelID, label }]
219
- const selectedModelKey = ref(""); // "providerID::modelID"
220
- const eventsConnected = ref(false);
221
- const awaitingFirst = ref(false); // 已发送、但还没收到任何事件反馈时为 true
222
- const aborting = ref(false); // 用户主动点了"停止生成",此时的请求失败不算错误
223
-
224
- // partID -> type,用于在 message.part.delta(不带 type 字段)先于
225
- // message.part.updated 到达时也能归类到正确的类型
226
- const partTypeCache = {};
227
-
228
- // messageID -> role,用于区分某个事件是"用户消息自己的回显"还是"assistant 的真实活动",
229
- // 避免用户消息的确认事件把"正在处理"占位提示提前清掉
230
- const messageRoles = {};
231
-
232
- async function api(url, opts = {}) {
233
- const res = await fetch(url, {
234
- credentials: "include",
235
- headers: { "Content-Type": "application/json" },
236
- ...opts,
237
- });
238
- if (!res.ok) {
239
- const body = await res.json().catch(() => ({}));
240
- throw new Error(body.error || `请求失败 (${res.status})`);
241
- }
242
- return res.status === 204 ? null : res.json();
243
- }
244
-
245
- async function loadMe() {
246
- me.value = await api("/api/me");
247
- }
248
-
249
- async function loadModels() {
250
- const { providers, defaults } = await api("/api/providers");
251
- const options = [];
252
- for (const p of providers) {
253
- for (const modelID of Object.keys(p.models || {})) {
254
- options.push({
255
- providerID: p.id,
256
- modelID,
257
- label: `${p.name || p.id} / ${p.models[modelID]?.name || modelID}`,
258
- });
259
- }
260
- }
261
- modelOptions.value = options;
262
-
263
- const firstProviderId = Object.keys(defaults || {})[0];
264
- if (firstProviderId) {
265
- const key = `${firstProviderId}::${defaults[firstProviderId]}`;
266
- if (options.some((o) => `${o.providerID}::${o.modelID}` === key)) {
267
- selectedModelKey.value = key;
268
- return;
269
- }
270
- }
271
- if (options[0]) {
272
- selectedModelKey.value = `${options[0].providerID}::${options[0].modelID}`;
273
- }
274
- }
275
-
276
- async function loadSessions() {
277
- sessions.value = await api("/api/sessions");
278
- }
279
-
280
- async function createSession() {
281
- creating.value = true;
282
- try {
283
- const s = await api("/api/sessions", {
284
- method: "POST",
285
- body: JSON.stringify({ title: `会话 ${new Date().toLocaleTimeString()}` }),
286
- });
287
- sessions.value.unshift(s);
288
- await selectSession(s.id);
289
- } catch (e) {
290
- alert(e.message);
291
- } finally {
292
- creating.value = false;
293
- }
294
- }
295
-
296
- async function selectSession(id) {
297
- activeId.value = id;
298
- messages.value = [];
299
- awaitingFirst.value = false;
300
- try {
301
- const list = await api(`/api/sessions/${id}/messages`);
302
- messages.value = list.map((entry) => {
303
- messageRoles[entry.info.id] = entry.info.role;
304
- return {
305
- id: entry.info.id,
306
- role: entry.info.role,
307
- done: !!entry.info.time?.completed,
308
- parts: (entry.parts || []).map((p) => ({
309
- id: p.id,
310
- type: p.type,
311
- text: p.text || "",
312
- tool: p.tool,
313
- state: p.state ? { ...p.state } : undefined,
314
- })),
315
- };
316
- });
317
- scrollToBottom();
318
- } catch (e) {
319
- alert(e.message);
320
- }
321
- }
322
-
323
- function findOrCreateMessage(id, role) {
324
- let m = messages.value.find((x) => x.id === id);
325
- if (!m) {
326
- m = { id, role, done: false, parts: [] };
327
- messages.value.push(m);
328
- }
329
- return m;
330
- }
331
-
332
- function findOrCreatePart(m, partId, type) {
333
- let p = m.parts.find((x) => x.id === partId);
334
- if (!p) {
335
- p = { id: partId, type: type || "text", text: "" };
336
- m.parts.push(p);
337
- } else if (type) {
338
- p.type = type;
339
- }
340
- return p;
341
- }
342
-
343
- // 把 message.part.updated 里 tool 相关的字段也同步进来(工具名、执行状态)
344
- function applyToolFields(p, part) {
345
- if (part.tool) p.tool = part.tool;
346
- if (part.state) p.state = { ...part.state };
347
- }
348
-
349
- // ---- 只关心两种展示类型:text(正文)和 reasoning(思考过程) ----
350
- function mainText(m) {
351
- return m.parts.filter((p) => p.type === "text").map((p) => p.text).join("");
352
- }
353
- function reasoningText(m) {
354
- return m.parts.filter((p) => p.type === "reasoning").map((p) => p.text).join("");
355
- }
356
- function toolCalls(m) {
357
- return m.parts.filter((p) => p.type === "tool");
358
- }
359
-
360
- async function send() {
361
- const text = draft.value.trim();
362
- if (!text || !activeId.value || sending.value) return;
363
- const sessionAtSend = activeId.value;
364
- draft.value = "";
365
- messages.value.push({
366
- id: `local-user-${Date.now()}`,
367
- role: "user",
368
- done: true,
369
- parts: [{ id: "t", type: "text", text }],
370
- });
371
- scrollToBottom();
372
- sending.value = true;
373
- awaitingFirst.value = true; // 发出去了,但还没收到任何事件——显示"正在处理"
374
- try {
375
- const [providerID, modelID] = selectedModelKey.value.split("::");
376
- // 这个 await 会一直等到 opencode 完全生成结束才 resolve;
377
- // 期间的增量内容靠下面 connectEvents() 的 SSE 实时渲染,不依赖这里。
378
- await api(`/api/sessions/${activeId.value}/prompt`, {
379
- method: "POST",
380
- body: JSON.stringify({ text, providerID, modelID }),
381
- });
382
- // 兜底:SSE 是尽力而为的转发,个别事件可能丢失(网络抖动/重连间隙)。
383
- // 生成结束后主动拉一次权威数据,把内容补全,避免"看起来没说完"。
384
- if (activeId.value === sessionAtSend) await syncMessages(sessionAtSend);
385
- } catch (e) {
386
- if (aborting.value) {
387
- // 用户主动点了"停止生成"——请求被打断是预期行为,不当作错误展示,
388
- // 只需要把当前这条 assistant 消息标记为"已结束"即可。
389
- const last = [...messages.value].reverse().find((m) => m.role === "assistant");
390
- if (last) last.done = true;
391
- } else {
392
- messages.value.push({
393
- id: `local-error-${Date.now()}`,
394
- role: "assistant",
395
- done: true,
396
- parts: [{ id: "e", type: "text", text: `[出错] ${e.message}` }],
397
- });
398
- }
399
- scrollToBottom();
400
- } finally {
401
- sending.value = false;
402
- awaitingFirst.value = false;
403
- aborting.value = false;
404
- }
405
- }
406
-
407
- // 主动中止正在进行的生成
408
- async function abortGeneration() {
409
- if (!activeId.value || !sending.value) return;
410
- aborting.value = true;
411
- try {
412
- await api(`/api/sessions/${activeId.value}/abort`, { method: "POST" });
413
- } catch (e) {
414
- console.error("中止失败:", e.message);
415
- aborting.value = false; // 中止请求本身失败了,交回给正常的错误提示逻辑
416
- }
417
- }
418
-
419
- // 结束并删除一个会话
420
- async function endSession(id) {
421
- if (!confirm("确定要结束并删除这个会话吗?此操作不可撤销。")) return;
422
- try {
423
- await api(`/api/sessions/${id}`, { method: "DELETE" });
424
- sessions.value = sessions.value.filter((s) => s.id !== id);
425
- if (activeId.value === id) {
426
- activeId.value = null;
427
- messages.value = [];
428
- if (sessions.value[0]) await selectSession(sessions.value[0].id);
429
- }
430
- } catch (e) {
431
- alert(e.message);
432
- }
433
- }
434
-
435
- // 用服务端权威数据刷新当前会话的消息(合并进已有的响应式对象,减少画面跳动)
436
- async function syncMessages(sessionId) {
437
- try {
438
- const list = await api(`/api/sessions/${sessionId}/messages`);
439
- if (activeId.value !== sessionId) return; // 用户已经切走了,不覆盖
440
- // 兜底:如果 message.updated(role: user)事件没收到,本地乐观回显不会被
441
- // 正常清理掉,这里用服务端权威列表反查一次,防止残留导致重复显示。
442
- messages.value = messages.value.filter((m) => !m.id.startsWith("local-user-"));
443
- for (const entry of list) {
444
- messageRoles[entry.info.id] = entry.info.role;
445
- const m = findOrCreateMessage(entry.info.id, entry.info.role);
446
- m.done = !!entry.info.time?.completed;
447
- for (const part of entry.parts || []) {
448
- const p = findOrCreatePart(m, part.id, part.type);
449
- if (typeof part.text === "string") p.text = part.text;
450
- applyToolFields(p, part);
451
- }
452
- }
453
- scrollToBottom();
454
- } catch {
455
- // 兜底同步失败不影响已经通过 SSE 渲染出来的内容,静默即可
456
- }
457
- }
458
-
459
- function handleKeydown(e) {
460
- if (e.key === "Enter" && !e.shiftKey) {
461
- e.preventDefault();
462
- send();
463
- }
464
- }
465
-
466
- function scrollToBottom() {
467
- nextTick(() => {
468
- const el = document.querySelector(".messages");
469
- if (el) el.scrollTop = el.scrollHeight;
470
- });
471
- }
472
-
473
- // ---- SSE:把 opencode 的增量事件实时映射到 messages ----
474
- function handleEvent(evt) {
475
- switch (evt.type) {
476
- case "message.updated": {
477
- const info = evt.properties?.info;
478
- if (!info || info.sessionID !== activeId.value) return;
479
- messageRoles[info.id] = info.role;
480
-
481
- if (info.role === "user") {
482
- // 这是服务端对"你刚发的那条消息"的权威确认,真实 id 和我们发送时
483
- // 本地乐观回显用的临时 id 不一样——把临时那条清掉,避免重复显示。
484
- messages.value = messages.value.filter((m) => !m.id.startsWith("local-user-"));
485
- }
486
- // 注意:这里不清除 awaitingFirst。message.updated 只说明 opencode
487
- // 在内部创建/更新了一个消息对象(可能只是个还没有任何内容的空壳),
488
- // 不代表已经有可以展示的真实内容——真正有内容出现要看下面的
489
- // message.part.updated / message.part.delta。之前把清除逻辑放在这里,
490
- // 导致"消息壳子"一创建(几乎瞬间)"..."就被撤掉了,但实际文字可能
491
- // 还要再等几秒到几十秒才真正生成出来,中间出现一大段空白期。
492
-
493
- const m = findOrCreateMessage(info.id, info.role);
494
- m.done = !!info.time?.completed;
495
- break;
496
- }
497
- case "message.part.updated": {
498
- const part = evt.properties?.part;
499
- if (!part || part.sessionID !== activeId.value) return;
500
- if (messageRoles[part.messageID] !== "user") {
501
- awaitingFirst.value = false; // 哪怕只是工具调用开始,也算"有动静"了
502
- }
503
- partTypeCache[part.id] = part.type;
504
- const m = findOrCreateMessage(part.messageID, "assistant");
505
- const p = findOrCreatePart(m, part.id, part.type);
506
- if (typeof part.text === "string") p.text = part.text; // 全量快照,直接覆盖
507
- applyToolFields(p, part);
508
- scrollToBottom();
509
- break;
510
- }
511
- case "message.part.delta": {
512
- const props = evt.properties || {};
513
- if (props.sessionID !== activeId.value) return;
514
- if (messageRoles[props.messageID] !== "user") {
515
- awaitingFirst.value = false;
516
- }
517
- if (props.field !== "text") return; // 目前只处理文本类增量
518
- const m = findOrCreateMessage(props.messageID, "assistant");
519
- const p = findOrCreatePart(m, props.partID, partTypeCache[props.partID]);
520
- p.text += props.delta || ""; // 增量追加,做到打字机效果
521
- scrollToBottom();
522
- break;
523
- }
524
- case "message.removed": {
525
- const props = evt.properties || {};
526
- if (props.sessionID !== activeId.value) return;
527
- messages.value = messages.value.filter((m) => m.id !== props.messageID);
528
- break;
529
- }
530
- default:
531
- break; // 其余事件类型(session 状态等)这个演示先不处理
532
- }
533
- }
534
-
535
- function connectEvents() {
536
- const es = new EventSource("/api/events");
537
- es.onopen = () => (eventsConnected.value = true);
538
- es.onerror = () => (eventsConnected.value = false);
539
- let lastLogAt = performance.now();
540
- es.onmessage = (e) => {
541
- eventsConnected.value = true;
542
- const evt = JSON.parse(e.data);
543
- // 调试用:打印每条事件到达的时间点和与上一条的间隔。
544
- // 正常应该是「发送后很快陆续到达,条目间隔几十~几百毫秒」;
545
- // 如果发现一大串事件的时间戳几乎完全相同(间隔 <5ms),
546
- // 说明事件是被中间某一层攒批之后一次性冲下来的,不是真·实时。
547
- const now = performance.now();
548
- console.log(
549
- `[SSE ${now.toFixed(0)}ms +${(now - lastLogAt).toFixed(0)}ms] ${evt.type}`,
550
- evt.properties
551
- );
552
- lastLogAt = now;
553
- handleEvent(evt);
554
- };
555
- }
556
-
557
- onMounted(async () => {
558
- await loadMe();
559
- await loadModels();
560
- await loadSessions();
561
- if (sessions.value[0]) await selectSession(sessions.value[0].id);
562
- connectEvents();
563
- });
564
-
565
- return {
566
- me, sessions, activeId, messages, draft, sending, creating,
567
- modelOptions, selectedModelKey, eventsConnected, awaitingFirst,
568
- createSession, selectSession, send, handleKeydown,
569
- mainText, reasoningText, toolCalls,
570
- abortGeneration, endSession,
571
- };
572
- },
573
- template: `
574
- <div class="topbar">
575
- <div class="brand">opencode <span>网关</span> 演示</div>
576
- <div class="identity" v-if="me">
577
- <span class="dot" :class="{ off: !eventsConnected }"></span>
578
- 当前用户 <code>{{ me.userId.slice(0, 8) }}</code>
579
- · server <code>{{ me.opencodeServer }}</code>
580
- </div>
581
- </div>
582
-
583
- <div class="layout">
584
- <div class="sidebar">
585
- <div class="sidebar-head">
586
- <h2>我的会话</h2>
587
- <button class="new-btn" :disabled="creating" @click="createSession">
588
- {{ creating ? '创建中…' : '+ 新建' }}
589
- </button>
590
- </div>
591
- <div class="session-list">
592
- <div v-if="sessions.length === 0" class="empty-hint">
593
- 还没有会话,点击右上角新建一个。<br/>
594
- 换个浏览器 / 无痕窗口打开本页,会得到不同的用户身份,
595
- 看到的会话列表也完全不同。
596
- </div>
597
- <div
598
- v-for="s in sessions" :key="s.id"
599
- class="session-item" :class="{ active: s.id === activeId }"
600
- @click="selectSession(s.id)"
601
- >
602
- <div class="title">{{ s.title || '未命名会话' }}</div>
603
- <div class="id">{{ s.id }}</div>
604
- <button class="close-btn" title="结束并删除该会话" @click.stop="endSession(s.id)">✕</button>
605
- </div>
606
- </div>
607
- </div>
608
-
609
- <div class="chat">
610
- <div v-if="!activeId" class="chat-empty">选择或新建一个会话开始对话</div>
611
- <template v-else>
612
- <div class="messages">
613
- <template v-for="m in messages" :key="m.id">
614
- <!-- 思考过程:折叠展示,仅当模型返回了 reasoning part 时出现 -->
615
- <details class="reasoning" v-if="m.role === 'assistant' && reasoningText(m)">
616
- <summary>思考过程</summary>
617
- <div class="reasoning-text">{{ reasoningText(m) }}</div>
618
- </details>
619
-
620
- <!-- 工具调用:显示当前执行状态,随事件实时更新 -->
621
- <div class="tool-line" v-for="t in toolCalls(m)" :key="t.id">
622
- <template v-if="t.state?.status === 'running'">⚙️ <span class="status-running">正在执行 {{ t.tool || '工具' }}…</span></template>
623
- <template v-else-if="t.state?.status === 'completed'">✅ <span class="status-completed">{{ t.tool || '工具' }} 执行完成</span></template>
624
- <template v-else-if="t.state?.status === 'error'">⚠️ <span class="status-error">{{ t.tool || '工具' }} 执行出错</span></template>
625
- <template v-else>⏳ 准备调用 {{ t.tool || '工具' }}</template>
626
- </div>
627
-
628
- <div class="msg" :class="m.role" v-if="mainText(m) || m.role === 'user'">
629
- <div>
630
- <div class="role">
631
- {{ m.role === 'user' ? '我' : 'opencode' }}
632
- <span class="status-pill" v-if="m.role === 'assistant' && !m.done">生成中</span>
633
- </div>
634
- <div class="bubble">{{ mainText(m) }}<span class="cursor" v-if="m.role === 'assistant' && !m.done && mainText(m)"></span></div>
635
- </div>
636
- </div>
637
- </template>
638
-
639
- <!-- 发出请求后、还没收到任何事件反馈之前的占位提示;用过渡做平滑淡出,
640
- 不是"首帧内容一出现就瞬间硬切掉" -->
641
- <transition name="thinking-fade">
642
- <div class="msg assistant" v-if="awaitingFirst">
643
- <div>
644
- <div class="role">opencode</div>
645
- <div class="bubble thinking-dots">
646
- <span class="thinking-dot"></span><span class="thinking-dot"></span><span class="thinking-dot"></span>
647
- </div>
648
- </div>
649
- </div>
650
- </transition>
651
- </div>
652
- <div class="composer">
653
- <div class="composer-row" v-if="modelOptions.length">
654
- <select class="model-select" v-model="selectedModelKey">
655
- <option
656
- v-for="o in modelOptions"
657
- :key="o.providerID + '::' + o.modelID"
658
- :value="o.providerID + '::' + o.modelID"
659
- >{{ o.label }}</option>
660
- </select>
661
- </div>
662
- <div class="composer-row">
663
- <textarea
664
- v-model="draft"
665
- placeholder="输入消息,Enter 发送,Shift+Enter 换行"
666
- @keydown="handleKeydown"
667
- ></textarea>
668
- <button class="send-btn" v-if="!sending" :disabled="!draft.trim()" @click="send">
669
- 发送
670
- </button>
671
- <button class="stop-btn" v-else @click="abortGeneration">
672
- ■ 停止生成
673
- </button>
674
- </div>
675
- </div>
676
- </template>
677
- </div>
678
- </div>
679
- `,
680
- }).mount("#app");
681
- </script>
682
- </body>
683
- </html>