| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359 |
- <script setup>
- import { ref, computed, watch, nextTick } from 'vue'
- import { ElMessage } from 'element-plus'
- import { ChatDotRound, Key, Link, Delete } from '@element-plus/icons-vue'
- import { agentDrawerOpen } from '../../stores/uiShellStore'
- import {
- GITCC_API_BASE,
- GITCC_API_KEY_STORAGE,
- GITCC_MODEL_DEFAULT,
- } from '../../shellConstants'
- function setDrawerOpen(v) {
- agentDrawerOpen.value = v
- }
- const apiKeyInput = ref('')
- const keyStatus = ref('empty')
- const keyMasked = ref('')
- const messages = ref([])
- const inputText = ref('')
- const sending = ref(false)
- const messagesEl = ref(null)
- const settingsOpen = ref(['settings'])
- function maskKey(k) {
- if (!k || k.length < 6) return '******'
- return `${k.slice(0, 2)}******${k.slice(-2)}`
- }
- function loadStoredKey() {
- const k = localStorage.getItem(GITCC_API_KEY_STORAGE)
- if (k) {
- keyMasked.value = maskKey(k)
- keyStatus.value = 'ready'
- apiKeyInput.value = ''
- } else {
- keyMasked.value = ''
- keyStatus.value = 'empty'
- }
- }
- watch(agentDrawerOpen, (open) => {
- if (open) {
- loadStoredKey()
- if (messages.value.length === 0) {
- messages.value.push({
- role: 'assistant',
- content: '你好,我是智能助手。请先完成 API Key 配置,即可开始对话。',
- })
- }
- nextTick(scrollBottom)
- }
- })
- function scrollBottom() {
- const el = messagesEl.value
- if (!el) return
- el.scrollTop = el.scrollHeight
- }
- async function verifyAndSave() {
- const key = apiKeyInput.value.trim()
- if (!key) {
- ElMessage.warning('请先粘贴 API Key')
- return
- }
- try {
- const res = await fetch(`${GITCC_API_BASE}/models`, {
- method: 'GET',
- headers: { Authorization: `Bearer ${key}` },
- })
- if (!res.ok) {
- keyStatus.value = 'invalid'
- ElMessage.error(`验证失败(HTTP ${res.status}),请检查 Key 是否有效`)
- return
- }
- localStorage.setItem(GITCC_API_KEY_STORAGE, key)
- keyMasked.value = maskKey(key)
- keyStatus.value = 'ready'
- apiKeyInput.value = ''
- ElMessage.success('Key 已验证并保存到本机浏览器')
- } catch (e) {
- keyStatus.value = 'invalid'
- ElMessage.error('网络异常或跨域被拦截,请检查网络或联系管理员配置代理')
- console.error(e)
- }
- }
- function clearKey() {
- localStorage.removeItem(GITCC_API_KEY_STORAGE)
- keyStatus.value = 'empty'
- keyMasked.value = ''
- apiKeyInput.value = ''
- messages.value = [
- { role: 'assistant', content: '已清除本地 Key。重新配置后可继续对话。' },
- ]
- ElMessage.info('已清除本地存储的 Key')
- }
- function openGetKey() {
- window.open('https://api.gitcc.com/', '_blank', 'noopener,noreferrer')
- }
- function openCommunity() {
- window.open('https://www.gitcc.com/', '_blank', 'noopener,noreferrer')
- }
- function getStoredKey() {
- return localStorage.getItem(GITCC_API_KEY_STORAGE) || ''
- }
- async function sendMessage() {
- const text = inputText.value.trim()
- if (!text || sending.value) return
- const key = getStoredKey()
- if (!key) {
- ElMessage.warning('请先配置并验证 API Key')
- return
- }
- messages.value.push({ role: 'user', content: text })
- inputText.value = ''
- sending.value = true
- await nextTick()
- scrollBottom()
- try {
- const res = await fetch(`${GITCC_API_BASE}/chat/completions`, {
- method: 'POST',
- headers: {
- Authorization: `Bearer ${key}`,
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({
- model: GITCC_MODEL_DEFAULT,
- messages: [{ role: 'user', content: text }],
- temperature: 0.7,
- max_tokens: 256,
- }),
- })
- const data = await res.json().catch(() => ({}))
- if (!res.ok) {
- const msg = data?.error?.message || `请求失败(${res.status})`
- messages.value.push({ role: 'assistant', content: `调用失败:${msg}。请检查 Key 或稍后重试。` })
- return
- }
- const reply = data?.choices?.[0]?.message?.content || '(无内容返回)'
- messages.value.push({ role: 'assistant', content: reply })
- } catch (e) {
- messages.value.push({
- role: 'assistant',
- content: '网络错误:无法连接智能体服务。若浏览器拦截跨域请求,需由网关代理 GitCC API。',
- })
- console.error(e)
- } finally {
- sending.value = false
- await nextTick()
- scrollBottom()
- }
- }
- function onKeydown(e) {
- if (e.key === 'Enter' && !e.shiftKey) {
- e.preventDefault()
- sendMessage()
- }
- }
- const chatDisabled = computed(() => keyStatus.value !== 'ready' || sending.value)
- </script>
- <template>
- <el-drawer
- :model-value="agentDrawerOpen"
- append-to-body
- direction="rtl"
- size="min(520px, 100vw)"
- class="agent-drawer"
- :show-close="true"
- @update:model-value="setDrawerOpen"
- >
- <template #header>
- <div class="drawer-title">
- <el-icon><ChatDotRound /></el-icon>
- <span>智能体助手</span>
- </div>
- </template>
- <el-collapse v-model="settingsOpen">
- <el-collapse-item title="API Key 配置" name="settings">
- <div class="settings-body">
- <p class="tip">
- Key 仅保存在您本机浏览器的 localStorage,用于直接调用 GitCC 接口,不会发送到检澜业务后端。
- </p>
- <el-alert
- class="proxy-alert"
- type="info"
- :closable="false"
- show-icon
- title="直连与跨域"
- description="浏览器直连 api.gitcc.com 可能受 CORS 限制,且 Key 暴露于前端。生产环境建议由后端网关代理 GitCC OpenAI 兼容接口(/v1)。"
- />
- <div class="link-row">
- <el-button type="primary" link :icon="Link" @click="openGetKey">获取 Key</el-button>
- <el-button type="info" link @click="openCommunity">前往 GitCC 社区</el-button>
- </div>
- <el-input
- v-model="apiKeyInput"
- type="password"
- show-password
- placeholder="粘贴 GitCC API Key"
- class="key-input"
- />
- <div class="btn-row">
- <el-button type="primary" @click="verifyAndSave">验证并保存</el-button>
- <el-button v-if="keyStatus === 'ready'" @click="clearKey" :icon="Delete">清除 Key</el-button>
- </div>
- <div v-if="keyStatus === 'ready'" class="key-ok">
- <el-icon><Key /></el-icon>
- 已配置:{{ keyMasked }}
- </div>
- <div v-else-if="keyStatus === 'invalid'" class="key-bad">上次验证未通过,请检查 Key 后重试。</div>
- </div>
- </el-collapse-item>
- </el-collapse>
- <div ref="messagesEl" class="messages">
- <div
- v-for="(m, i) in messages"
- :key="i"
- class="bubble-row"
- :class="m.role"
- >
- <div class="bubble">{{ m.content }}</div>
- </div>
- <div v-if="sending" class="bubble-row assistant">
- <div class="bubble typing">正在输入...</div>
- </div>
- </div>
- <div class="composer">
- <el-input
- v-model="inputText"
- type="textarea"
- :rows="3"
- :disabled="chatDisabled"
- placeholder="输入消息,Enter 发送,Shift+Enter 换行"
- @keydown="onKeydown"
- />
- <el-button type="primary" class="send" :loading="sending" :disabled="chatDisabled" @click="sendMessage">
- 发送
- </el-button>
- </div>
- </el-drawer>
- </template>
- <style scoped>
- .drawer-title {
- display: flex;
- align-items: center;
- gap: 8px;
- font-weight: 700;
- font-size: 1.05rem;
- color: var(--el-text-color-primary);
- }
- .settings-body {
- padding: 4px 0 8px;
- }
- .tip {
- font-size: 0.82rem;
- color: var(--el-text-color-secondary);
- line-height: 1.5;
- margin: 0 0 10px;
- }
- .proxy-alert {
- margin-bottom: 12px;
- }
- .link-row {
- display: flex;
- gap: 8px;
- margin-bottom: 10px;
- }
- .key-input {
- margin-bottom: 10px;
- }
- .btn-row {
- display: flex;
- gap: 8px;
- flex-wrap: wrap;
- }
- .key-ok {
- margin-top: 10px;
- font-size: 0.85rem;
- color: var(--el-color-primary);
- display: flex;
- align-items: center;
- gap: 6px;
- }
- .key-bad {
- margin-top: 8px;
- font-size: 0.85rem;
- color: #dc2626;
- }
- .messages {
- margin-top: 12px;
- padding: 12px;
- height: min(46vh, 420px);
- overflow-y: auto;
- background: var(--el-fill-color-light);
- border-radius: 12px;
- border: 1px solid var(--el-border-color-lighter);
- }
- .bubble-row {
- display: flex;
- margin-bottom: 10px;
- }
- .bubble-row.user {
- justify-content: flex-end;
- }
- .bubble-row.assistant {
- justify-content: flex-start;
- }
- .bubble {
- max-width: 88%;
- padding: 10px 12px;
- border-radius: 12px;
- font-size: 0.9rem;
- line-height: 1.5;
- white-space: pre-wrap;
- word-break: break-word;
- }
- .user .bubble {
- background: linear-gradient(
- 135deg,
- color-mix(in srgb, var(--el-color-primary) 88%, #0f172a),
- var(--el-color-primary)
- );
- color: #f8fafc;
- border-bottom-right-radius: 4px;
- }
- .assistant .bubble {
- background: var(--el-bg-color);
- color: var(--el-text-color-primary);
- border: 1px solid var(--el-border-color-lighter);
- border-bottom-left-radius: 4px;
- }
- .typing {
- font-style: italic;
- color: var(--el-text-color-secondary);
- }
- .composer {
- margin-top: 14px;
- display: flex;
- flex-direction: column;
- gap: 10px;
- }
- .send {
- align-self: flex-end;
- min-width: 100px;
- }
- </style>
|