AgentAssistantDrawer.vue 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359
  1. <script setup>
  2. import { ref, computed, watch, nextTick } from 'vue'
  3. import { ElMessage } from 'element-plus'
  4. import { ChatDotRound, Key, Link, Delete } from '@element-plus/icons-vue'
  5. import { agentDrawerOpen } from '../../stores/uiShellStore'
  6. import {
  7. GITCC_API_BASE,
  8. GITCC_API_KEY_STORAGE,
  9. GITCC_MODEL_DEFAULT,
  10. } from '../../shellConstants'
  11. function setDrawerOpen(v) {
  12. agentDrawerOpen.value = v
  13. }
  14. const apiKeyInput = ref('')
  15. const keyStatus = ref('empty')
  16. const keyMasked = ref('')
  17. const messages = ref([])
  18. const inputText = ref('')
  19. const sending = ref(false)
  20. const messagesEl = ref(null)
  21. const settingsOpen = ref(['settings'])
  22. function maskKey(k) {
  23. if (!k || k.length < 6) return '******'
  24. return `${k.slice(0, 2)}******${k.slice(-2)}`
  25. }
  26. function loadStoredKey() {
  27. const k = localStorage.getItem(GITCC_API_KEY_STORAGE)
  28. if (k) {
  29. keyMasked.value = maskKey(k)
  30. keyStatus.value = 'ready'
  31. apiKeyInput.value = ''
  32. } else {
  33. keyMasked.value = ''
  34. keyStatus.value = 'empty'
  35. }
  36. }
  37. watch(agentDrawerOpen, (open) => {
  38. if (open) {
  39. loadStoredKey()
  40. if (messages.value.length === 0) {
  41. messages.value.push({
  42. role: 'assistant',
  43. content: '你好,我是智能助手。请先完成 API Key 配置,即可开始对话。',
  44. })
  45. }
  46. nextTick(scrollBottom)
  47. }
  48. })
  49. function scrollBottom() {
  50. const el = messagesEl.value
  51. if (!el) return
  52. el.scrollTop = el.scrollHeight
  53. }
  54. async function verifyAndSave() {
  55. const key = apiKeyInput.value.trim()
  56. if (!key) {
  57. ElMessage.warning('请先粘贴 API Key')
  58. return
  59. }
  60. try {
  61. const res = await fetch(`${GITCC_API_BASE}/models`, {
  62. method: 'GET',
  63. headers: { Authorization: `Bearer ${key}` },
  64. })
  65. if (!res.ok) {
  66. keyStatus.value = 'invalid'
  67. ElMessage.error(`验证失败(HTTP ${res.status}),请检查 Key 是否有效`)
  68. return
  69. }
  70. localStorage.setItem(GITCC_API_KEY_STORAGE, key)
  71. keyMasked.value = maskKey(key)
  72. keyStatus.value = 'ready'
  73. apiKeyInput.value = ''
  74. ElMessage.success('Key 已验证并保存到本机浏览器')
  75. } catch (e) {
  76. keyStatus.value = 'invalid'
  77. ElMessage.error('网络异常或跨域被拦截,请检查网络或联系管理员配置代理')
  78. console.error(e)
  79. }
  80. }
  81. function clearKey() {
  82. localStorage.removeItem(GITCC_API_KEY_STORAGE)
  83. keyStatus.value = 'empty'
  84. keyMasked.value = ''
  85. apiKeyInput.value = ''
  86. messages.value = [
  87. { role: 'assistant', content: '已清除本地 Key。重新配置后可继续对话。' },
  88. ]
  89. ElMessage.info('已清除本地存储的 Key')
  90. }
  91. function openGetKey() {
  92. window.open('https://api.gitcc.com/', '_blank', 'noopener,noreferrer')
  93. }
  94. function openCommunity() {
  95. window.open('https://www.gitcc.com/', '_blank', 'noopener,noreferrer')
  96. }
  97. function getStoredKey() {
  98. return localStorage.getItem(GITCC_API_KEY_STORAGE) || ''
  99. }
  100. async function sendMessage() {
  101. const text = inputText.value.trim()
  102. if (!text || sending.value) return
  103. const key = getStoredKey()
  104. if (!key) {
  105. ElMessage.warning('请先配置并验证 API Key')
  106. return
  107. }
  108. messages.value.push({ role: 'user', content: text })
  109. inputText.value = ''
  110. sending.value = true
  111. await nextTick()
  112. scrollBottom()
  113. try {
  114. const res = await fetch(`${GITCC_API_BASE}/chat/completions`, {
  115. method: 'POST',
  116. headers: {
  117. Authorization: `Bearer ${key}`,
  118. 'Content-Type': 'application/json',
  119. },
  120. body: JSON.stringify({
  121. model: GITCC_MODEL_DEFAULT,
  122. messages: [{ role: 'user', content: text }],
  123. temperature: 0.7,
  124. max_tokens: 256,
  125. }),
  126. })
  127. const data = await res.json().catch(() => ({}))
  128. if (!res.ok) {
  129. const msg = data?.error?.message || `请求失败(${res.status})`
  130. messages.value.push({ role: 'assistant', content: `调用失败:${msg}。请检查 Key 或稍后重试。` })
  131. return
  132. }
  133. const reply = data?.choices?.[0]?.message?.content || '(无内容返回)'
  134. messages.value.push({ role: 'assistant', content: reply })
  135. } catch (e) {
  136. messages.value.push({
  137. role: 'assistant',
  138. content: '网络错误:无法连接智能体服务。若浏览器拦截跨域请求,需由网关代理 GitCC API。',
  139. })
  140. console.error(e)
  141. } finally {
  142. sending.value = false
  143. await nextTick()
  144. scrollBottom()
  145. }
  146. }
  147. function onKeydown(e) {
  148. if (e.key === 'Enter' && !e.shiftKey) {
  149. e.preventDefault()
  150. sendMessage()
  151. }
  152. }
  153. const chatDisabled = computed(() => keyStatus.value !== 'ready' || sending.value)
  154. </script>
  155. <template>
  156. <el-drawer
  157. :model-value="agentDrawerOpen"
  158. append-to-body
  159. direction="rtl"
  160. size="min(520px, 100vw)"
  161. class="agent-drawer"
  162. :show-close="true"
  163. @update:model-value="setDrawerOpen"
  164. >
  165. <template #header>
  166. <div class="drawer-title">
  167. <el-icon><ChatDotRound /></el-icon>
  168. <span>智能体助手</span>
  169. </div>
  170. </template>
  171. <el-collapse v-model="settingsOpen">
  172. <el-collapse-item title="API Key 配置" name="settings">
  173. <div class="settings-body">
  174. <p class="tip">
  175. Key 仅保存在您本机浏览器的 localStorage,用于直接调用 GitCC 接口,不会发送到检澜业务后端。
  176. </p>
  177. <el-alert
  178. class="proxy-alert"
  179. type="info"
  180. :closable="false"
  181. show-icon
  182. title="直连与跨域"
  183. description="浏览器直连 api.gitcc.com 可能受 CORS 限制,且 Key 暴露于前端。生产环境建议由后端网关代理 GitCC OpenAI 兼容接口(/v1)。"
  184. />
  185. <div class="link-row">
  186. <el-button type="primary" link :icon="Link" @click="openGetKey">获取 Key</el-button>
  187. <el-button type="info" link @click="openCommunity">前往 GitCC 社区</el-button>
  188. </div>
  189. <el-input
  190. v-model="apiKeyInput"
  191. type="password"
  192. show-password
  193. placeholder="粘贴 GitCC API Key"
  194. class="key-input"
  195. />
  196. <div class="btn-row">
  197. <el-button type="primary" @click="verifyAndSave">验证并保存</el-button>
  198. <el-button v-if="keyStatus === 'ready'" @click="clearKey" :icon="Delete">清除 Key</el-button>
  199. </div>
  200. <div v-if="keyStatus === 'ready'" class="key-ok">
  201. <el-icon><Key /></el-icon>
  202. 已配置:{{ keyMasked }}
  203. </div>
  204. <div v-else-if="keyStatus === 'invalid'" class="key-bad">上次验证未通过,请检查 Key 后重试。</div>
  205. </div>
  206. </el-collapse-item>
  207. </el-collapse>
  208. <div ref="messagesEl" class="messages">
  209. <div
  210. v-for="(m, i) in messages"
  211. :key="i"
  212. class="bubble-row"
  213. :class="m.role"
  214. >
  215. <div class="bubble">{{ m.content }}</div>
  216. </div>
  217. <div v-if="sending" class="bubble-row assistant">
  218. <div class="bubble typing">正在输入...</div>
  219. </div>
  220. </div>
  221. <div class="composer">
  222. <el-input
  223. v-model="inputText"
  224. type="textarea"
  225. :rows="3"
  226. :disabled="chatDisabled"
  227. placeholder="输入消息,Enter 发送,Shift+Enter 换行"
  228. @keydown="onKeydown"
  229. />
  230. <el-button type="primary" class="send" :loading="sending" :disabled="chatDisabled" @click="sendMessage">
  231. 发送
  232. </el-button>
  233. </div>
  234. </el-drawer>
  235. </template>
  236. <style scoped>
  237. .drawer-title {
  238. display: flex;
  239. align-items: center;
  240. gap: 8px;
  241. font-weight: 700;
  242. font-size: 1.05rem;
  243. color: var(--el-text-color-primary);
  244. }
  245. .settings-body {
  246. padding: 4px 0 8px;
  247. }
  248. .tip {
  249. font-size: 0.82rem;
  250. color: var(--el-text-color-secondary);
  251. line-height: 1.5;
  252. margin: 0 0 10px;
  253. }
  254. .proxy-alert {
  255. margin-bottom: 12px;
  256. }
  257. .link-row {
  258. display: flex;
  259. gap: 8px;
  260. margin-bottom: 10px;
  261. }
  262. .key-input {
  263. margin-bottom: 10px;
  264. }
  265. .btn-row {
  266. display: flex;
  267. gap: 8px;
  268. flex-wrap: wrap;
  269. }
  270. .key-ok {
  271. margin-top: 10px;
  272. font-size: 0.85rem;
  273. color: var(--el-color-primary);
  274. display: flex;
  275. align-items: center;
  276. gap: 6px;
  277. }
  278. .key-bad {
  279. margin-top: 8px;
  280. font-size: 0.85rem;
  281. color: #dc2626;
  282. }
  283. .messages {
  284. margin-top: 12px;
  285. padding: 12px;
  286. height: min(46vh, 420px);
  287. overflow-y: auto;
  288. background: var(--el-fill-color-light);
  289. border-radius: 12px;
  290. border: 1px solid var(--el-border-color-lighter);
  291. }
  292. .bubble-row {
  293. display: flex;
  294. margin-bottom: 10px;
  295. }
  296. .bubble-row.user {
  297. justify-content: flex-end;
  298. }
  299. .bubble-row.assistant {
  300. justify-content: flex-start;
  301. }
  302. .bubble {
  303. max-width: 88%;
  304. padding: 10px 12px;
  305. border-radius: 12px;
  306. font-size: 0.9rem;
  307. line-height: 1.5;
  308. white-space: pre-wrap;
  309. word-break: break-word;
  310. }
  311. .user .bubble {
  312. background: linear-gradient(
  313. 135deg,
  314. color-mix(in srgb, var(--el-color-primary) 88%, #0f172a),
  315. var(--el-color-primary)
  316. );
  317. color: #f8fafc;
  318. border-bottom-right-radius: 4px;
  319. }
  320. .assistant .bubble {
  321. background: var(--el-bg-color);
  322. color: var(--el-text-color-primary);
  323. border: 1px solid var(--el-border-color-lighter);
  324. border-bottom-left-radius: 4px;
  325. }
  326. .typing {
  327. font-style: italic;
  328. color: var(--el-text-color-secondary);
  329. }
  330. .composer {
  331. margin-top: 14px;
  332. display: flex;
  333. flex-direction: column;
  334. gap: 10px;
  335. }
  336. .send {
  337. align-self: flex-end;
  338. min-width: 100px;
  339. }
  340. </style>