dsh-gufa-code 0.0.0-stage → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +93 -2
- package/client.js +11548 -0
- package/cordis.patch.yml +8 -0
- package/docs/icon-180.png +0 -0
- package/docs/icon.svg +18 -0
- package/docs/market-submission.md +32 -0
- package/docs/screenshot-ide.jpg +0 -0
- package/host/assets.js +31 -0
- package/host/chat.js +523 -0
- package/host/context.js +438 -0
- package/host/dev.js +35 -0
- package/host/fs.js +306 -0
- package/host/git.js +532 -0
- package/host/project.js +305 -0
- package/host/ssh.js +54 -0
- package/host/terminal-pty.js +237 -0
- package/host/terminal.js +947 -0
- package/icon.svg +18 -0
- package/index.js +63 -0
- package/locale/en.json +121 -0
- package/locale/zh.json +121 -0
- package/package.json +67 -4
- package/screenshots.json +3 -0
- package/scripts/build-client.mjs +38 -0
- package/scripts/selftest-client.mjs +457 -0
- package/scripts/selftest-host.mjs +197 -0
- package/scripts/selftest-http.mjs +284 -0
- package/scripts/selftest-router.mjs +101 -0
- package/scripts/selftest-terminal-client.mjs +314 -0
- package/scripts/selftest-terminal-pty.mjs +190 -0
- package/scripts/selftest-terminal.mjs +351 -0
- package/scripts/test-chat-render.mjs +702 -0
- package/scripts/test-chat.mjs +761 -0
- package/scripts/test-editor.mjs +516 -0
- package/scripts/verify-workspace-sessions.mjs +135 -0
- package/src/client/00-core.js +1686 -0
- package/src/client/10-icons.js +205 -0
- package/src/client/20-editor.js +1318 -0
- package/src/client/30-terminal.js +1510 -0
- package/src/client/40-chat.js +1184 -0
- package/src/client/46-gitgraph.js +278 -0
- package/src/client/50-panels.js +916 -0
- package/src/client/55-backdrop.js +508 -0
- package/src/client/60-dock.js +662 -0
- package/src/client/85-shortcuts.js +516 -0
- package/src/client/90-app.js +2272 -0
- package/src/client/99-main.js +449 -0
- package/vendor/monaco/LICENSE +21 -0
- package/vendor/monaco/ThirdPartyNotices.txt +448 -0
- package/vendor/monaco/vs/base/browser/ui/codicons/codicon/codicon.ttf +0 -0
- package/vendor/monaco/vs/base/worker/workerMain.js +31 -0
- package/vendor/monaco/vs/basic-languages/abap/abap.js +10 -0
- package/vendor/monaco/vs/basic-languages/apex/apex.js +10 -0
- package/vendor/monaco/vs/basic-languages/azcli/azcli.js +10 -0
- package/vendor/monaco/vs/basic-languages/bat/bat.js +10 -0
- package/vendor/monaco/vs/basic-languages/bicep/bicep.js +11 -0
- package/vendor/monaco/vs/basic-languages/cameligo/cameligo.js +10 -0
- package/vendor/monaco/vs/basic-languages/clojure/clojure.js +10 -0
- package/vendor/monaco/vs/basic-languages/coffee/coffee.js +10 -0
- package/vendor/monaco/vs/basic-languages/cpp/cpp.js +10 -0
- package/vendor/monaco/vs/basic-languages/csharp/csharp.js +10 -0
- package/vendor/monaco/vs/basic-languages/csp/csp.js +10 -0
- package/vendor/monaco/vs/basic-languages/css/css.js +12 -0
- package/vendor/monaco/vs/basic-languages/cypher/cypher.js +10 -0
- package/vendor/monaco/vs/basic-languages/dart/dart.js +10 -0
- package/vendor/monaco/vs/basic-languages/dockerfile/dockerfile.js +10 -0
- package/vendor/monaco/vs/basic-languages/ecl/ecl.js +10 -0
- package/vendor/monaco/vs/basic-languages/elixir/elixir.js +10 -0
- package/vendor/monaco/vs/basic-languages/flow9/flow9.js +10 -0
- package/vendor/monaco/vs/basic-languages/freemarker2/freemarker2.js +12 -0
- package/vendor/monaco/vs/basic-languages/fsharp/fsharp.js +10 -0
- package/vendor/monaco/vs/basic-languages/go/go.js +10 -0
- package/vendor/monaco/vs/basic-languages/graphql/graphql.js +10 -0
- package/vendor/monaco/vs/basic-languages/handlebars/handlebars.js +10 -0
- package/vendor/monaco/vs/basic-languages/hcl/hcl.js +10 -0
- package/vendor/monaco/vs/basic-languages/html/html.js +10 -0
- package/vendor/monaco/vs/basic-languages/ini/ini.js +10 -0
- package/vendor/monaco/vs/basic-languages/java/java.js +10 -0
- package/vendor/monaco/vs/basic-languages/javascript/javascript.js +10 -0
- package/vendor/monaco/vs/basic-languages/julia/julia.js +10 -0
- package/vendor/monaco/vs/basic-languages/kotlin/kotlin.js +10 -0
- package/vendor/monaco/vs/basic-languages/less/less.js +11 -0
- package/vendor/monaco/vs/basic-languages/lexon/lexon.js +10 -0
- package/vendor/monaco/vs/basic-languages/liquid/liquid.js +10 -0
- package/vendor/monaco/vs/basic-languages/lua/lua.js +10 -0
- package/vendor/monaco/vs/basic-languages/m3/m3.js +10 -0
- package/vendor/monaco/vs/basic-languages/markdown/markdown.js +10 -0
- package/vendor/monaco/vs/basic-languages/mdx/mdx.js +10 -0
- package/vendor/monaco/vs/basic-languages/mips/mips.js +10 -0
- package/vendor/monaco/vs/basic-languages/msdax/msdax.js +10 -0
- package/vendor/monaco/vs/basic-languages/mysql/mysql.js +10 -0
- package/vendor/monaco/vs/basic-languages/objective-c/objective-c.js +10 -0
- package/vendor/monaco/vs/basic-languages/pascal/pascal.js +10 -0
- package/vendor/monaco/vs/basic-languages/pascaligo/pascaligo.js +10 -0
- package/vendor/monaco/vs/basic-languages/perl/perl.js +10 -0
- package/vendor/monaco/vs/basic-languages/pgsql/pgsql.js +10 -0
- package/vendor/monaco/vs/basic-languages/php/php.js +10 -0
- package/vendor/monaco/vs/basic-languages/pla/pla.js +10 -0
- package/vendor/monaco/vs/basic-languages/postiats/postiats.js +10 -0
- package/vendor/monaco/vs/basic-languages/powerquery/powerquery.js +10 -0
- package/vendor/monaco/vs/basic-languages/powershell/powershell.js +10 -0
- package/vendor/monaco/vs/basic-languages/protobuf/protobuf.js +11 -0
- package/vendor/monaco/vs/basic-languages/pug/pug.js +10 -0
- package/vendor/monaco/vs/basic-languages/python/python.js +10 -0
- package/vendor/monaco/vs/basic-languages/qsharp/qsharp.js +10 -0
- package/vendor/monaco/vs/basic-languages/r/r.js +10 -0
- package/vendor/monaco/vs/basic-languages/razor/razor.js +10 -0
- package/vendor/monaco/vs/basic-languages/redis/redis.js +10 -0
- package/vendor/monaco/vs/basic-languages/redshift/redshift.js +10 -0
- package/vendor/monaco/vs/basic-languages/restructuredtext/restructuredtext.js +10 -0
- package/vendor/monaco/vs/basic-languages/ruby/ruby.js +10 -0
- package/vendor/monaco/vs/basic-languages/rust/rust.js +10 -0
- package/vendor/monaco/vs/basic-languages/sb/sb.js +10 -0
- package/vendor/monaco/vs/basic-languages/scala/scala.js +10 -0
- package/vendor/monaco/vs/basic-languages/scheme/scheme.js +10 -0
- package/vendor/monaco/vs/basic-languages/scss/scss.js +12 -0
- package/vendor/monaco/vs/basic-languages/shell/shell.js +10 -0
- package/vendor/monaco/vs/basic-languages/solidity/solidity.js +10 -0
- package/vendor/monaco/vs/basic-languages/sophia/sophia.js +10 -0
- package/vendor/monaco/vs/basic-languages/sparql/sparql.js +10 -0
- package/vendor/monaco/vs/basic-languages/sql/sql.js +10 -0
- package/vendor/monaco/vs/basic-languages/st/st.js +10 -0
- package/vendor/monaco/vs/basic-languages/swift/swift.js +13 -0
- package/vendor/monaco/vs/basic-languages/systemverilog/systemverilog.js +10 -0
- package/vendor/monaco/vs/basic-languages/tcl/tcl.js +10 -0
- package/vendor/monaco/vs/basic-languages/twig/twig.js +10 -0
- package/vendor/monaco/vs/basic-languages/typescript/typescript.js +10 -0
- package/vendor/monaco/vs/basic-languages/typespec/typespec.js +10 -0
- package/vendor/monaco/vs/basic-languages/vb/vb.js +10 -0
- package/vendor/monaco/vs/basic-languages/wgsl/wgsl.js +307 -0
- package/vendor/monaco/vs/basic-languages/xml/xml.js +10 -0
- package/vendor/monaco/vs/basic-languages/yaml/yaml.js +10 -0
- package/vendor/monaco/vs/editor/editor.main.css +8 -0
- package/vendor/monaco/vs/editor/editor.main.js +798 -0
- package/vendor/monaco/vs/language/css/cssMode.js +13 -0
- package/vendor/monaco/vs/language/css/cssWorker.js +77 -0
- package/vendor/monaco/vs/language/html/htmlMode.js +13 -0
- package/vendor/monaco/vs/language/html/htmlWorker.js +454 -0
- package/vendor/monaco/vs/language/json/jsonMode.js +19 -0
- package/vendor/monaco/vs/language/json/jsonWorker.js +42 -0
- package/vendor/monaco/vs/language/typescript/tsMode.js +20 -0
- package/vendor/monaco/vs/language/typescript/tsWorker.js +51328 -0
- package/vendor/monaco/vs/loader.js +11 -0
- package/vendor/monaco/vs/nls.messages.de.js +21 -0
- package/vendor/monaco/vs/nls.messages.es.js +21 -0
- package/vendor/monaco/vs/nls.messages.fr.js +19 -0
- package/vendor/monaco/vs/nls.messages.it.js +19 -0
- package/vendor/monaco/vs/nls.messages.ja.js +21 -0
- package/vendor/monaco/vs/nls.messages.ko.js +19 -0
- package/vendor/monaco/vs/nls.messages.ru.js +21 -0
- package/vendor/monaco/vs/nls.messages.zh-cn.js +21 -0
- package/vendor/monaco/vs/nls.messages.zh-tw.js +19 -0
- package/vendor/xterm/LICENSE +21 -0
- package/vendor/xterm/LICENSE.addon-fit +19 -0
- package/vendor/xterm/addon-fit.js +2 -0
- package/vendor/xterm/xterm.css +285 -0
- package/vendor/xterm/xterm.js +2 -0
|
@@ -0,0 +1,1184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 40-chat:右侧「古法 AI」聊天面板。
|
|
3
|
+
*
|
|
4
|
+
* 结构:
|
|
5
|
+
* 1. 纯函数(可单测):localStorage 会话存取、markdown-lite 解析、请求体上下文拼装;
|
|
6
|
+
* 2. 小组件:markdown 文本、代码块(复制/插入)、消息气泡;
|
|
7
|
+
* 3. 主组件 G.ChatPanel(props):会话列表 + 流式对话 + 上下文 chips + 快捷提问。
|
|
8
|
+
*
|
|
9
|
+
* props: { project, activeFile, selection, theme, onInsertCode, width }
|
|
10
|
+
* project: { root, name } | null
|
|
11
|
+
* activeFile: { path, content, language } | null
|
|
12
|
+
* selection: { text, path, line, column } | null
|
|
13
|
+
* onInsertCode(code) 由 App 实现,通过 G.bus 'editor:insert' 插入编辑器
|
|
14
|
+
*
|
|
15
|
+
* 与 Host 的协议(host/chat.js):
|
|
16
|
+
* G.api('chat.info') → { provider, model, ready }
|
|
17
|
+
* G.stream('chat.stream', { messages: [{ role, content }], context }, onEvent)
|
|
18
|
+
* 事件:{type:'delta', text} / {type:'thinking'} / {type:'done'} / {type:'error', message}
|
|
19
|
+
*
|
|
20
|
+
* 本文件只依赖 G.*(不 import 任何 Harness Client 包),在 build-client.mjs 的
|
|
21
|
+
* factory 作用域内执行,因此可直接使用 h / hooks。
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/* ── 常量 ─────────────────────────────────────────────────────────────── */
|
|
25
|
+
|
|
26
|
+
const GUFA_CHAT_CONVERSATION_LIMIT = 30
|
|
27
|
+
const GUFA_CHAT_MESSAGE_LIMIT = 120
|
|
28
|
+
const GUFA_CHAT_MESSAGE_CHAR_LIMIT = 60_000
|
|
29
|
+
/** 客户端兜底看门狗:Host 120 秒超时后一定会发终止事件,超过这个时间还没收到就本地收尾。 */
|
|
30
|
+
const GUFA_CHAT_WATCHDOG_MS = 150_000
|
|
31
|
+
const GUFA_CHAT_STORAGE_PREFIX = 'gufa-code:chat:v1:'
|
|
32
|
+
|
|
33
|
+
/** 核心词典里没有的少量文案(插件以中文为主,缺失 key 时用这里的回退)。 */
|
|
34
|
+
const GUFA_CHAT_LABELS = {
|
|
35
|
+
notReady: '未就绪',
|
|
36
|
+
emptyReply: '(模型没有返回内容)',
|
|
37
|
+
stopped: '(已停止)',
|
|
38
|
+
ctrlHint: 'Enter 发送 · Shift+Enter 换行',
|
|
39
|
+
openFileHint: '打开文件后可附带上下文',
|
|
40
|
+
newConversation: '新对话',
|
|
41
|
+
clearHint: '清空当前对话',
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 聊天面板专用样式(注入一次,卸载时移除)。 */
|
|
45
|
+
const GUFA_CHAT_CSS = `
|
|
46
|
+
.gufa-chat-wrap{position:relative;display:flex;flex-direction:column;height:100%;min-height:0;overflow:hidden}
|
|
47
|
+
.gufa-chat-headwrap{position:relative;flex:0 0 auto;z-index:20}
|
|
48
|
+
.gufa-chat-head{gap:6px}
|
|
49
|
+
.gufa-chat-head .gufa-panel-head-title{flex:1 1 auto}
|
|
50
|
+
.gufa-chat-model{flex:0 1 auto;max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
|
|
51
|
+
font-size:9.5px;font-weight:500;letter-spacing:0;text-transform:none;color:var(--gufa-text-dim);
|
|
52
|
+
padding:2px 6px;border:1px solid var(--gufa-border);border-radius:6px;background:var(--gufa-layer2)}
|
|
53
|
+
.gufa-chat-model[data-warn]{color:var(--dsw-alias-state-warning-primary);border-color:color-mix(in srgb,var(--dsw-alias-state-warning-primary) 40%,transparent)}
|
|
54
|
+
.gufa-chat-history{position:absolute;top:34px;left:0;right:0;display:flex;flex-direction:column;
|
|
55
|
+
border-top:1px solid var(--gufa-border);border-bottom:1px solid var(--gufa-border-strong);
|
|
56
|
+
background:var(--dsw-alias-bg-overlay);box-shadow:0 14px 30px color-mix(in srgb,#000 30%,transparent);z-index:30}
|
|
57
|
+
.gufa-chat-history-head{display:flex;align-items:center;gap:6px;height:28px;padding:0 10px;font-size:10px;
|
|
58
|
+
letter-spacing:.08em;text-transform:uppercase;color:var(--gufa-text-dim);border-bottom:1px solid var(--gufa-border)}
|
|
59
|
+
.gufa-chat-history-list{display:flex;flex-direction:column;max-height:260px;overflow:auto;padding:4px}
|
|
60
|
+
.gufa-chat-history-empty{padding:14px 10px;font-size:11.5px;color:var(--gufa-text-dim);text-align:center}
|
|
61
|
+
.gufa-chat-history-item{display:flex;align-items:center;gap:4px;border-radius:6px;min-width:0}
|
|
62
|
+
.gufa-chat-history-item:hover{background:var(--gufa-hover)}
|
|
63
|
+
.gufa-chat-history-item[data-active]{background:var(--gufa-active)}
|
|
64
|
+
.gufa-chat-history-main{flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:1px;align-items:flex-start;
|
|
65
|
+
padding:5px 8px;border:0;background:transparent;color:var(--gufa-text);cursor:pointer;text-align:left}
|
|
66
|
+
.gufa-chat-history-title{max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px}
|
|
67
|
+
.gufa-chat-history-time{font-size:10px;color:var(--gufa-text-dim)}
|
|
68
|
+
.gufa-chat-history-del{width:24px;height:24px;margin-right:4px;flex:0 0 auto;display:none;align-items:center;justify-content:center;
|
|
69
|
+
border:0;border-radius:5px;background:transparent;color:var(--gufa-text-dim);cursor:pointer}
|
|
70
|
+
.gufa-chat-history-item:hover .gufa-chat-history-del{display:inline-flex}
|
|
71
|
+
.gufa-chat-history-del:hover{background:var(--gufa-hover);color:var(--dsw-alias-state-error-primary)}
|
|
72
|
+
.gufa-chat-history-foot{display:flex;gap:6px;padding:6px;border-top:1px solid var(--gufa-border)}
|
|
73
|
+
.gufa-chat-msgs{flex:1 1 auto;min-height:0;overflow:auto;padding:12px 10px 4px;overscroll-behavior:contain}
|
|
74
|
+
.gufa-chat-msg{display:flex;gap:8px;align-items:flex-start;margin-bottom:12px}
|
|
75
|
+
.gufa-chat-msg[data-role="user"]{justify-content:flex-end}
|
|
76
|
+
.gufa-chat-avatar{width:20px;height:20px;flex:0 0 auto;margin-top:2px;display:flex;align-items:center;justify-content:center;
|
|
77
|
+
border-radius:6px;color:var(--gufa-accent);background:color-mix(in srgb,var(--gufa-accent) 14%,transparent)}
|
|
78
|
+
.gufa-chat-bubble{min-width:0;max-width:100%;overflow:hidden;position:relative}
|
|
79
|
+
.gufa-chat-msg[data-role="user"] .gufa-chat-bubble{max-width:88%;padding:7px 10px;border-radius:10px 10px 3px 10px;
|
|
80
|
+
background:color-mix(in srgb,var(--gufa-accent) 12%,transparent);
|
|
81
|
+
border:1px solid color-mix(in srgb,var(--gufa-accent) 30%,transparent)}
|
|
82
|
+
.gufa-chat-msg[data-role="assistant"] .gufa-chat-bubble{flex:1 1 auto;padding:8px 11px;border-radius:10px 10px 10px 3px;
|
|
83
|
+
background:var(--gufa-layer2);border:1px solid var(--gufa-border)}
|
|
84
|
+
.gufa-chat-usertext{font-size:12.5px;line-height:1.55;white-space:pre-wrap;word-break:break-word}
|
|
85
|
+
.gufa-chat-text{font-size:12.5px;line-height:1.62;word-break:break-word}
|
|
86
|
+
.gufa-chat-line{white-space:pre-wrap}
|
|
87
|
+
.gufa-chat-gap{height:5px}
|
|
88
|
+
.gufa-chat-text strong{font-weight:650}
|
|
89
|
+
.gufa-chat-text code,.gufa-chat-inline-code{font:11.5px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;padding:1px 4px;
|
|
90
|
+
border-radius:4px;background:var(--gufa-active);border:1px solid var(--gufa-border);word-break:break-word}
|
|
91
|
+
.gufa-chat-code{margin:8px 0;border:1px solid var(--gufa-border-strong);border-radius:8px;overflow:hidden;background:var(--gufa-bg)}
|
|
92
|
+
.gufa-chat-code-head{display:flex;align-items:center;gap:6px;height:26px;padding:0 5px 0 10px;
|
|
93
|
+
background:var(--gufa-layer2);border-bottom:1px solid var(--gufa-border)}
|
|
94
|
+
.gufa-chat-code-lang{font-size:10px;letter-spacing:.05em;color:var(--gufa-text-dim);text-transform:lowercase;
|
|
95
|
+
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:120px}
|
|
96
|
+
.gufa-chat-fill{flex:1 1 auto}
|
|
97
|
+
.gufa-chat-code-btn{display:inline-flex;align-items:center;gap:4px;height:20px;padding:0 7px;border:0;border-radius:5px;
|
|
98
|
+
background:transparent;color:var(--gufa-text-dim);font-size:10.5px;cursor:pointer;white-space:nowrap}
|
|
99
|
+
.gufa-chat-code-btn:hover{background:var(--gufa-hover);color:var(--gufa-text)}
|
|
100
|
+
.gufa-chat-code-body{margin:0;padding:9px 11px;overflow:auto;max-height:360px;white-space:pre;tab-size:2;
|
|
101
|
+
font:11.5px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--gufa-text)}
|
|
102
|
+
.gufa-chat-thinking{display:flex;align-items:center;gap:7px;padding:1px 0;font-size:11.5px;color:var(--gufa-text-dim)}
|
|
103
|
+
.gufa-chat-error{display:flex;align-items:flex-start;gap:6px;margin-top:6px;padding:6px 8px;border-radius:6px;font-size:11.5px;
|
|
104
|
+
color:var(--dsw-alias-state-error-primary);
|
|
105
|
+
border:1px solid color-mix(in srgb,var(--dsw-alias-state-error-primary) 34%,transparent);
|
|
106
|
+
background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 10%,transparent)}
|
|
107
|
+
.gufa-chat-caret{display:inline-block;width:7px;height:13px;margin-left:2px;vertical-align:text-bottom;
|
|
108
|
+
background:var(--gufa-accent);opacity:.78;animation:gufa-chat-blink 1s steps(2,start) infinite}
|
|
109
|
+
@keyframes gufa-chat-blink{to{visibility:hidden}}
|
|
110
|
+
.gufa-chat-composer{flex:0 0 auto;display:flex;flex-direction:column;gap:6px;padding:8px 10px 9px;
|
|
111
|
+
border-top:1px solid var(--gufa-border);background:var(--gufa-layer)}
|
|
112
|
+
.gufa-chat-context{display:flex;align-items:center;gap:6px;flex-wrap:wrap;min-height:20px}
|
|
113
|
+
.gufa-chat-context-label{font-size:9.5px;letter-spacing:.08em;text-transform:uppercase;color:var(--gufa-text-dim)}
|
|
114
|
+
.gufa-chat-chip{display:inline-flex;align-items:center;gap:5px;max-width:210px;height:21px;padding:0 8px;border-radius:11px;
|
|
115
|
+
border:1px dashed var(--gufa-border-strong);background:transparent;color:var(--gufa-text-dim);font-size:11px;cursor:pointer}
|
|
116
|
+
.gufa-chat-chip:hover{border-color:var(--gufa-accent);color:var(--gufa-text)}
|
|
117
|
+
.gufa-chat-chip[data-on]{border-style:solid;color:var(--gufa-text);
|
|
118
|
+
border-color:color-mix(in srgb,var(--gufa-accent) 52%,transparent);
|
|
119
|
+
background:color-mix(in srgb,var(--gufa-accent) 10%,transparent)}
|
|
120
|
+
.gufa-chat-chip-text{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
121
|
+
.gufa-chat-context-hint{font-size:10.5px;color:var(--gufa-text-dim);opacity:.85}
|
|
122
|
+
.gufa-chat-quick{display:flex;gap:5px;flex-wrap:wrap}
|
|
123
|
+
.gufa-chat-quick-btn{height:23px;padding:0 9px;border-radius:6px;border:1px solid var(--gufa-border);
|
|
124
|
+
background:var(--gufa-layer2);color:var(--gufa-text-dim);font-size:11px;cursor:pointer;white-space:nowrap}
|
|
125
|
+
.gufa-chat-quick-btn:hover:not(:disabled){border-color:var(--gufa-accent);color:var(--gufa-text)}
|
|
126
|
+
.gufa-chat-quick-btn:disabled{opacity:.45;cursor:default}
|
|
127
|
+
.gufa-chat-notice{display:flex;align-items:center;gap:6px;padding:6px 8px;border-radius:6px;font-size:11px;
|
|
128
|
+
color:var(--dsw-alias-state-error-primary);
|
|
129
|
+
border:1px solid color-mix(in srgb,var(--dsw-alias-state-error-primary) 40%,transparent);
|
|
130
|
+
background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 9%,transparent)}
|
|
131
|
+
.gufa-chat-notice-text{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
132
|
+
.gufa-chat-notice-close{width:20px;height:20px;flex:0 0 auto;display:inline-flex;align-items:center;justify-content:center;
|
|
133
|
+
border:0;border-radius:4px;background:transparent;color:inherit;cursor:pointer}
|
|
134
|
+
.gufa-chat-notice-close:hover{background:var(--gufa-hover)}
|
|
135
|
+
.gufa-chat-inputwrap{display:flex;align-items:flex-end;gap:8px}
|
|
136
|
+
.gufa-chat-input{flex:1 1 auto;min-width:0;min-height:34px;max-height:168px;resize:none;padding:8px 10px;border-radius:8px;
|
|
137
|
+
border:1px solid var(--gufa-border-strong);background:var(--gufa-bg);color:var(--gufa-text);
|
|
138
|
+
font:inherit;font-size:12.5px;line-height:1.5;outline:none}
|
|
139
|
+
.gufa-chat-input:focus{border-color:var(--gufa-accent)}
|
|
140
|
+
.gufa-chat-input::placeholder{color:var(--gufa-text-dim)}
|
|
141
|
+
.gufa-chat-send{display:inline-flex;align-items:center;gap:6px;height:34px;padding:0 12px;border-radius:8px;flex:0 0 auto;
|
|
142
|
+
border:1px solid var(--gufa-accent);background:var(--gufa-accent);color:#fff;font-size:12px;cursor:pointer}
|
|
143
|
+
.gufa-chat-send:hover:not(:disabled){background:color-mix(in srgb,var(--gufa-accent) 86%,#000)}
|
|
144
|
+
.gufa-chat-send:disabled{opacity:.45;cursor:default}
|
|
145
|
+
.gufa-chat-send[data-stop]{background:transparent;color:var(--dsw-alias-state-error-primary);
|
|
146
|
+
border-color:color-mix(in srgb,var(--dsw-alias-state-error-primary) 60%,transparent)}
|
|
147
|
+
.gufa-chat-send[data-stop]:hover{background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 10%,transparent)}
|
|
148
|
+
.gufa-chat-foot{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:10px;color:var(--gufa-text-dim)}
|
|
149
|
+
.gufa-chat-foot-dim{opacity:.85;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
150
|
+
`
|
|
151
|
+
|
|
152
|
+
/* ── 纯函数:localStorage 会话存取 ───────────────────────────────────── */
|
|
153
|
+
|
|
154
|
+
/** 会话在 localStorage 里的 key:按项目根隔离(无项目时共用一个空项目桶)。 */
|
|
155
|
+
function gufaChatStorageKey(root) {
|
|
156
|
+
const normalized = typeof root === 'string' && root !== '' ? root : ''
|
|
157
|
+
return `${GUFA_CHAT_STORAGE_PREFIX}${normalized}`
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
let GUFA_CHAT_ID_SEQUENCE = 0
|
|
161
|
+
/** 会话 / 消息本地 id。 */
|
|
162
|
+
function gufaChatNewId() {
|
|
163
|
+
GUFA_CHAT_ID_SEQUENCE += 1
|
|
164
|
+
return `c${Date.now().toString(36)}${GUFA_CHAT_ID_SEQUENCE.toString(36)}${Math.random().toString(36).slice(2, 6)}`
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** 会话标题:取首条用户输入的前 28 个字符。 */
|
|
168
|
+
function gufaChatTitleOf(text) {
|
|
169
|
+
const normalized = String(text ?? '').replace(/\s+/g, ' ').trim()
|
|
170
|
+
if (normalized === '') return ''
|
|
171
|
+
return normalized.length > 28 ? `${normalized.slice(0, 27)}…` : normalized
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** 相对时间(用于历史下拉)。 */
|
|
175
|
+
function gufaChatRelativeTime(timestamp, now) {
|
|
176
|
+
const value = typeof timestamp === 'number' && Number.isFinite(timestamp) ? timestamp : 0
|
|
177
|
+
const current = typeof now === 'number' && Number.isFinite(now) ? now : Date.now()
|
|
178
|
+
const delta = Math.max(0, current - value)
|
|
179
|
+
if (value === 0) return ''
|
|
180
|
+
if (delta < 60_000) return '刚刚'
|
|
181
|
+
if (delta < 3_600_000) return `${Math.floor(delta / 60_000)} 分钟前`
|
|
182
|
+
if (delta < 86_400_000) return `${Math.floor(delta / 3_600_000)} 小时前`
|
|
183
|
+
const date = new Date(value)
|
|
184
|
+
const pad = (part) => String(part).padStart(2, '0')
|
|
185
|
+
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** 归一化并裁剪一个会话(消息条数、单条字符数、字段类型)。 */
|
|
189
|
+
function gufaChatTrimConversation(conversation) {
|
|
190
|
+
const source = conversation !== null && typeof conversation === 'object' ? conversation : {}
|
|
191
|
+
const rawMessages = Array.isArray(source.messages) ? source.messages : []
|
|
192
|
+
const messages = rawMessages.slice(-GUFA_CHAT_MESSAGE_LIMIT).map((item) => {
|
|
193
|
+
const message = item !== null && typeof item === 'object' ? item : {}
|
|
194
|
+
let content = typeof message.content === 'string' ? message.content : ''
|
|
195
|
+
if (content.length > GUFA_CHAT_MESSAGE_CHAR_LIMIT) content = `${content.slice(0, GUFA_CHAT_MESSAGE_CHAR_LIMIT)}\n…(内容过长,已截断)`
|
|
196
|
+
const trimmed = {
|
|
197
|
+
id: typeof message.id === 'string' && message.id !== '' ? message.id : gufaChatNewId(),
|
|
198
|
+
role: message.role === 'assistant' ? 'assistant' : 'user',
|
|
199
|
+
content,
|
|
200
|
+
at: typeof message.at === 'number' && Number.isFinite(message.at) ? message.at : Date.now(),
|
|
201
|
+
}
|
|
202
|
+
if (typeof message.error === 'string' && message.error !== '') trimmed.error = message.error
|
|
203
|
+
if (message.stopped === true) trimmed.stopped = true
|
|
204
|
+
return trimmed
|
|
205
|
+
})
|
|
206
|
+
return {
|
|
207
|
+
id: typeof source.id === 'string' && source.id !== '' ? source.id : gufaChatNewId(),
|
|
208
|
+
title: typeof source.title === 'string' ? source.title : '',
|
|
209
|
+
messages,
|
|
210
|
+
updatedAt: typeof source.updatedAt === 'number' && Number.isFinite(source.updatedAt) ? source.updatedAt : Date.now(),
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** 校验从 localStorage 读出的结构;返回 { conversations, activeId }。 */
|
|
215
|
+
function gufaChatSanitizeStore(raw) {
|
|
216
|
+
if (raw === null || typeof raw !== 'object') return { conversations: [], activeId: null }
|
|
217
|
+
const list = Array.isArray(raw.conversations) ? raw.conversations : []
|
|
218
|
+
const conversations = []
|
|
219
|
+
const seen = new Set()
|
|
220
|
+
for (const item of list) {
|
|
221
|
+
if (item === null || typeof item !== 'object') continue
|
|
222
|
+
const conversation = gufaChatTrimConversation(item)
|
|
223
|
+
if (seen.has(conversation.id)) continue
|
|
224
|
+
seen.add(conversation.id)
|
|
225
|
+
conversations.push(conversation)
|
|
226
|
+
if (conversations.length >= GUFA_CHAT_CONVERSATION_LIMIT) break
|
|
227
|
+
}
|
|
228
|
+
const requestedActive = typeof raw.activeId === 'string' ? raw.activeId : ''
|
|
229
|
+
const activeId = conversations.some((item) => item.id === requestedActive) ? requestedActive : (conversations[0]?.id ?? null)
|
|
230
|
+
return { conversations, activeId }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** 读取某个项目的会话库(任何异常都回落到空)。 */
|
|
234
|
+
function gufaChatLoadStore(root) {
|
|
235
|
+
try {
|
|
236
|
+
const raw = localStorage.getItem(gufaChatStorageKey(root))
|
|
237
|
+
if (raw === null) return { conversations: [], activeId: null }
|
|
238
|
+
return gufaChatSanitizeStore(JSON.parse(raw))
|
|
239
|
+
} catch {
|
|
240
|
+
return { conversations: [], activeId: null }
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** 写入会话库;配额不足时丢掉较旧的一半再试一次。返回是否成功。 */
|
|
245
|
+
function gufaChatSaveStore(root, conversations, activeId) {
|
|
246
|
+
const list = (Array.isArray(conversations) ? conversations : [])
|
|
247
|
+
.slice(0, GUFA_CHAT_CONVERSATION_LIMIT)
|
|
248
|
+
.map(gufaChatTrimConversation)
|
|
249
|
+
const payload = { version: 1, activeId: typeof activeId === 'string' ? activeId : null, conversations: list }
|
|
250
|
+
try {
|
|
251
|
+
localStorage.setItem(gufaChatStorageKey(root), JSON.stringify(payload))
|
|
252
|
+
return true
|
|
253
|
+
} catch {
|
|
254
|
+
const reduced = payload.conversations.slice(0, Math.max(1, Math.ceil(payload.conversations.length / 2)))
|
|
255
|
+
try {
|
|
256
|
+
localStorage.setItem(gufaChatStorageKey(root), JSON.stringify({ ...payload, conversations: reduced }))
|
|
257
|
+
return true
|
|
258
|
+
} catch {
|
|
259
|
+
return false
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/* ── 纯函数:markdown-lite 解析 ──────────────────────────────────────── */
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* 把 markdown-lite 文本拆成块:``` 围栏代码块 与 普通文本块。
|
|
268
|
+
* 流式输出时可能只有开头围栏,因此末尾未闭合的围栏也按代码块处理(open: true)。
|
|
269
|
+
* @param {string} source
|
|
270
|
+
* @returns {Array<{ type: 'text', text: string } | { type: 'code', lang: string, text: string, open?: boolean }>}
|
|
271
|
+
*/
|
|
272
|
+
function gufaChatParseMarkdown(source) {
|
|
273
|
+
const text = typeof source === 'string' ? source : String(source ?? '')
|
|
274
|
+
const blocks = []
|
|
275
|
+
if (text === '') return blocks
|
|
276
|
+
const fencePattern = /```([^\n`]*)\r?\n([\s\S]*?)```/g
|
|
277
|
+
let lastIndex = 0
|
|
278
|
+
let match
|
|
279
|
+
while ((match = fencePattern.exec(text)) !== null) {
|
|
280
|
+
if (match.index > lastIndex) blocks.push({ type: 'text', text: text.slice(lastIndex, match.index) })
|
|
281
|
+
blocks.push({ type: 'code', lang: match[1].trim(), text: match[2].replace(/\n$/, '') })
|
|
282
|
+
lastIndex = fencePattern.lastIndex
|
|
283
|
+
}
|
|
284
|
+
const rest = text.slice(lastIndex)
|
|
285
|
+
if (rest === '') return blocks
|
|
286
|
+
const openIndex = rest.indexOf('```')
|
|
287
|
+
if (openIndex < 0) {
|
|
288
|
+
blocks.push({ type: 'text', text: rest })
|
|
289
|
+
return blocks
|
|
290
|
+
}
|
|
291
|
+
if (openIndex > 0) blocks.push({ type: 'text', text: rest.slice(0, openIndex) })
|
|
292
|
+
const header = rest.slice(openIndex + 3)
|
|
293
|
+
const newline = header.indexOf('\n')
|
|
294
|
+
blocks.push({
|
|
295
|
+
type: 'code',
|
|
296
|
+
lang: (newline >= 0 ? header.slice(0, newline) : header).trim(),
|
|
297
|
+
text: newline >= 0 ? header.slice(newline + 1) : '',
|
|
298
|
+
open: true,
|
|
299
|
+
})
|
|
300
|
+
return blocks
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* 行内 markdown-lite:`code` 与 **粗体**。
|
|
305
|
+
* @param {string} text
|
|
306
|
+
* @returns {Array<{ type: 'text' | 'bold' | 'code', text: string }>}
|
|
307
|
+
*/
|
|
308
|
+
function gufaChatInline(text) {
|
|
309
|
+
const source = typeof text === 'string' ? text : String(text ?? '')
|
|
310
|
+
const tokens = []
|
|
311
|
+
const pattern = /\*\*([^*\n]+)\*\*|`([^`\n]+)`/g
|
|
312
|
+
let lastIndex = 0
|
|
313
|
+
let match
|
|
314
|
+
while ((match = pattern.exec(source)) !== null) {
|
|
315
|
+
if (match.index > lastIndex) tokens.push({ type: 'text', text: source.slice(lastIndex, match.index) })
|
|
316
|
+
if (match[1] !== undefined) tokens.push({ type: 'bold', text: match[1] })
|
|
317
|
+
else tokens.push({ type: 'code', text: match[2] })
|
|
318
|
+
lastIndex = pattern.lastIndex
|
|
319
|
+
}
|
|
320
|
+
if (lastIndex < source.length) tokens.push({ type: 'text', text: source.slice(lastIndex) })
|
|
321
|
+
return tokens
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/* ── 纯函数:请求体拼装 ──────────────────────────────────────────────── */
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* 根据上下文开关拼出发给 Host 的 context。
|
|
328
|
+
* 关掉文件但保留选中代码时,仍带上 selectionPath 让模型知道代码来源。
|
|
329
|
+
* @returns {null | { root?: string, path?: string, content?: string, language?: string, selection?: string, selectionPath?: string, selectionLine?: number }}
|
|
330
|
+
*/
|
|
331
|
+
function gufaChatBuildContext(options) {
|
|
332
|
+
const source = options !== null && typeof options === 'object' ? options : {}
|
|
333
|
+
const project = source.project !== null && typeof source.project === 'object' ? source.project : null
|
|
334
|
+
const activeFile = source.activeFile !== null && typeof source.activeFile === 'object' ? source.activeFile : null
|
|
335
|
+
const selection = source.selection !== null && typeof source.selection === 'object' ? source.selection : null
|
|
336
|
+
const useFile = source.attachFile !== false && activeFile !== null
|
|
337
|
+
const useSelection = source.attachSelection !== false && selection !== null && typeof selection.text === 'string' && selection.text !== ''
|
|
338
|
+
const context = {}
|
|
339
|
+
if (project !== null && typeof project.root === 'string' && project.root !== '') context.root = project.root
|
|
340
|
+
if (useSelection) {
|
|
341
|
+
context.selection = selection.text
|
|
342
|
+
if (typeof selection.path === 'string' && selection.path !== '') context.selectionPath = selection.path
|
|
343
|
+
if (typeof selection.line === 'number' && Number.isFinite(selection.line) && selection.line > 0) context.selectionLine = Math.trunc(selection.line)
|
|
344
|
+
}
|
|
345
|
+
if (useFile) {
|
|
346
|
+
if (typeof activeFile.path === 'string' && activeFile.path !== '') context.path = activeFile.path
|
|
347
|
+
if (typeof activeFile.content === 'string') context.content = activeFile.content
|
|
348
|
+
if (typeof activeFile.language === 'string' && activeFile.language !== '') context.language = activeFile.language
|
|
349
|
+
} else if (useSelection && context.path === undefined) {
|
|
350
|
+
context.path = selection.path
|
|
351
|
+
}
|
|
352
|
+
return Object.keys(context).length === 0 ? null : context
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* 把本地会话消息转换成请求用的 messages 数组。
|
|
357
|
+
* 出错/空内容的消息不发给模型,避免把错误提示当成对话历史。
|
|
358
|
+
* @param {Array<{ role: string, content?: string, error?: string }>} messages
|
|
359
|
+
* @returns {Array<{ role: 'user' | 'assistant', content: string }>}
|
|
360
|
+
*/
|
|
361
|
+
function gufaChatRequestMessages(messages) {
|
|
362
|
+
if (!Array.isArray(messages)) return []
|
|
363
|
+
const result = []
|
|
364
|
+
for (const message of messages) {
|
|
365
|
+
if (message === null || typeof message !== 'object') continue
|
|
366
|
+
if (message.role !== 'user' && message.role !== 'assistant') continue
|
|
367
|
+
if (typeof message.error === 'string' && message.error !== '') continue
|
|
368
|
+
const content = typeof message.content === 'string' ? message.content.trim() : ''
|
|
369
|
+
if (content === '') continue
|
|
370
|
+
result.push({ role: message.role, content })
|
|
371
|
+
}
|
|
372
|
+
return result
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/* ── 工具 ─────────────────────────────────────────────────────────────── */
|
|
376
|
+
|
|
377
|
+
/** 确保聊天面板样式已注入;返回清理函数(无样式时为空操作)。 */
|
|
378
|
+
function gufaChatEnsureStyles() {
|
|
379
|
+
const id = 'gufa-code/chat'
|
|
380
|
+
if (document.querySelector(`style[data-gufa="${id}"]`) !== null) return () => {}
|
|
381
|
+
const style = document.createElement('style')
|
|
382
|
+
style.dataset.gufa = id
|
|
383
|
+
style.textContent = GUFA_CHAT_CSS
|
|
384
|
+
document.head.append(style)
|
|
385
|
+
return () => style.remove()
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** 复制文本:优先 Clipboard API,失败时回落到临时 textarea。 */
|
|
389
|
+
function gufaChatCopyText(text) {
|
|
390
|
+
const value = typeof text === 'string' ? text : String(text ?? '')
|
|
391
|
+
if (value === '') return false
|
|
392
|
+
try {
|
|
393
|
+
if (typeof navigator !== 'undefined' && navigator.clipboard !== undefined && typeof navigator.clipboard.writeText === 'function') {
|
|
394
|
+
navigator.clipboard.writeText(value).catch(() => {})
|
|
395
|
+
return true
|
|
396
|
+
}
|
|
397
|
+
} catch {
|
|
398
|
+
/* 回落到 execCommand */
|
|
399
|
+
}
|
|
400
|
+
try {
|
|
401
|
+
const area = document.createElement('textarea')
|
|
402
|
+
area.value = value
|
|
403
|
+
area.setAttribute('readonly', '')
|
|
404
|
+
area.style.position = 'fixed'
|
|
405
|
+
area.style.top = '-9999px'
|
|
406
|
+
area.style.opacity = '0'
|
|
407
|
+
document.body.append(area)
|
|
408
|
+
area.select()
|
|
409
|
+
const ok = document.execCommand('copy')
|
|
410
|
+
area.remove()
|
|
411
|
+
return ok
|
|
412
|
+
} catch {
|
|
413
|
+
return false
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/* ── 小组件 ───────────────────────────────────────────────────────────── */
|
|
418
|
+
|
|
419
|
+
/** 行内 markdown-lite 渲染(粗体 / 行内代码 / 纯文本)。 */
|
|
420
|
+
function GufaChatInline(props) {
|
|
421
|
+
const tokens = gufaChatInline(props.text)
|
|
422
|
+
return h(
|
|
423
|
+
React.Fragment,
|
|
424
|
+
null,
|
|
425
|
+
tokens.map((token, index) => {
|
|
426
|
+
if (token.type === 'bold') return h('strong', { key: index }, token.text)
|
|
427
|
+
if (token.type === 'code') return h('code', { key: index, className: 'gufa-chat-inline-code' }, token.text)
|
|
428
|
+
return h(React.Fragment, { key: index }, token.text)
|
|
429
|
+
}),
|
|
430
|
+
)
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** 普通文本块:按行渲染,空行留出间距(列表等简化为纯文本行)。 */
|
|
434
|
+
function GufaChatMarkdownText(props) {
|
|
435
|
+
const lines = String(props.text ?? '').split('\n')
|
|
436
|
+
return h(
|
|
437
|
+
'div',
|
|
438
|
+
{ className: 'gufa-chat-text' },
|
|
439
|
+
lines.map((line, index) => (
|
|
440
|
+
line.trim() === ''
|
|
441
|
+
? h('div', { key: index, className: 'gufa-chat-gap' })
|
|
442
|
+
: h('div', { key: index, className: 'gufa-chat-line' }, h(GufaChatInline, { text: line }))
|
|
443
|
+
)),
|
|
444
|
+
)
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** 代码块:语言标签 + 复制 + 插入到编辑器。 */
|
|
448
|
+
function GufaChatCode(props) {
|
|
449
|
+
const t = G.useT()
|
|
450
|
+
const [copied, setCopied] = useState(false)
|
|
451
|
+
const timerRef = useRef(0)
|
|
452
|
+
useEffect(() => () => {
|
|
453
|
+
if (timerRef.current !== 0) window.clearTimeout(timerRef.current)
|
|
454
|
+
}, [])
|
|
455
|
+
const code = typeof props.code === 'string' ? props.code : ''
|
|
456
|
+
const canInsert = typeof props.onInsert === 'function'
|
|
457
|
+
const onCopy = useCallback(() => {
|
|
458
|
+
if (!gufaChatCopyText(code)) return
|
|
459
|
+
setCopied(true)
|
|
460
|
+
if (timerRef.current !== 0) window.clearTimeout(timerRef.current)
|
|
461
|
+
timerRef.current = window.setTimeout(() => {
|
|
462
|
+
timerRef.current = 0
|
|
463
|
+
setCopied(false)
|
|
464
|
+
}, 1500)
|
|
465
|
+
}, [code])
|
|
466
|
+
const onInsert = useCallback(() => {
|
|
467
|
+
if (typeof props.onInsert === 'function') props.onInsert(code)
|
|
468
|
+
}, [props.onInsert, code])
|
|
469
|
+
return h(
|
|
470
|
+
'div',
|
|
471
|
+
{ className: 'gufa-chat-code' },
|
|
472
|
+
h(
|
|
473
|
+
'div',
|
|
474
|
+
{ className: 'gufa-chat-code-head' },
|
|
475
|
+
h('span', { className: 'gufa-chat-code-lang' }, props.lang === undefined || props.lang === '' ? 'code' : props.lang),
|
|
476
|
+
h('span', { className: 'gufa-chat-fill' }),
|
|
477
|
+
h(
|
|
478
|
+
'button',
|
|
479
|
+
{ type: 'button', className: 'gufa-chat-code-btn', title: t('chat.copy'), onClick: onCopy },
|
|
480
|
+
h(G.Icon, { name: copied ? 'check' : 'files', size: 11 }),
|
|
481
|
+
copied ? t('chat.copied') : t('chat.copy'),
|
|
482
|
+
),
|
|
483
|
+
canInsert
|
|
484
|
+
? h(
|
|
485
|
+
'button',
|
|
486
|
+
{ type: 'button', className: 'gufa-chat-code-btn', title: t('chat.insert'), onClick: onInsert },
|
|
487
|
+
h(G.Icon, { name: 'code', size: 11 }),
|
|
488
|
+
t('chat.insert'),
|
|
489
|
+
)
|
|
490
|
+
: null,
|
|
491
|
+
),
|
|
492
|
+
h('pre', { className: 'gufa-chat-code-body gufa-scroll' }, h('code', null, code)),
|
|
493
|
+
)
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** AI 消息正文:markdown-lite 块渲染。 */
|
|
497
|
+
function GufaChatAssistantBody(props) {
|
|
498
|
+
const blocks = useMemo(() => gufaChatParseMarkdown(props.text), [props.text])
|
|
499
|
+
return h(
|
|
500
|
+
React.Fragment,
|
|
501
|
+
null,
|
|
502
|
+
blocks.map((block, index) => (
|
|
503
|
+
block.type === 'code'
|
|
504
|
+
? h(GufaChatCode, { key: index, lang: block.lang, code: block.text, onInsert: props.onInsert })
|
|
505
|
+
: h(GufaChatMarkdownText, { key: index, text: block.text })
|
|
506
|
+
)),
|
|
507
|
+
)
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** 单条消息气泡。 */
|
|
511
|
+
function GufaChatMessageView(props) {
|
|
512
|
+
const t = G.useT()
|
|
513
|
+
const message = props.message
|
|
514
|
+
const isUser = message.role === 'user'
|
|
515
|
+
const streaming = props.streaming === true
|
|
516
|
+
const thinking = props.thinking === true
|
|
517
|
+
return h(
|
|
518
|
+
'div',
|
|
519
|
+
{ className: 'gufa-chat-msg', 'data-role': isUser ? 'user' : 'assistant' },
|
|
520
|
+
isUser ? null : h('div', { className: 'gufa-chat-avatar' }, h(G.Icon, { name: 'sparkles', size: 12 })),
|
|
521
|
+
h(
|
|
522
|
+
'div',
|
|
523
|
+
{ className: 'gufa-chat-bubble' },
|
|
524
|
+
isUser
|
|
525
|
+
? h('div', { className: 'gufa-chat-usertext' }, message.content)
|
|
526
|
+
: h(GufaChatAssistantBody, { text: message.content, onInsert: props.onInsert }),
|
|
527
|
+
message.error !== undefined && message.error !== ''
|
|
528
|
+
? h(
|
|
529
|
+
'div',
|
|
530
|
+
{ className: 'gufa-chat-error' },
|
|
531
|
+
h(G.Icon, { name: 'warn', size: 12 }),
|
|
532
|
+
h('span', { style: { flex: '1 1 auto', minWidth: 0 } }, message.error),
|
|
533
|
+
)
|
|
534
|
+
: null,
|
|
535
|
+
thinking ? h('div', { className: 'gufa-chat-thinking' }, h(G.Spinner), t('chat.thinking')) : null,
|
|
536
|
+
streaming && !thinking && message.content !== '' ? h('span', { className: 'gufa-chat-caret' }) : null,
|
|
537
|
+
),
|
|
538
|
+
)
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/* ── 主组件:G.ChatPanel ─────────────────────────────────────────────── */
|
|
542
|
+
|
|
543
|
+
G.ChatPanel = function ChatPanel(props) {
|
|
544
|
+
const t = G.useT()
|
|
545
|
+
const project = props.project !== null && typeof props.project === 'object' ? props.project : null
|
|
546
|
+
const activeFile = props.activeFile !== null && typeof props.activeFile === 'object' ? props.activeFile : null
|
|
547
|
+
const selection = props.selection !== null && typeof props.selection === 'object' && typeof props.selection.text === 'string' && props.selection.text !== ''
|
|
548
|
+
? props.selection
|
|
549
|
+
: null
|
|
550
|
+
const root = project !== null && typeof project.root === 'string' ? project.root : ''
|
|
551
|
+
const insertCode = typeof props.onInsertCode === 'function' ? props.onInsertCode : null
|
|
552
|
+
|
|
553
|
+
const [conversations, setConversations] = useState([])
|
|
554
|
+
const [activeId, setActiveId] = useState(null)
|
|
555
|
+
const [input, setInput] = useState('')
|
|
556
|
+
const [streaming, setStreaming] = useState(false)
|
|
557
|
+
const [thinking, setThinking] = useState(false)
|
|
558
|
+
const [notice, setNotice] = useState('')
|
|
559
|
+
const [info, setInfo] = useState({ provider: '', model: '', ready: false })
|
|
560
|
+
const [infoError, setInfoError] = useState('')
|
|
561
|
+
const [historyOpen, setHistoryOpen] = useState(false)
|
|
562
|
+
const [attachFile, setAttachFile] = useState(true)
|
|
563
|
+
const [attachSelection, setAttachSelection] = useState(true)
|
|
564
|
+
|
|
565
|
+
const conversationsRef = useRef([])
|
|
566
|
+
const activeIdRef = useRef(null)
|
|
567
|
+
const rootRef = useRef(root)
|
|
568
|
+
const loadedRootRef = useRef(null)
|
|
569
|
+
const streamRef = useRef(null)
|
|
570
|
+
const watchdogRef = useRef(0)
|
|
571
|
+
const listRef = useRef(null)
|
|
572
|
+
const inputRef = useRef(null)
|
|
573
|
+
const headRef = useRef(null)
|
|
574
|
+
const stickRef = useRef(true)
|
|
575
|
+
const mountedRef = useRef(true)
|
|
576
|
+
|
|
577
|
+
/* ── 样式注入 ─────────────────────────────────────────────────────── */
|
|
578
|
+
useEffect(() => gufaChatEnsureStyles(), [])
|
|
579
|
+
|
|
580
|
+
/* ── 模型信息(chat.info) ────────────────────────────────────────── */
|
|
581
|
+
useEffect(() => {
|
|
582
|
+
let cancelled = false
|
|
583
|
+
void (async () => {
|
|
584
|
+
try {
|
|
585
|
+
const value = await G.api('chat.info')
|
|
586
|
+
if (cancelled) return
|
|
587
|
+
setInfo({
|
|
588
|
+
provider: value !== null && typeof value === 'object' && typeof value.provider === 'string' ? value.provider : '',
|
|
589
|
+
model: value !== null && typeof value === 'object' && typeof value.model === 'string' ? value.model : '',
|
|
590
|
+
ready: value !== null && typeof value === 'object' && value.ready === true,
|
|
591
|
+
})
|
|
592
|
+
setInfoError('')
|
|
593
|
+
} catch (error) {
|
|
594
|
+
if (!cancelled) setInfoError(G.errorText(error))
|
|
595
|
+
}
|
|
596
|
+
})()
|
|
597
|
+
return () => {
|
|
598
|
+
cancelled = true
|
|
599
|
+
}
|
|
600
|
+
}, [])
|
|
601
|
+
|
|
602
|
+
/* ── 会话库读写 ───────────────────────────────────────────────────── */
|
|
603
|
+
const persist = useCallback((nextConversations, nextActiveId) => {
|
|
604
|
+
const ok = gufaChatSaveStore(rootRef.current, nextConversations, nextActiveId)
|
|
605
|
+
if (!ok) setNotice('本地对话历史保存失败:浏览器存储空间不足。')
|
|
606
|
+
return ok
|
|
607
|
+
}, [])
|
|
608
|
+
|
|
609
|
+
/* 项目切换:换一个项目就换一份会话库(同一个项目跨刷新保留)。 */
|
|
610
|
+
useEffect(() => {
|
|
611
|
+
if (loadedRootRef.current === root) return
|
|
612
|
+
loadedRootRef.current = root
|
|
613
|
+
rootRef.current = root
|
|
614
|
+
const current = streamRef.current
|
|
615
|
+
if (current !== null) {
|
|
616
|
+
streamRef.current = null
|
|
617
|
+
try {
|
|
618
|
+
if (typeof current.abort === 'function') current.abort()
|
|
619
|
+
} catch {
|
|
620
|
+
/* ignore */
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
if (watchdogRef.current !== 0) {
|
|
624
|
+
window.clearTimeout(watchdogRef.current)
|
|
625
|
+
watchdogRef.current = 0
|
|
626
|
+
}
|
|
627
|
+
setStreaming(false)
|
|
628
|
+
setThinking(false)
|
|
629
|
+
const store = gufaChatLoadStore(root)
|
|
630
|
+
conversationsRef.current = store.conversations
|
|
631
|
+
activeIdRef.current = store.activeId
|
|
632
|
+
setConversations(store.conversations)
|
|
633
|
+
setActiveId(store.activeId)
|
|
634
|
+
setHistoryOpen(false)
|
|
635
|
+
setNotice('')
|
|
636
|
+
stickRef.current = true
|
|
637
|
+
}, [root])
|
|
638
|
+
|
|
639
|
+
/* 卸载:中止进行中的请求,并把已收到的部分内容落盘。 */
|
|
640
|
+
useEffect(() => () => {
|
|
641
|
+
mountedRef.current = false
|
|
642
|
+
if (watchdogRef.current !== 0) {
|
|
643
|
+
window.clearTimeout(watchdogRef.current)
|
|
644
|
+
watchdogRef.current = 0
|
|
645
|
+
}
|
|
646
|
+
const current = streamRef.current
|
|
647
|
+
streamRef.current = null
|
|
648
|
+
if (current !== null) {
|
|
649
|
+
try {
|
|
650
|
+
if (typeof current.abort === 'function') current.abort()
|
|
651
|
+
} catch {
|
|
652
|
+
/* ignore */
|
|
653
|
+
}
|
|
654
|
+
const finalized = conversationsRef.current.map((conversation) => {
|
|
655
|
+
if (conversation.id !== current.conversationId) return conversation
|
|
656
|
+
return {
|
|
657
|
+
...conversation,
|
|
658
|
+
messages: conversation.messages.map((message) => (
|
|
659
|
+
message.id === current.assistantId && typeof message.content === 'string' && message.content === ''
|
|
660
|
+
? { ...message, content: GUFA_CHAT_LABELS.stopped }
|
|
661
|
+
: message
|
|
662
|
+
)),
|
|
663
|
+
}
|
|
664
|
+
})
|
|
665
|
+
conversationsRef.current = finalized
|
|
666
|
+
gufaChatSaveStore(rootRef.current, finalized, activeIdRef.current)
|
|
667
|
+
}
|
|
668
|
+
}, [])
|
|
669
|
+
|
|
670
|
+
/* ── 状态提交 ─────────────────────────────────────────────────────── */
|
|
671
|
+
const commitConversations = useCallback((nextConversations, nextActiveId, persistNow) => {
|
|
672
|
+
conversationsRef.current = nextConversations
|
|
673
|
+
setConversations(nextConversations)
|
|
674
|
+
if (nextActiveId !== undefined) {
|
|
675
|
+
activeIdRef.current = nextActiveId
|
|
676
|
+
setActiveId(nextActiveId)
|
|
677
|
+
}
|
|
678
|
+
if (persistNow !== false) persist(nextConversations, activeIdRef.current)
|
|
679
|
+
}, [persist])
|
|
680
|
+
|
|
681
|
+
const mutateConversation = useCallback((conversationId, updater, persistNow) => {
|
|
682
|
+
const previous = conversationsRef.current
|
|
683
|
+
const index = previous.findIndex((item) => item.id === conversationId)
|
|
684
|
+
if (index < 0) return
|
|
685
|
+
const nextConversation = updater(previous[index])
|
|
686
|
+
if (nextConversation === previous[index]) return
|
|
687
|
+
const next = previous.slice()
|
|
688
|
+
next[index] = nextConversation
|
|
689
|
+
conversationsRef.current = next
|
|
690
|
+
setConversations(next)
|
|
691
|
+
if (persistNow !== false) persist(next, activeIdRef.current)
|
|
692
|
+
}, [persist])
|
|
693
|
+
|
|
694
|
+
/* ── 滚动跟随 ─────────────────────────────────────────────────────── */
|
|
695
|
+
const onScroll = useCallback(() => {
|
|
696
|
+
const node = listRef.current
|
|
697
|
+
if (node === null) return
|
|
698
|
+
stickRef.current = node.scrollHeight - node.scrollTop - node.clientHeight < 48
|
|
699
|
+
}, [])
|
|
700
|
+
|
|
701
|
+
useEffect(() => {
|
|
702
|
+
const node = listRef.current
|
|
703
|
+
if (node !== null && stickRef.current) node.scrollTop = node.scrollHeight
|
|
704
|
+
}, [conversations, activeId, thinking, streaming])
|
|
705
|
+
|
|
706
|
+
/* ── 流式请求生命周期 ─────────────────────────────────────────────── */
|
|
707
|
+
const clearWatchdog = useCallback(() => {
|
|
708
|
+
if (watchdogRef.current !== 0) {
|
|
709
|
+
window.clearTimeout(watchdogRef.current)
|
|
710
|
+
watchdogRef.current = 0
|
|
711
|
+
}
|
|
712
|
+
}, [])
|
|
713
|
+
|
|
714
|
+
const finishStream = useCallback((conversationId, assistantId, errorMessage) => {
|
|
715
|
+
const current = streamRef.current
|
|
716
|
+
if (current === null || current.assistantId !== assistantId) return
|
|
717
|
+
streamRef.current = null
|
|
718
|
+
clearWatchdog()
|
|
719
|
+
setStreaming(false)
|
|
720
|
+
setThinking(false)
|
|
721
|
+
mutateConversation(conversationId, (conversation) => {
|
|
722
|
+
const messages = conversation.messages.map((message) => {
|
|
723
|
+
if (message.id !== assistantId) return message
|
|
724
|
+
const content = typeof message.content === 'string' ? message.content : ''
|
|
725
|
+
if (typeof errorMessage === 'string' && errorMessage !== '') return { ...message, content, error: errorMessage }
|
|
726
|
+
if (content.trim() === '') return { ...message, content: GUFA_CHAT_LABELS.emptyReply }
|
|
727
|
+
return { ...message, content }
|
|
728
|
+
})
|
|
729
|
+
return { ...conversation, messages, updatedAt: Date.now() }
|
|
730
|
+
}, true)
|
|
731
|
+
}, [clearWatchdog, mutateConversation])
|
|
732
|
+
|
|
733
|
+
const handleStreamEvent = useCallback((conversationId, assistantId, event) => {
|
|
734
|
+
const current = streamRef.current
|
|
735
|
+
if (current === null || current.assistantId !== assistantId) return
|
|
736
|
+
if (event === null || typeof event !== 'object') return
|
|
737
|
+
if (event.type === 'thinking') {
|
|
738
|
+
setThinking(true)
|
|
739
|
+
return
|
|
740
|
+
}
|
|
741
|
+
if (event.type === 'delta') {
|
|
742
|
+
const text = typeof event.text === 'string' ? event.text : ''
|
|
743
|
+
if (text === '') return
|
|
744
|
+
setThinking(false)
|
|
745
|
+
mutateConversation(conversationId, (conversation) => ({
|
|
746
|
+
...conversation,
|
|
747
|
+
messages: conversation.messages.map((message) => (
|
|
748
|
+
message.id === assistantId ? { ...message, content: `${message.content ?? ''}${text}` } : message
|
|
749
|
+
)),
|
|
750
|
+
}), false)
|
|
751
|
+
return
|
|
752
|
+
}
|
|
753
|
+
if (event.type === 'error') {
|
|
754
|
+
finishStream(conversationId, assistantId, typeof event.message === 'string' && event.message !== '' ? event.message : t('chat.error'))
|
|
755
|
+
return
|
|
756
|
+
}
|
|
757
|
+
if (event.type === 'done') finishStream(conversationId, assistantId, '')
|
|
758
|
+
}, [finishStream, mutateConversation, t])
|
|
759
|
+
|
|
760
|
+
const armWatchdog = useCallback((conversationId, assistantId) => {
|
|
761
|
+
clearWatchdog()
|
|
762
|
+
watchdogRef.current = window.setTimeout(() => {
|
|
763
|
+
watchdogRef.current = 0
|
|
764
|
+
const current = streamRef.current
|
|
765
|
+
if (current === null || current.assistantId !== assistantId) return
|
|
766
|
+
const abort = current.abort
|
|
767
|
+
finishStream(conversationId, assistantId, '响应超时:长时间没有收到 AI 回复,请重试。')
|
|
768
|
+
try {
|
|
769
|
+
if (typeof abort === 'function') abort()
|
|
770
|
+
} catch {
|
|
771
|
+
/* ignore */
|
|
772
|
+
}
|
|
773
|
+
}, GUFA_CHAT_WATCHDOG_MS)
|
|
774
|
+
}, [clearWatchdog, finishStream])
|
|
775
|
+
|
|
776
|
+
const sendMessage = useCallback((overrideText) => {
|
|
777
|
+
const raw = typeof overrideText === 'string' ? overrideText : input
|
|
778
|
+
const text = raw.trim()
|
|
779
|
+
if (text === '' || streamRef.current !== null) return
|
|
780
|
+
const now = Date.now()
|
|
781
|
+
let conversation = conversationsRef.current.find((item) => item.id === activeIdRef.current) ?? null
|
|
782
|
+
let nextConversations = conversationsRef.current
|
|
783
|
+
if (conversation === null) {
|
|
784
|
+
conversation = { id: gufaChatNewId(), title: '', messages: [], updatedAt: now }
|
|
785
|
+
nextConversations = [conversation, ...nextConversations].slice(0, GUFA_CHAT_CONVERSATION_LIMIT)
|
|
786
|
+
}
|
|
787
|
+
const assistantId = gufaChatNewId()
|
|
788
|
+
const userMessage = { id: gufaChatNewId(), role: 'user', content: text, at: now }
|
|
789
|
+
const assistantMessage = { id: assistantId, role: 'assistant', content: '', at: now }
|
|
790
|
+
const history = gufaChatRequestMessages(conversation.messages)
|
|
791
|
+
const title = conversation.title === '' ? gufaChatTitleOf(text) : conversation.title
|
|
792
|
+
const updated = {
|
|
793
|
+
...conversation,
|
|
794
|
+
title,
|
|
795
|
+
messages: [...conversation.messages, userMessage, assistantMessage],
|
|
796
|
+
updatedAt: now,
|
|
797
|
+
}
|
|
798
|
+
nextConversations = [updated, ...nextConversations.filter((item) => item.id !== updated.id)]
|
|
799
|
+
.slice(0, GUFA_CHAT_CONVERSATION_LIMIT)
|
|
800
|
+
commitConversations(nextConversations, updated.id, true)
|
|
801
|
+
setInput('')
|
|
802
|
+
setNotice('')
|
|
803
|
+
setThinking(true)
|
|
804
|
+
setStreaming(true)
|
|
805
|
+
stickRef.current = true
|
|
806
|
+
const payload = { messages: [...history, { role: 'user', content: text }] }
|
|
807
|
+
const context = gufaChatBuildContext({ project, activeFile, selection, attachFile, attachSelection })
|
|
808
|
+
if (context !== null) payload.context = context
|
|
809
|
+
streamRef.current = { conversationId: updated.id, assistantId, abort: null }
|
|
810
|
+
armWatchdog(updated.id, assistantId)
|
|
811
|
+
try {
|
|
812
|
+
const abort = G.stream('chat.stream', payload, (event) => handleStreamEvent(updated.id, assistantId, event))
|
|
813
|
+
const current = streamRef.current
|
|
814
|
+
if (current !== null && current.assistantId === assistantId) current.abort = abort
|
|
815
|
+
} catch (error) {
|
|
816
|
+
finishStream(updated.id, assistantId, G.errorText(error))
|
|
817
|
+
}
|
|
818
|
+
}, [input, project, activeFile, selection, attachFile, attachSelection, commitConversations, handleStreamEvent, armWatchdog, finishStream])
|
|
819
|
+
|
|
820
|
+
const stopStream = useCallback(() => {
|
|
821
|
+
const current = streamRef.current
|
|
822
|
+
if (current === null) return
|
|
823
|
+
streamRef.current = null
|
|
824
|
+
clearWatchdog()
|
|
825
|
+
try {
|
|
826
|
+
if (typeof current.abort === 'function') current.abort()
|
|
827
|
+
} catch {
|
|
828
|
+
/* ignore */
|
|
829
|
+
}
|
|
830
|
+
setStreaming(false)
|
|
831
|
+
setThinking(false)
|
|
832
|
+
mutateConversation(current.conversationId, (conversation) => ({
|
|
833
|
+
...conversation,
|
|
834
|
+
messages: conversation.messages.map((message) => {
|
|
835
|
+
if (message.id !== current.assistantId) return message
|
|
836
|
+
const content = typeof message.content === 'string' ? message.content : ''
|
|
837
|
+
return { ...message, content: content === '' ? `(${t('chat.stop')})` : content, stopped: true }
|
|
838
|
+
}),
|
|
839
|
+
updatedAt: Date.now(),
|
|
840
|
+
}), true)
|
|
841
|
+
}, [clearWatchdog, mutateConversation, t])
|
|
842
|
+
|
|
843
|
+
/* ── 会话操作 ─────────────────────────────────────────────────────── */
|
|
844
|
+
const startNewConversation = useCallback(() => {
|
|
845
|
+
stopStream()
|
|
846
|
+
const current = conversationsRef.current
|
|
847
|
+
const reusable = current.find((item) => item.messages.length === 0) ?? null
|
|
848
|
+
if (reusable !== null) {
|
|
849
|
+
commitConversations(current, reusable.id, true)
|
|
850
|
+
setHistoryOpen(false)
|
|
851
|
+
return
|
|
852
|
+
}
|
|
853
|
+
const conversation = { id: gufaChatNewId(), title: '', messages: [], updatedAt: Date.now() }
|
|
854
|
+
const next = [conversation, ...current].slice(0, GUFA_CHAT_CONVERSATION_LIMIT)
|
|
855
|
+
commitConversations(next, conversation.id, true)
|
|
856
|
+
setHistoryOpen(false)
|
|
857
|
+
setNotice('')
|
|
858
|
+
window.setTimeout(() => {
|
|
859
|
+
if (inputRef.current !== null) inputRef.current.focus()
|
|
860
|
+
}, 0)
|
|
861
|
+
}, [stopStream, commitConversations])
|
|
862
|
+
|
|
863
|
+
const selectConversation = useCallback((conversationId) => {
|
|
864
|
+
setHistoryOpen(false)
|
|
865
|
+
if (conversationId === activeIdRef.current) return
|
|
866
|
+
stopStream()
|
|
867
|
+
const exists = conversationsRef.current.some((item) => item.id === conversationId)
|
|
868
|
+
if (!exists) return
|
|
869
|
+
activeIdRef.current = conversationId
|
|
870
|
+
setActiveId(conversationId)
|
|
871
|
+
setNotice('')
|
|
872
|
+
stickRef.current = true
|
|
873
|
+
persist(conversationsRef.current, conversationId)
|
|
874
|
+
}, [stopStream, persist])
|
|
875
|
+
|
|
876
|
+
const deleteConversation = useCallback((conversationId) => {
|
|
877
|
+
const current = streamRef.current
|
|
878
|
+
if (current !== null && current.conversationId === conversationId) stopStream()
|
|
879
|
+
const next = conversationsRef.current.filter((item) => item.id !== conversationId)
|
|
880
|
+
const nextActive = activeIdRef.current === conversationId ? (next[0]?.id ?? null) : activeIdRef.current
|
|
881
|
+
commitConversations(next, nextActive, true)
|
|
882
|
+
}, [stopStream, commitConversations])
|
|
883
|
+
|
|
884
|
+
const clearActiveConversation = useCallback(() => {
|
|
885
|
+
const conversationId = activeIdRef.current
|
|
886
|
+
if (conversationId === null) return
|
|
887
|
+
const current = streamRef.current
|
|
888
|
+
if (current !== null && current.conversationId === conversationId) stopStream()
|
|
889
|
+
mutateConversation(conversationId, (conversation) => ({ ...conversation, messages: [], title: '', updatedAt: Date.now() }), true)
|
|
890
|
+
setNotice('')
|
|
891
|
+
setHistoryOpen(false)
|
|
892
|
+
}, [stopStream, mutateConversation])
|
|
893
|
+
|
|
894
|
+
/* ── 输入框 ───────────────────────────────────────────────────────── */
|
|
895
|
+
useEffect(() => {
|
|
896
|
+
const node = inputRef.current
|
|
897
|
+
if (node === null) return
|
|
898
|
+
node.style.height = 'auto'
|
|
899
|
+
const maxHeight = 168
|
|
900
|
+
const height = Math.min(node.scrollHeight, maxHeight)
|
|
901
|
+
node.style.height = `${Math.max(height, 34)}px`
|
|
902
|
+
node.style.overflowY = node.scrollHeight > maxHeight ? 'auto' : 'hidden'
|
|
903
|
+
}, [input])
|
|
904
|
+
|
|
905
|
+
const onChangeInput = useCallback((event) => setInput(event.target.value), [])
|
|
906
|
+
|
|
907
|
+
const onInputKeyDown = useCallback((event) => {
|
|
908
|
+
const composing = event.nativeEvent !== undefined && event.nativeEvent !== null
|
|
909
|
+
? event.nativeEvent.isComposing === true
|
|
910
|
+
: event.isComposing === true
|
|
911
|
+
if (event.key !== 'Enter' || event.shiftKey || composing) return
|
|
912
|
+
event.preventDefault()
|
|
913
|
+
if (streamRef.current !== null) return
|
|
914
|
+
sendMessage()
|
|
915
|
+
}, [sendMessage])
|
|
916
|
+
|
|
917
|
+
/* ── 历史下拉:点击外部关闭 ───────────────────────────────────────── */
|
|
918
|
+
useEffect(() => {
|
|
919
|
+
if (!historyOpen) return undefined
|
|
920
|
+
const onPointerDown = (event) => {
|
|
921
|
+
const node = headRef.current
|
|
922
|
+
if (node !== null && node.contains(event.target)) return
|
|
923
|
+
setHistoryOpen(false)
|
|
924
|
+
}
|
|
925
|
+
window.addEventListener('mousedown', onPointerDown, true)
|
|
926
|
+
return () => window.removeEventListener('mousedown', onPointerDown, true)
|
|
927
|
+
}, [historyOpen])
|
|
928
|
+
|
|
929
|
+
/* ── 渲染数据 ─────────────────────────────────────────────────────── */
|
|
930
|
+
const activeConversation = useMemo(
|
|
931
|
+
() => conversations.find((item) => item.id === activeId) ?? null,
|
|
932
|
+
[conversations, activeId],
|
|
933
|
+
)
|
|
934
|
+
const activeMessages = activeConversation !== null && Array.isArray(activeConversation.messages) ? activeConversation.messages : []
|
|
935
|
+
const streamingId = streamRef.current !== null ? streamRef.current.assistantId : null
|
|
936
|
+
const hasEditorContext = activeFile !== null || selection !== null
|
|
937
|
+
const modelText = info.model === ''
|
|
938
|
+
? '—'
|
|
939
|
+
: `${info.provider === '' ? '' : `${info.provider} / `}${info.model}`
|
|
940
|
+
const modelTitle = infoError !== ''
|
|
941
|
+
? `${t('chat.model')}:${infoError}`
|
|
942
|
+
: info.ready
|
|
943
|
+
? `${t('chat.model')}:${modelText}`
|
|
944
|
+
: `${t('chat.model')}:${modelText}(${GUFA_CHAT_LABELS.notReady})`
|
|
945
|
+
|
|
946
|
+
const quickActions = [
|
|
947
|
+
{ key: 'explain', label: t('chat.quickExplain'), prompt: '请解释我提供的这段代码:它做了什么、关键逻辑是什么、有哪些需要注意的边界情况。' },
|
|
948
|
+
{ key: 'bug', label: t('chat.quickBug'), prompt: '请检查我提供的这段代码可能存在的问题(bug、边界条件、性能、可读性),并给出修改建议。' },
|
|
949
|
+
{ key: 'comment', label: t('chat.quickComment'), prompt: '请为我提供的这段代码补充简洁清晰的中文注释,直接给出注释后的完整代码。' },
|
|
950
|
+
{ key: 'refactor', label: t('chat.quickRefactor'), prompt: '请给出我提供的这段代码的重构建议,并给出重构后的完整代码。' },
|
|
951
|
+
]
|
|
952
|
+
|
|
953
|
+
/* ── 历史下拉列表 ─────────────────────────────────────────────────── */
|
|
954
|
+
const historyMenu = historyOpen
|
|
955
|
+
? h(
|
|
956
|
+
'div',
|
|
957
|
+
{ className: 'gufa-chat-history' },
|
|
958
|
+
h(
|
|
959
|
+
'div',
|
|
960
|
+
{ className: 'gufa-chat-history-head' },
|
|
961
|
+
h(G.Icon, { name: 'history', size: 11 }),
|
|
962
|
+
t('chat.history'),
|
|
963
|
+
h('span', { className: 'gufa-chat-fill' }),
|
|
964
|
+
h(
|
|
965
|
+
'button',
|
|
966
|
+
{
|
|
967
|
+
type: 'button',
|
|
968
|
+
className: 'gufa-chat-code-btn',
|
|
969
|
+
title: t('chat.clear'),
|
|
970
|
+
disabled: activeMessages.length === 0 && activeConversation === null,
|
|
971
|
+
onClick: clearActiveConversation,
|
|
972
|
+
},
|
|
973
|
+
h(G.Icon, { name: 'trash', size: 11 }),
|
|
974
|
+
t('chat.clear'),
|
|
975
|
+
),
|
|
976
|
+
),
|
|
977
|
+
conversations.length === 0
|
|
978
|
+
? h('div', { className: 'gufa-chat-history-empty' }, t('chat.noHistory'))
|
|
979
|
+
: h(
|
|
980
|
+
'div',
|
|
981
|
+
{ className: 'gufa-chat-history-list gufa-scroll' },
|
|
982
|
+
conversations.map((conversation) => h(
|
|
983
|
+
'div',
|
|
984
|
+
{
|
|
985
|
+
key: conversation.id,
|
|
986
|
+
className: 'gufa-chat-history-item',
|
|
987
|
+
'data-active': conversation.id === activeId ? '' : undefined,
|
|
988
|
+
},
|
|
989
|
+
h(
|
|
990
|
+
'button',
|
|
991
|
+
{
|
|
992
|
+
type: 'button',
|
|
993
|
+
className: 'gufa-chat-history-main',
|
|
994
|
+
title: conversation.title === '' ? GUFA_CHAT_LABELS.newConversation : conversation.title,
|
|
995
|
+
onClick: () => selectConversation(conversation.id),
|
|
996
|
+
},
|
|
997
|
+
h('span', { className: 'gufa-chat-history-title' }, conversation.title === '' ? GUFA_CHAT_LABELS.newConversation : conversation.title),
|
|
998
|
+
h('span', { className: 'gufa-chat-history-time' }, gufaChatRelativeTime(conversation.updatedAt)),
|
|
999
|
+
),
|
|
1000
|
+
h(
|
|
1001
|
+
'button',
|
|
1002
|
+
{
|
|
1003
|
+
type: 'button',
|
|
1004
|
+
className: 'gufa-chat-history-del',
|
|
1005
|
+
title: t('chat.deleteChat'),
|
|
1006
|
+
onClick: (event) => {
|
|
1007
|
+
event.stopPropagation()
|
|
1008
|
+
deleteConversation(conversation.id)
|
|
1009
|
+
},
|
|
1010
|
+
},
|
|
1011
|
+
h(G.Icon, { name: 'trash', size: 12 }),
|
|
1012
|
+
),
|
|
1013
|
+
)),
|
|
1014
|
+
),
|
|
1015
|
+
h(
|
|
1016
|
+
'div',
|
|
1017
|
+
{ className: 'gufa-chat-history-foot' },
|
|
1018
|
+
h(
|
|
1019
|
+
G.Button,
|
|
1020
|
+
{ icon: 'plus', onClick: startNewConversation },
|
|
1021
|
+
t('chat.newChat'),
|
|
1022
|
+
),
|
|
1023
|
+
),
|
|
1024
|
+
)
|
|
1025
|
+
: null
|
|
1026
|
+
|
|
1027
|
+
/* ── 消息列表 ─────────────────────────────────────────────────────── */
|
|
1028
|
+
const messageList = activeMessages.length === 0
|
|
1029
|
+
? h(G.Empty, { icon: 'sparkles', title: t('chat.emptyTitle'), hint: t('chat.emptyHint') })
|
|
1030
|
+
: activeMessages.map((message) => h(GufaChatMessageView, {
|
|
1031
|
+
key: message.id,
|
|
1032
|
+
message,
|
|
1033
|
+
streaming: streaming && message.id === streamingId,
|
|
1034
|
+
thinking: thinking && message.id === streamingId,
|
|
1035
|
+
onInsert: insertCode,
|
|
1036
|
+
}))
|
|
1037
|
+
|
|
1038
|
+
/* ── 渲染 ─────────────────────────────────────────────────────────── */
|
|
1039
|
+
return h(
|
|
1040
|
+
'div',
|
|
1041
|
+
{ className: 'gufa-chat-wrap' },
|
|
1042
|
+
h(
|
|
1043
|
+
'div',
|
|
1044
|
+
{ className: 'gufa-chat-headwrap', ref: headRef },
|
|
1045
|
+
h(
|
|
1046
|
+
'div',
|
|
1047
|
+
{ className: 'gufa-panel-head gufa-chat-head' },
|
|
1048
|
+
h(G.Icon, { name: 'sparkles', size: 13 }),
|
|
1049
|
+
h('span', { className: 'gufa-panel-head-title' }, t('chat.title')),
|
|
1050
|
+
h('span', { className: 'gufa-chat-model', title: modelTitle, 'data-warn': info.ready ? undefined : '' }, modelText),
|
|
1051
|
+
h(
|
|
1052
|
+
G.IconButton,
|
|
1053
|
+
{ title: t('chat.history'), onClick: () => setHistoryOpen((value) => !value) },
|
|
1054
|
+
h(G.Icon, { name: 'history', size: 14 }),
|
|
1055
|
+
),
|
|
1056
|
+
h(
|
|
1057
|
+
G.IconButton,
|
|
1058
|
+
{ title: t('chat.newChat'), onClick: startNewConversation },
|
|
1059
|
+
h(G.Icon, { name: 'plus', size: 14 }),
|
|
1060
|
+
),
|
|
1061
|
+
),
|
|
1062
|
+
historyMenu,
|
|
1063
|
+
),
|
|
1064
|
+
h(
|
|
1065
|
+
'div',
|
|
1066
|
+
{ className: 'gufa-chat-msgs gufa-scroll', ref: listRef, onScroll },
|
|
1067
|
+
messageList,
|
|
1068
|
+
),
|
|
1069
|
+
h(
|
|
1070
|
+
'div',
|
|
1071
|
+
{ className: 'gufa-chat-composer' },
|
|
1072
|
+
h(
|
|
1073
|
+
'div',
|
|
1074
|
+
{ className: 'gufa-chat-context' },
|
|
1075
|
+
h('span', { className: 'gufa-chat-context-label' }, t('chat.contextLabel')),
|
|
1076
|
+
activeFile !== null
|
|
1077
|
+
? h(
|
|
1078
|
+
'button',
|
|
1079
|
+
{
|
|
1080
|
+
type: 'button',
|
|
1081
|
+
className: 'gufa-chat-chip',
|
|
1082
|
+
'data-on': attachFile ? '' : undefined,
|
|
1083
|
+
title: `${t('chat.ctxFile')}:${activeFile.path ?? ''}${attachFile ? '' : `(${t('chat.attachFile')})`}`,
|
|
1084
|
+
onClick: () => setAttachFile((value) => !value),
|
|
1085
|
+
},
|
|
1086
|
+
h(G.Icon, { name: 'file', size: 11 }),
|
|
1087
|
+
h('span', { className: 'gufa-chat-chip-text' }, G.basename(activeFile.path)),
|
|
1088
|
+
)
|
|
1089
|
+
: null,
|
|
1090
|
+
selection !== null
|
|
1091
|
+
? h(
|
|
1092
|
+
'button',
|
|
1093
|
+
{
|
|
1094
|
+
type: 'button',
|
|
1095
|
+
className: 'gufa-chat-chip',
|
|
1096
|
+
'data-on': attachSelection ? '' : undefined,
|
|
1097
|
+
title: `${t('chat.ctxSelection')}${selection.path ? `:${selection.path}` : ''}`,
|
|
1098
|
+
onClick: () => setAttachSelection((value) => !value),
|
|
1099
|
+
},
|
|
1100
|
+
h(G.Icon, { name: 'code', size: 11 }),
|
|
1101
|
+
h('span', { className: 'gufa-chat-chip-text' },
|
|
1102
|
+
selection.line === undefined || selection.line === null
|
|
1103
|
+
? t('chat.ctxSelection')
|
|
1104
|
+
: `${t('chat.ctxSelection')} · ${selection.line}`,
|
|
1105
|
+
),
|
|
1106
|
+
)
|
|
1107
|
+
: null,
|
|
1108
|
+
activeFile === null && selection === null
|
|
1109
|
+
? h('span', { className: 'gufa-chat-context-hint' }, GUFA_CHAT_LABELS.openFileHint)
|
|
1110
|
+
: null,
|
|
1111
|
+
),
|
|
1112
|
+
h(
|
|
1113
|
+
'div',
|
|
1114
|
+
{ className: 'gufa-chat-quick' },
|
|
1115
|
+
quickActions.map((action) => h(
|
|
1116
|
+
'button',
|
|
1117
|
+
{
|
|
1118
|
+
key: action.key,
|
|
1119
|
+
type: 'button',
|
|
1120
|
+
className: 'gufa-chat-quick-btn',
|
|
1121
|
+
disabled: !hasEditorContext || streaming,
|
|
1122
|
+
title: hasEditorContext ? action.label : GUFA_CHAT_LABELS.openFileHint,
|
|
1123
|
+
onClick: () => sendMessage(action.prompt),
|
|
1124
|
+
},
|
|
1125
|
+
action.label,
|
|
1126
|
+
)),
|
|
1127
|
+
),
|
|
1128
|
+
notice !== ''
|
|
1129
|
+
? h(
|
|
1130
|
+
'div',
|
|
1131
|
+
{ className: 'gufa-chat-notice' },
|
|
1132
|
+
h(G.Icon, { name: 'warn', size: 12 }),
|
|
1133
|
+
h('span', { className: 'gufa-chat-notice-text' }, notice),
|
|
1134
|
+
h(
|
|
1135
|
+
'button',
|
|
1136
|
+
{ type: 'button', className: 'gufa-chat-notice-close', title: t('common.close'), onClick: () => setNotice('') },
|
|
1137
|
+
h(G.Icon, { name: 'close', size: 11 }),
|
|
1138
|
+
),
|
|
1139
|
+
)
|
|
1140
|
+
: null,
|
|
1141
|
+
h(
|
|
1142
|
+
'div',
|
|
1143
|
+
{ className: 'gufa-chat-inputwrap' },
|
|
1144
|
+
h('textarea', {
|
|
1145
|
+
ref: inputRef,
|
|
1146
|
+
className: 'gufa-chat-input gufa-scroll',
|
|
1147
|
+
rows: 1,
|
|
1148
|
+
value: input,
|
|
1149
|
+
placeholder: t('chat.inputPlaceholder'),
|
|
1150
|
+
spellCheck: false,
|
|
1151
|
+
onChange: onChangeInput,
|
|
1152
|
+
onKeyDown: onInputKeyDown,
|
|
1153
|
+
}),
|
|
1154
|
+
streaming
|
|
1155
|
+
? h(
|
|
1156
|
+
'button',
|
|
1157
|
+
{ type: 'button', className: 'gufa-chat-send', 'data-stop': '', title: t('chat.stop'), onClick: stopStream },
|
|
1158
|
+
h(G.Icon, { name: 'stop', size: 12 }),
|
|
1159
|
+
t('chat.stop'),
|
|
1160
|
+
)
|
|
1161
|
+
: h(
|
|
1162
|
+
'button',
|
|
1163
|
+
{
|
|
1164
|
+
type: 'button',
|
|
1165
|
+
className: 'gufa-chat-send',
|
|
1166
|
+
disabled: input.trim() === '',
|
|
1167
|
+
title: t('chat.send'),
|
|
1168
|
+
onClick: () => sendMessage(),
|
|
1169
|
+
},
|
|
1170
|
+
h(G.Icon, { name: 'send', size: 12 }),
|
|
1171
|
+
t('chat.send'),
|
|
1172
|
+
),
|
|
1173
|
+
),
|
|
1174
|
+
h(
|
|
1175
|
+
'div',
|
|
1176
|
+
{ className: 'gufa-chat-foot' },
|
|
1177
|
+
h('span', { className: 'gufa-chat-foot-dim' }, GUFA_CHAT_LABELS.ctrlHint),
|
|
1178
|
+
h('span', { className: 'gufa-chat-foot-dim' }, activeConversation === null
|
|
1179
|
+
? GUFA_CHAT_LABELS.newConversation
|
|
1180
|
+
: `${activeMessages.length} · ${conversations.length}/${GUFA_CHAT_CONVERSATION_LIMIT}`),
|
|
1181
|
+
),
|
|
1182
|
+
),
|
|
1183
|
+
)
|
|
1184
|
+
}
|