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.
Files changed (158) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +93 -2
  3. package/client.js +11548 -0
  4. package/cordis.patch.yml +8 -0
  5. package/docs/icon-180.png +0 -0
  6. package/docs/icon.svg +18 -0
  7. package/docs/market-submission.md +32 -0
  8. package/docs/screenshot-ide.jpg +0 -0
  9. package/host/assets.js +31 -0
  10. package/host/chat.js +523 -0
  11. package/host/context.js +438 -0
  12. package/host/dev.js +35 -0
  13. package/host/fs.js +306 -0
  14. package/host/git.js +532 -0
  15. package/host/project.js +305 -0
  16. package/host/ssh.js +54 -0
  17. package/host/terminal-pty.js +237 -0
  18. package/host/terminal.js +947 -0
  19. package/icon.svg +18 -0
  20. package/index.js +63 -0
  21. package/locale/en.json +121 -0
  22. package/locale/zh.json +121 -0
  23. package/package.json +67 -4
  24. package/screenshots.json +3 -0
  25. package/scripts/build-client.mjs +38 -0
  26. package/scripts/selftest-client.mjs +457 -0
  27. package/scripts/selftest-host.mjs +197 -0
  28. package/scripts/selftest-http.mjs +284 -0
  29. package/scripts/selftest-router.mjs +101 -0
  30. package/scripts/selftest-terminal-client.mjs +314 -0
  31. package/scripts/selftest-terminal-pty.mjs +190 -0
  32. package/scripts/selftest-terminal.mjs +351 -0
  33. package/scripts/test-chat-render.mjs +702 -0
  34. package/scripts/test-chat.mjs +761 -0
  35. package/scripts/test-editor.mjs +516 -0
  36. package/scripts/verify-workspace-sessions.mjs +135 -0
  37. package/src/client/00-core.js +1686 -0
  38. package/src/client/10-icons.js +205 -0
  39. package/src/client/20-editor.js +1318 -0
  40. package/src/client/30-terminal.js +1510 -0
  41. package/src/client/40-chat.js +1184 -0
  42. package/src/client/46-gitgraph.js +278 -0
  43. package/src/client/50-panels.js +916 -0
  44. package/src/client/55-backdrop.js +508 -0
  45. package/src/client/60-dock.js +662 -0
  46. package/src/client/85-shortcuts.js +516 -0
  47. package/src/client/90-app.js +2272 -0
  48. package/src/client/99-main.js +449 -0
  49. package/vendor/monaco/LICENSE +21 -0
  50. package/vendor/monaco/ThirdPartyNotices.txt +448 -0
  51. package/vendor/monaco/vs/base/browser/ui/codicons/codicon/codicon.ttf +0 -0
  52. package/vendor/monaco/vs/base/worker/workerMain.js +31 -0
  53. package/vendor/monaco/vs/basic-languages/abap/abap.js +10 -0
  54. package/vendor/monaco/vs/basic-languages/apex/apex.js +10 -0
  55. package/vendor/monaco/vs/basic-languages/azcli/azcli.js +10 -0
  56. package/vendor/monaco/vs/basic-languages/bat/bat.js +10 -0
  57. package/vendor/monaco/vs/basic-languages/bicep/bicep.js +11 -0
  58. package/vendor/monaco/vs/basic-languages/cameligo/cameligo.js +10 -0
  59. package/vendor/monaco/vs/basic-languages/clojure/clojure.js +10 -0
  60. package/vendor/monaco/vs/basic-languages/coffee/coffee.js +10 -0
  61. package/vendor/monaco/vs/basic-languages/cpp/cpp.js +10 -0
  62. package/vendor/monaco/vs/basic-languages/csharp/csharp.js +10 -0
  63. package/vendor/monaco/vs/basic-languages/csp/csp.js +10 -0
  64. package/vendor/monaco/vs/basic-languages/css/css.js +12 -0
  65. package/vendor/monaco/vs/basic-languages/cypher/cypher.js +10 -0
  66. package/vendor/monaco/vs/basic-languages/dart/dart.js +10 -0
  67. package/vendor/monaco/vs/basic-languages/dockerfile/dockerfile.js +10 -0
  68. package/vendor/monaco/vs/basic-languages/ecl/ecl.js +10 -0
  69. package/vendor/monaco/vs/basic-languages/elixir/elixir.js +10 -0
  70. package/vendor/monaco/vs/basic-languages/flow9/flow9.js +10 -0
  71. package/vendor/monaco/vs/basic-languages/freemarker2/freemarker2.js +12 -0
  72. package/vendor/monaco/vs/basic-languages/fsharp/fsharp.js +10 -0
  73. package/vendor/monaco/vs/basic-languages/go/go.js +10 -0
  74. package/vendor/monaco/vs/basic-languages/graphql/graphql.js +10 -0
  75. package/vendor/monaco/vs/basic-languages/handlebars/handlebars.js +10 -0
  76. package/vendor/monaco/vs/basic-languages/hcl/hcl.js +10 -0
  77. package/vendor/monaco/vs/basic-languages/html/html.js +10 -0
  78. package/vendor/monaco/vs/basic-languages/ini/ini.js +10 -0
  79. package/vendor/monaco/vs/basic-languages/java/java.js +10 -0
  80. package/vendor/monaco/vs/basic-languages/javascript/javascript.js +10 -0
  81. package/vendor/monaco/vs/basic-languages/julia/julia.js +10 -0
  82. package/vendor/monaco/vs/basic-languages/kotlin/kotlin.js +10 -0
  83. package/vendor/monaco/vs/basic-languages/less/less.js +11 -0
  84. package/vendor/monaco/vs/basic-languages/lexon/lexon.js +10 -0
  85. package/vendor/monaco/vs/basic-languages/liquid/liquid.js +10 -0
  86. package/vendor/monaco/vs/basic-languages/lua/lua.js +10 -0
  87. package/vendor/monaco/vs/basic-languages/m3/m3.js +10 -0
  88. package/vendor/monaco/vs/basic-languages/markdown/markdown.js +10 -0
  89. package/vendor/monaco/vs/basic-languages/mdx/mdx.js +10 -0
  90. package/vendor/monaco/vs/basic-languages/mips/mips.js +10 -0
  91. package/vendor/monaco/vs/basic-languages/msdax/msdax.js +10 -0
  92. package/vendor/monaco/vs/basic-languages/mysql/mysql.js +10 -0
  93. package/vendor/monaco/vs/basic-languages/objective-c/objective-c.js +10 -0
  94. package/vendor/monaco/vs/basic-languages/pascal/pascal.js +10 -0
  95. package/vendor/monaco/vs/basic-languages/pascaligo/pascaligo.js +10 -0
  96. package/vendor/monaco/vs/basic-languages/perl/perl.js +10 -0
  97. package/vendor/monaco/vs/basic-languages/pgsql/pgsql.js +10 -0
  98. package/vendor/monaco/vs/basic-languages/php/php.js +10 -0
  99. package/vendor/monaco/vs/basic-languages/pla/pla.js +10 -0
  100. package/vendor/monaco/vs/basic-languages/postiats/postiats.js +10 -0
  101. package/vendor/monaco/vs/basic-languages/powerquery/powerquery.js +10 -0
  102. package/vendor/monaco/vs/basic-languages/powershell/powershell.js +10 -0
  103. package/vendor/monaco/vs/basic-languages/protobuf/protobuf.js +11 -0
  104. package/vendor/monaco/vs/basic-languages/pug/pug.js +10 -0
  105. package/vendor/monaco/vs/basic-languages/python/python.js +10 -0
  106. package/vendor/monaco/vs/basic-languages/qsharp/qsharp.js +10 -0
  107. package/vendor/monaco/vs/basic-languages/r/r.js +10 -0
  108. package/vendor/monaco/vs/basic-languages/razor/razor.js +10 -0
  109. package/vendor/monaco/vs/basic-languages/redis/redis.js +10 -0
  110. package/vendor/monaco/vs/basic-languages/redshift/redshift.js +10 -0
  111. package/vendor/monaco/vs/basic-languages/restructuredtext/restructuredtext.js +10 -0
  112. package/vendor/monaco/vs/basic-languages/ruby/ruby.js +10 -0
  113. package/vendor/monaco/vs/basic-languages/rust/rust.js +10 -0
  114. package/vendor/monaco/vs/basic-languages/sb/sb.js +10 -0
  115. package/vendor/monaco/vs/basic-languages/scala/scala.js +10 -0
  116. package/vendor/monaco/vs/basic-languages/scheme/scheme.js +10 -0
  117. package/vendor/monaco/vs/basic-languages/scss/scss.js +12 -0
  118. package/vendor/monaco/vs/basic-languages/shell/shell.js +10 -0
  119. package/vendor/monaco/vs/basic-languages/solidity/solidity.js +10 -0
  120. package/vendor/monaco/vs/basic-languages/sophia/sophia.js +10 -0
  121. package/vendor/monaco/vs/basic-languages/sparql/sparql.js +10 -0
  122. package/vendor/monaco/vs/basic-languages/sql/sql.js +10 -0
  123. package/vendor/monaco/vs/basic-languages/st/st.js +10 -0
  124. package/vendor/monaco/vs/basic-languages/swift/swift.js +13 -0
  125. package/vendor/monaco/vs/basic-languages/systemverilog/systemverilog.js +10 -0
  126. package/vendor/monaco/vs/basic-languages/tcl/tcl.js +10 -0
  127. package/vendor/monaco/vs/basic-languages/twig/twig.js +10 -0
  128. package/vendor/monaco/vs/basic-languages/typescript/typescript.js +10 -0
  129. package/vendor/monaco/vs/basic-languages/typespec/typespec.js +10 -0
  130. package/vendor/monaco/vs/basic-languages/vb/vb.js +10 -0
  131. package/vendor/monaco/vs/basic-languages/wgsl/wgsl.js +307 -0
  132. package/vendor/monaco/vs/basic-languages/xml/xml.js +10 -0
  133. package/vendor/monaco/vs/basic-languages/yaml/yaml.js +10 -0
  134. package/vendor/monaco/vs/editor/editor.main.css +8 -0
  135. package/vendor/monaco/vs/editor/editor.main.js +798 -0
  136. package/vendor/monaco/vs/language/css/cssMode.js +13 -0
  137. package/vendor/monaco/vs/language/css/cssWorker.js +77 -0
  138. package/vendor/monaco/vs/language/html/htmlMode.js +13 -0
  139. package/vendor/monaco/vs/language/html/htmlWorker.js +454 -0
  140. package/vendor/monaco/vs/language/json/jsonMode.js +19 -0
  141. package/vendor/monaco/vs/language/json/jsonWorker.js +42 -0
  142. package/vendor/monaco/vs/language/typescript/tsMode.js +20 -0
  143. package/vendor/monaco/vs/language/typescript/tsWorker.js +51328 -0
  144. package/vendor/monaco/vs/loader.js +11 -0
  145. package/vendor/monaco/vs/nls.messages.de.js +21 -0
  146. package/vendor/monaco/vs/nls.messages.es.js +21 -0
  147. package/vendor/monaco/vs/nls.messages.fr.js +19 -0
  148. package/vendor/monaco/vs/nls.messages.it.js +19 -0
  149. package/vendor/monaco/vs/nls.messages.ja.js +21 -0
  150. package/vendor/monaco/vs/nls.messages.ko.js +19 -0
  151. package/vendor/monaco/vs/nls.messages.ru.js +21 -0
  152. package/vendor/monaco/vs/nls.messages.zh-cn.js +21 -0
  153. package/vendor/monaco/vs/nls.messages.zh-tw.js +19 -0
  154. package/vendor/xterm/LICENSE +21 -0
  155. package/vendor/xterm/LICENSE.addon-fit +19 -0
  156. package/vendor/xterm/addon-fit.js +2 -0
  157. package/vendor/xterm/xterm.css +285 -0
  158. 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
+ }