1064 lines
31 KiB
HTML
1064 lines
31 KiB
HTML
<!-- 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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
}
|
||
|
||
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>
|