Files
skill-lib/frontend/archive/sgClaw验证-已归档/sgclaw-chat-standalone.html

1064 lines
31 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- This file is a local migration draft for superRpa sgclaw-chat page. Copy to superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/ when ready. -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>sgClaw Chat (SuperRPA迁移稿)</title>
<style>
:root {
--bg: #0f172a;
--bg-soft: rgba(15, 23, 42, 0.8);
--card: rgba(15, 23, 42, 0.64);
--panel: rgba(15, 23, 42, 0.92);
--line: rgba(148, 163, 184, 0.2);
--text: #e2e8f0;
--text-subtle: #94a3b8;
--accent: #38bdf8;
--accent-soft: rgba(56, 189, 248, 0.15);
--ok: #22c55e;
--warn: #f59e0b;
--bad: #ef4444;
--chip: rgba(56, 189, 248, 0.14);
--shadow: 0 20px 60px rgba(3, 7, 18, 0.35);
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 100%;
min-height: 100%;
}
body {
font-family: 'Inter', 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
color: var(--text);
background:
radial-gradient(circle at 20% 0%, rgba(56, 189, 248, 0.24), transparent 35%),
radial-gradient(circle at 80% 15%, rgba(168, 85, 247, 0.16), transparent 30%),
linear-gradient(140deg, #020617, #0f172a 55%, #111827 100%);
min-height: 100vh;
padding: 20px;
line-height: 1.45;
}
.page {
width: min(1280px, 100%);
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 16px;
min-height: calc(100vh - 40px);
}
.top-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 18px;
border: 1px solid var(--line);
border-radius: 14px;
background: var(--panel);
backdrop-filter: blur(18px);
box-shadow: var(--shadow);
position: sticky;
top: 0;
z-index: 10;
}
.title {
margin: 0;
font-size: 20px;
font-weight: 700;
letter-spacing: 0.02em;
display: flex;
align-items: center;
gap: 10px;
}
.status-chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 4px 10px;
border-radius: 999px;
border: 1px solid var(--line);
background: var(--accent-soft);
font-size: 12px;
color: #bfdbfe;
white-space: nowrap;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: var(--ok);
display: inline-block;
}
.layout {
display: grid;
grid-template-columns: 280px 1fr;
gap: 16px;
min-height: 0;
flex: 1;
}
.side {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 14px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
box-shadow: var(--shadow);
}
.side h2 {
margin: 0;
font-size: 16px;
letter-spacing: 0.02em;
}
.note {
color: var(--text-subtle);
font-size: 13px;
margin: 0;
line-height: 1.55;
}
.control {
display: flex;
flex-direction: column;
gap: 6px;
}
.control label {
font-size: 12px;
color: #cbd5e1;
}
.control input,
.control select,
.control textarea {
width: 100%;
border-radius: 10px;
border: 1px solid var(--line);
background: rgba(15, 23, 42, 0.78);
color: var(--text);
padding: 10px 12px;
font-size: 13px;
outline: none;
transition: 0.2s border-color, 0.2s box-shadow;
font-family: inherit;
}
.control input::placeholder,
.control textarea::placeholder {
color: #64748b;
}
.control input:focus,
.control select:focus,
.control textarea:focus {
border-color: #60a5fa;
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.18);
}
.quick-grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.chip {
border: 1px solid var(--line);
border-radius: 999px;
background: var(--chip);
color: #e2e8f0;
padding: 6px 10px;
font-size: 12px;
cursor: pointer;
transition: 0.18s background;
white-space: nowrap;
}
.chip:hover {
background: rgba(56, 189, 248, 0.26);
}
.chat-card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 14px;
display: flex;
flex-direction: column;
min-height: 0;
min-width: 0;
box-shadow: var(--shadow);
overflow: hidden;
}
.chat-toolbar {
border-bottom: 1px solid var(--line);
padding: 14px 16px;
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.toolbar-btn {
border: 0;
background: #0ea5e9;
color: #f8fafc;
font-weight: 600;
border-radius: 10px;
padding: 8px 12px;
font-size: 12px;
cursor: pointer;
transition: 0.2s transform, 0.2s opacity;
}
.toolbar-btn:hover { opacity: 0.92; transform: translateY(-1px); }
.toolbar-btn.secondary {
background: transparent;
color: #dbeafe;
border: 1px solid var(--line);
}
.toolbar-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.status-line {
margin-left: auto;
font-size: 12px;
color: var(--text-subtle);
display: inline-flex;
align-items: center;
gap: 6px;
}
.messages {
flex: 1;
min-height: 420px;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
scroll-behavior: smooth;
}
.empty-state {
border: 1px dashed var(--line);
border-radius: 12px;
color: #cbd5e1;
padding: 24px;
text-align: center;
background: rgba(15, 23, 42, 0.56);
}
.bubble {
max-width: min(75%, 760px);
border-radius: 14px;
padding: 12px 14px;
box-shadow: 0 8px 20px rgba(2, 6, 23, 0.25);
border: 1px solid transparent;
white-space: pre-wrap;
word-break: break-word;
position: relative;
animation: fadeIn 0.2s ease;
}
.bubble.user {
align-self: flex-end;
background: linear-gradient(145deg, rgba(14, 165, 233, 0.2), rgba(14, 165, 233, 0.08));
border-color: rgba(56, 189, 248, 0.35);
}
.bubble.assistant {
align-self: flex-start;
background: rgba(30, 41, 59, 0.82);
border-color: rgba(148, 163, 184, 0.28);
}
.bubble.error {
background: rgba(127, 29, 29, 0.35);
border-color: rgba(239, 68, 68, 0.4);
color: #fecaca;
}
.bubble .meta {
display: flex;
justify-content: space-between;
gap: 10px;
align-items: center;
margin-bottom: 6px;
font-size: 11px;
color: #94a3b8;
}
.role-tag {
font-weight: 700;
font-size: 11px;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid var(--line);
color: #cbd5e1;
background: rgba(15, 23, 42, 0.75);
}
.composer {
padding: 12px;
border-top: 1px solid var(--line);
background: rgba(15, 23, 42, 0.9);
}
.input-line {
display: grid;
grid-template-columns: 1fr auto;
gap: 10px;
align-items: end;
}
textarea {
width: 100%;
resize: none;
min-height: 52px;
max-height: 150px;
border: 1px solid var(--line);
border-radius: 12px;
padding: 10px 12px;
background: rgba(15, 23, 42, 0.84);
color: var(--text);
outline: none;
font-family: inherit;
font-size: 14px;
line-height: 1.5;
}
.input-line .toolbar-btn {
height: 52px;
width: 88px;
}
.error-tip {
margin-top: 8px;
color: #fca5a5;
font-size: 12px;
min-height: 18px;
}
.tip {
margin: 0;
color: #94a3b8;
font-size: 12px;
}
.copy-btn {
position: absolute;
right: 8px;
top: 8px;
border: 0;
background: transparent;
color: #9ca3af;
font-size: 12px;
cursor: pointer;
}
.copy-btn:hover {
color: #dbeafe;
}
@keyframes fadeIn {
from { transform: translateY(6px); opacity: 0.7; }
to { transform: translateY(0); opacity: 1; }
}
.typing-dot {
width: 8px;
aspect-ratio: 1;
border-radius: 50%;
background: #f8fafc;
display: inline-block;
animation: bounce 1s infinite;
}
.typing-dot:nth-child(2) { animation-delay: 0.15s; }
.typing-dot:nth-child(3) { animation-delay: 0.3s; }
@keyframes bounce {
0%, 80%, 100% {
transform: translateY(0);
}
40% {
transform: translateY(-4px);
}
}
@media (max-width: 1100px) {
.layout {
grid-template-columns: 1fr;
}
.side {
order: 2;
}
.chat-card {
min-height: 72vh;
}
.bubble {
max-width: 100%;
}
}
@media (max-width: 680px) {
body {
padding: 10px;
}
.top-bar {
flex-direction: column;
align-items: flex-start;
}
.chat-toolbar {
flex-wrap: wrap;
}
.input-line {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="page" id="app">
<header class="top-bar">
<h1 class="title">SG <span style="color:#38bdf8">Claw</span> Chat</h1>
<div class="status-chip">
<span class="status-dot"></span>
<span id="statusText">就绪</span>
</div>
</header>
<div class="layout">
<aside class="side">
<section>
<h2>连接配置</h2>
<p class="note">支持 OpenAI、Claude 与本地 mock 模式;未配置 key 时自动回退到本地模拟回复。</p>
</section>
<div class="control">
<label for="provider">服务方</label>
<select id="provider">
<option value="openai">OpenAI</option>
<option value="claude">Claude</option>
<option value="mock">本地 Mock</option>
</select>
</div>
<div class="control">
<label for="endpoint">接口地址</label>
<input id="endpoint" type="text" placeholder="https://api.openai.com/v1/chat/completions" />
</div>
<div class="control">
<label for="model">模型</label>
<input id="model" type="text" placeholder="gpt-4o-mini" />
</div>
<div class="control" id="apiKeyWrap">
<label for="apiKey">API Key</label>
<input id="apiKey" type="password" placeholder="仅本页使用,不会上传" />
</div>
<section>
<h2>快捷问题</h2>
<div class="quick-grid">
<button class="chip" data-prompt="请帮我总结这段对话的关键结论">总结结论</button>
<button class="chip" data-prompt="给我一个适合 sgClaw 的自动化验证思路">验证思路</button>
<button class="chip" data-prompt="把这件事情分解成 5 步执行计划">分解任务</button>
</div>
</section>
<section>
<h2>帮助</h2>
<p class="note">输入消息后回车直接发送Shift+Enter 换行。历史记录会保存在当前浏览器本地。</p>
<button class="toolbar-btn secondary" id="exportBtn" type="button">导出对话</button>
<button class="toolbar-btn secondary" id="clearBtn" type="button">清空会话</button>
<p class="tip" id="historyCount">已保存 0 条消息</p>
</section>
</aside>
<section class="chat-card">
<div class="chat-toolbar">
<button class="toolbar-btn" id="sendBtn" type="button">开始对话</button>
<div class="status-line" id="providerHint">当前OpenAI · gpt-4o-mini</div>
</div>
<div class="messages" id="messages"></div>
<div class="composer">
<div class="input-line">
<textarea id="userInput" rows="2" placeholder="输入你的问题,如:请帮我分析 sgClaw 的对话流程"></textarea>
<button class="toolbar-btn" id="sendMsgBtn" type="button">发送</button>
</div>
<p class="error-tip" id="errorTip"></p>
<p class="tip">注意:聊天数据仅保存在本地浏览器,不会上传到服务器。</p>
</div>
</section>
</div>
</div>
<script>
;(function () {
var state = {
provider: 'openai',
model: 'gpt-4o-mini',
endpoint: 'https://api.openai.com/v1/chat/completions',
apiKey: '',
openai: {
model: 'gpt-4o-mini',
endpoint: 'https://api.openai.com/v1/chat/completions',
key: ''
},
claude: {
model: 'claude-3-5-sonnet-20240620',
endpoint: 'https://api.anthropic.com/v1/messages',
key: ''
},
messages: [
{
role: 'assistant',
content: '你好,我是 sgClaw 聊天界面。你可以直接切换到 OpenAI/Claude 或使用 mock 模式进行体验。',
time: Date.now(),
status: 'done'
}
],
busy: false,
providers: {
openai: { label: 'OpenAI', hint: 'OpenAI API', requiresKey: true },
claude: { label: 'Claude', hint: 'Anthropic Claude API', requiresKey: true },
mock: { label: '本地 Mock', hint: '离线模拟回复', requiresKey: false }
}
}
var els = {
provider: document.getElementById('provider'),
endpoint: document.getElementById('endpoint'),
model: document.getElementById('model'),
apiKey: document.getElementById('apiKey'),
apiKeyWrap: document.getElementById('apiKeyWrap'),
providerHint: document.getElementById('providerHint'),
statusText: document.getElementById('statusText'),
messages: document.getElementById('messages'),
userInput: document.getElementById('userInput'),
sendMsgBtn: document.getElementById('sendMsgBtn'),
sendBtn: document.getElementById('sendBtn'),
exportBtn: document.getElementById('exportBtn'),
clearBtn: document.getElementById('clearBtn'),
historyCount: document.getElementById('historyCount'),
errorTip: document.getElementById('errorTip')
}
var CONFIG_KEY = 'sgclaw-chat-ui-v1'
var STORAGE_KEY = 'sgclaw-chat-messages-v1'
var providerDefaults = {
openai: {
endpoint: 'https://api.openai.com/v1/chat/completions',
model: 'gpt-4o-mini',
key: ''
},
claude: {
endpoint: 'https://api.anthropic.com/v1/messages',
model: 'claude-3-5-sonnet-20240620',
key: ''
},
mock: {
endpoint: '',
model: 'mock-local',
key: ''
}
}
function loadPersistedState() {
try {
var rawConfig = localStorage.getItem(CONFIG_KEY)
if (rawConfig) {
var cfg = JSON.parse(rawConfig)
if (cfg.provider) state.provider = cfg.provider
if (cfg.messagesMax) state.messagesMax = cfg.messagesMax
if (cfg.openai) state.openai = Object.assign(state.openai, cfg.openai)
if (cfg.claude) state.claude = Object.assign(state.claude, cfg.claude)
}
var rawMessages = localStorage.getItem(STORAGE_KEY)
if (rawMessages) {
var list = JSON.parse(rawMessages)
if (Array.isArray(list) && list.length) {
state.messages = list.slice(-80).map(function (item) {
if (!item || !item.role || typeof item.content !== 'string') return null
item.time = item.time || Date.now()
item.status = item.status || 'done'
return item
}).filter(Boolean)
}
}
} catch (_e) {
// ignore legacy storage errors
}
state.openai.key = window.__SGCLAW_TEST_OPENAI_KEY__ || state.openai.key || ''
state.claude.key = window.__SGCLAW_TEST_CLAUDE_KEY__ || state.claude.key || ''
state.model = providerDefaults[state.provider].model
state.endpoint = providerDefaults[state.provider].endpoint
state.apiKey = state[state.provider] ? state[state.provider].key : ''
}
function saveConfig() {
var cfg = {
provider: state.provider,
openai: state.openai,
claude: state.claude
}
localStorage.setItem(CONFIG_KEY, JSON.stringify(cfg))
}
function saveMessages() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state.messages))
els.historyCount.textContent = '已保存 ' + state.messages.length + ' 条消息'
}
function formatTime(ts) {
var d = new Date(ts)
var hh = String(d.getHours()).padStart(2, '0')
var mm = String(d.getMinutes()).padStart(2, '0')
return hh + ':' + mm
}
function buildHistory() {
return state.messages
.slice(-20)
.map(function (msg) {
return { role: msg.role, content: msg.content }
})
}
function sanitize(text) {
return (text || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
}
function render() {
while (els.messages.firstChild) {
els.messages.removeChild(els.messages.firstChild)
}
if (!state.messages.length) {
var emptyEl = document.createElement('div')
emptyEl.className = 'empty-state'
emptyEl.textContent = '尚无历史消息,发一条开始吧。'
els.messages.appendChild(emptyEl)
} else {
state.messages.forEach(function (msg, idx) {
var row = document.createElement('article')
row.className = 'bubble ' + msg.role
if (msg.status === 'error') row.classList.add('error')
var meta = document.createElement('div')
meta.className = 'meta'
var roleTag = document.createElement('span')
roleTag.className = 'role-tag'
roleTag.textContent = msg.role === 'user' ? '我' : 'sgClaw'
var helper = document.createElement('span')
helper.textContent = formatTime(msg.time || Date.now())
meta.appendChild(roleTag)
meta.appendChild(helper)
row.appendChild(meta)
var content = document.createElement('div')
content.style.whiteSpace = 'pre-wrap'
content.style.wordBreak = 'break-word'
if (msg.status === 'thinking' && msg.role === 'assistant') {
var dots = document.createElement('span')
dots.className = 'typing-dot'
var dots2 = document.createElement('span')
dots2.className = 'typing-dot'
var dots3 = document.createElement('span')
dots3.className = 'typing-dot'
row.appendChild(dots)
row.appendChild(dots2)
row.appendChild(dots3)
} else {
content.innerHTML = sanitize(msg.content)
row.appendChild(content)
}
if (msg.role === 'assistant' && msg.content) {
var copyBtn = document.createElement('button')
copyBtn.className = 'copy-btn'
copyBtn.type = 'button'
copyBtn.textContent = '复制'
copyBtn.dataset.index = String(idx)
copyBtn.addEventListener('click', function (event) {
var i = Number(event.target.dataset.index)
var target = state.messages[i] && state.messages[i].content
if (!target) return
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(target)
}
})
row.appendChild(copyBtn)
}
if (msg.role === 'user') {
row.style.alignSelf = 'flex-end'
}
els.messages.appendChild(row)
})
}
var firstUser = state.messages.filter(function (msg) { return msg.role === 'user' }).length
els.historyCount.textContent = '已保存 ' + state.messages.length + ' 条消息,用户提问 ' + firstUser + ' 条'
setTimeout(function () {
els.messages.scrollTop = els.messages.scrollHeight
}, 0)
}
function updateForm() {
var defaults = providerDefaults[state.provider]
state.model = defaults.model
state.endpoint = defaults.endpoint
if (state.provider === 'openai') {
state.apiKey = state.openai.key
state.model = state.openai.model || defaults.model
state.endpoint = state.openai.endpoint || defaults.endpoint
} else if (state.provider === 'claude') {
state.apiKey = state.claude.key
state.model = state.claude.model || defaults.model
state.endpoint = state.claude.endpoint || defaults.endpoint
}
els.provider.value = state.provider
els.endpoint.value = state.endpoint
els.model.value = state.model
els.apiKey.value = state.apiKey
var current = state.providers[state.provider]
els.apiKeyWrap.style.display = current.requiresKey ? 'flex' : 'none'
els.providerHint.textContent = '当前:' + current.label + ' · ' + state.model
if (state.provider === 'mock') {
els.statusText.textContent = '离线 mock 模式'
} else if (els.apiKey.value) {
els.statusText.textContent = '已配置 ' + current.label
} else {
els.statusText.textContent = '未配置 ' + current.label + ' Key发送时将自动回退到 Mock'
}
saveConfig()
}
function setBusy(isBusy) {
state.busy = isBusy
els.sendMsgBtn.disabled = isBusy
els.sendBtn.disabled = isBusy
els.userInput.disabled = isBusy
els.statusText.textContent = isBusy ? '正在响应...' : '就绪'
}
function setError(message) {
els.errorTip.textContent = message || ''
}
function validateProvider() {
if (state.provider === 'mock') return true
if (!state.apiKey) {
setError('未配置 ' + (state.provider === 'openai' ? 'OpenAI' : 'Claude') + ' API Key使用 mock 回复')
return false
}
if (!state.endpoint) {
setError('请先填写接口地址')
return false
}
if (!state.model) {
setError('请先填写模型名称')
return false
}
return true
}
function normalizeText(text) {
return (text || '').replace(/\r\n/g, '\n').trim()
}
function fallbackMockReply(prompt) {
var hints = {
openai: '已收到你的问题,当前在 mock 环境中使用的是演示回复。',
claude: '我已理解你的请求,当前 mock 模式返回的是占位说明。',
mock: '这是本地模拟回复。可在左侧切换到 OpenAI 或 Claude 获取真实模型结果。'
}
return (hints[state.provider] || '收到。') + '\n\n原文' + prompt
}
function callOpenAI(prompt) {
var body = {
model: state.model,
temperature: 0.35,
messages: buildHistory().concat([{ role: 'user', content: prompt }])
}
return fetch(state.endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer ' + state.apiKey
},
body: JSON.stringify(body)
}).then(function (resp) {
if (!resp.ok) {
return resp.text().then(function (text) {
throw new Error('OpenAI 响应异常: ' + resp.status + ' ' + text.slice(0, 120))
})
}
return resp.json()
}).then(function (data) {
if (!data.choices || !data.choices[0] || !data.choices[0].message) {
throw new Error('OpenAI 返回结构异常')
}
return data.choices[0].message.content || '(无文本返回)'
})
}
function callClaude(prompt) {
var messages = buildHistory().map(function (msg) {
return {
role: msg.role === 'assistant' ? 'assistant' : 'user',
content: msg.content
}
})
return fetch(state.endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': state.apiKey,
'anthropic-version': '2023-06-01'
},
body: JSON.stringify({
model: state.model,
max_tokens: 1024,
messages: messages.concat([{ role: 'user', content: prompt }]),
system: '你是 SGClaw 侧边界面内置助手,提供简洁、准确、可执行的技术建议。'
})
}).then(function (resp) {
if (!resp.ok) {
return resp.text().then(function (text) {
throw new Error('Claude 响应异常: ' + resp.status + ' ' + text.slice(0, 120))
})
}
return resp.json()
}).then(function (data) {
if (!data.content || !Array.isArray(data.content) || data.content.length === 0) {
throw new Error('Claude 返回结构异常')
}
return data.content.map(function (block) {
return block.type === 'text' ? (block.text || '') : ('[' + block.type + ']')
}).join('\n')
})
}
function postMessage(prompt) {
if (!validateProvider()) {
return Promise.resolve(fallbackMockReply(prompt))
}
setError('')
if (state.provider === 'openai') return callOpenAI(prompt)
if (state.provider === 'claude') return callClaude(prompt)
return Promise.resolve(fallbackMockReply(prompt))
}
function sendMessage() {
var prompt = normalizeText(els.userInput.value)
if (!prompt || state.busy) return
var userMsg = {
role: 'user',
content: prompt,
time: Date.now(),
status: 'done'
}
state.messages.push(userMsg)
els.userInput.value = ''
els.userInput.style.height = 'auto'
var assistantMsg = {
role: 'assistant',
content: '',
time: Date.now(),
status: 'thinking'
}
state.messages.push(assistantMsg)
var assistantIndex = state.messages.length - 1
render()
setBusy(true)
return postMessage(prompt)
.then(function (reply) {
if (reply === null) {
return
}
state.messages[assistantIndex].content = reply
state.messages[assistantIndex].status = 'done'
state.messages[assistantIndex].time = Date.now()
})
.catch(function (err) {
state.messages[assistantIndex].content = '对话失败:' + (err && err.message ? err.message : '未知错误')
state.messages[assistantIndex].status = 'error'
state.messages[assistantIndex].time = Date.now()
})
.finally(function () {
setBusy(false)
render()
saveMessages()
if (state.messages.length > 200) {
state.messages = state.messages.slice(-200)
}
})
}
function exportChat() {
var payload = {
provider: state.provider,
model: state.model,
endpoint: state.endpoint,
exportedAt: new Date().toISOString(),
messages: state.messages
}
var blob = new Blob([JSON.stringify(payload, null, 2)], {
type: 'application/json'
})
var link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = 'sgclaw-chat-' + new Date().toISOString().replace(/[:.]/g, '-') + '.json'
link.click()
URL.revokeObjectURL(link.href)
}
function clearChat() {
if (!window.confirm('确定清空当前聊天记录吗?')) return
state.messages = [
{
role: 'assistant',
content: '记录已清空。请继续输入你的新问题。',
time: Date.now(),
status: 'done'
}
]
saveMessages()
render()
}
function bindEvents() {
els.provider.addEventListener('change', function (e) {
state.provider = e.target.value
updateForm()
})
els.endpoint.addEventListener('change', function (e) {
if (state.provider === 'openai') {
state.openai.endpoint = e.target.value
} else if (state.provider === 'claude') {
state.claude.endpoint = e.target.value
}
state.endpoint = e.target.value
saveConfig()
})
els.model.addEventListener('change', function (e) {
if (state.provider === 'openai') {
state.openai.model = e.target.value
} else if (state.provider === 'claude') {
state.claude.model = e.target.value
}
state.model = e.target.value
updateForm()
})
els.apiKey.addEventListener('change', function (e) {
if (state.provider === 'openai') {
state.openai.key = e.target.value.trim()
} else if (state.provider === 'claude') {
state.claude.key = e.target.value.trim()
}
state.apiKey = e.target.value.trim()
saveConfig()
setError('')
updateForm()
})
els.sendMsgBtn.addEventListener('click', sendMessage)
els.sendBtn.addEventListener('click', function () {
els.userInput.focus()
})
els.exportBtn.addEventListener('click', exportChat)
els.clearBtn.addEventListener('click', clearChat)
els.userInput.addEventListener('input', function () {
setError('')
els.userInput.style.height = 'auto'
els.userInput.style.height = Math.min(150, els.userInput.scrollHeight) + 'px'
})
els.userInput.addEventListener('keydown', function (e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
sendMessage()
}
})
var quickButtons = document.querySelectorAll('.quick-grid .chip')
quickButtons.forEach(function (btn) {
btn.addEventListener('click', function () {
if (state.busy) return
els.userInput.value = btn.dataset.prompt
els.userInput.focus()
sendMessage()
})
})
}
loadPersistedState()
bindEvents()
updateForm()
render()
els.userInput.focus()
})()
</script>
</body>
</html>