1141 lines
32 KiB
TypeScript
1141 lines
32 KiB
TypeScript
// Copyright 2026 The Chromium Authors
|
||
// Use of this source code is governed by a BSD-style license that can be
|
||
// found in the LICENSE file.
|
||
|
||
import {css, html, LitElement} from 'chrome://resources/lit/v3_0/lit.rollup.js';
|
||
|
||
type Provider = 'openai'|'claude'|'mock';
|
||
type MessageRole = 'user'|'assistant';
|
||
type MessageStatus = 'done'|'thinking'|'error';
|
||
|
||
interface ChatMessage {
|
||
role: MessageRole;
|
||
content: string;
|
||
time: number;
|
||
status: MessageStatus;
|
||
}
|
||
|
||
interface ProviderSetting {
|
||
model: string;
|
||
endpoint: string;
|
||
key: string;
|
||
}
|
||
|
||
interface PersistedConfig {
|
||
provider?: Provider;
|
||
openai?: Partial<ProviderSetting>;
|
||
claude?: Partial<ProviderSetting>;
|
||
}
|
||
|
||
const CONFIG_KEY = 'sgclaw-chat-ui-v1';
|
||
const STORAGE_KEY = 'sgclaw-chat-messages-v1';
|
||
|
||
const PROVIDER_DEFAULTS: Record<Provider, Omit<ProviderSetting, 'key'>> = {
|
||
openai: {
|
||
endpoint: 'https://api.openai.com/v1/chat/completions',
|
||
model: 'gpt-4o-mini',
|
||
},
|
||
claude: {
|
||
endpoint: 'https://api.anthropic.com/v1/messages',
|
||
model: 'claude-3-5-sonnet-20240620',
|
||
},
|
||
mock: {
|
||
endpoint: '',
|
||
model: 'mock-local',
|
||
},
|
||
};
|
||
|
||
const MAX_SAVED_MESSAGES = 200;
|
||
|
||
declare global {
|
||
interface Window {
|
||
__SGCLAW_TEST_OPENAI_KEY__?: string;
|
||
__SGCLAW_TEST_CLAUDE_KEY__?: string;
|
||
}
|
||
|
||
interface HTMLElementTagNameMap {
|
||
'sgclaw-chat-function-app': SgClawChatFunctionApp;
|
||
}
|
||
}
|
||
|
||
function normalizeText(text: string): string {
|
||
return (text || '').replace(/\r\n/g, '\n').trim();
|
||
}
|
||
|
||
function clampHeight(rawHeight: number): string {
|
||
return `${Math.min(150, Math.max(52, rawHeight))}px`;
|
||
}
|
||
|
||
function formatTime(ts: number): string {
|
||
const d = new Date(ts);
|
||
const hh = String(d.getHours()).padStart(2, '0');
|
||
const mm = String(d.getMinutes()).padStart(2, '0');
|
||
return `${hh}:${mm}`;
|
||
}
|
||
|
||
function fallbackMockReply(provider: Provider, prompt: string): string {
|
||
const hints: Record<Exclude<Provider, undefined>, string> = {
|
||
openai: '已收到你的问题,当前在 mock 环境中使用的是演示回复。',
|
||
claude: '我已理解你的请求,当前 mock 模式返回的是占位说明。',
|
||
mock: '这是本地模拟回复。可在左侧切换到 OpenAI 或 Claude 获取真实模型结果。',
|
||
};
|
||
|
||
return `${hints[provider] || '收到。'}\n\n原文:${prompt}`;
|
||
}
|
||
|
||
export class SgClawChatFunctionApp extends LitElement {
|
||
static get is() {
|
||
return 'sgclaw-chat-function-app';
|
||
}
|
||
|
||
static override get styles() {
|
||
return css`
|
||
:host {
|
||
display: block;
|
||
--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);
|
||
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: 100%;
|
||
padding: 20px;
|
||
font-family: 'Inter', 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||
}
|
||
|
||
:host * { box-sizing: border-box; }
|
||
|
||
.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;
|
||
}
|
||
|
||
.typing {
|
||
display: inline-flex;
|
||
gap: 6px;
|
||
}
|
||
|
||
.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 fadeIn {
|
||
from { transform: translateY(6px); opacity: 0.7; }
|
||
to { transform: translateY(0); opacity: 1; }
|
||
}
|
||
|
||
@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) {
|
||
:host {
|
||
padding: 10px;
|
||
}
|
||
|
||
.top-bar {
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.chat-toolbar {
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.input-line {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
`;
|
||
}
|
||
|
||
static override get properties() {
|
||
return {
|
||
provider: {type: String},
|
||
endpoint: {type: String},
|
||
model: {type: String},
|
||
apiKey: {type: String},
|
||
messages: {type: Array},
|
||
userInput: {type: String},
|
||
busy: {type: Boolean},
|
||
errorTip: {type: String},
|
||
statusText: {type: String},
|
||
};
|
||
}
|
||
|
||
protected accessor provider: Provider = 'openai';
|
||
protected accessor endpoint: string = PROVIDER_DEFAULTS.openai.endpoint;
|
||
protected accessor model: string = PROVIDER_DEFAULTS.openai.model;
|
||
protected accessor apiKey: string = '';
|
||
protected accessor messages: ChatMessage[] = [
|
||
{
|
||
role: 'assistant',
|
||
content: '你好,我是 sgClaw 聊天界面。你可以直接切换到 OpenAI/Claude 或使用 mock 模式进行体验。',
|
||
time: Date.now(),
|
||
status: 'done',
|
||
},
|
||
];
|
||
|
||
protected accessor userInput = '';
|
||
protected accessor busy = false;
|
||
protected accessor errorTip = '';
|
||
protected accessor statusText = '就绪';
|
||
|
||
private openai: ProviderSetting = {
|
||
model: PROVIDER_DEFAULTS.openai.model,
|
||
endpoint: PROVIDER_DEFAULTS.openai.endpoint,
|
||
key: '',
|
||
};
|
||
|
||
private claude: ProviderSetting = {
|
||
model: PROVIDER_DEFAULTS.claude.model,
|
||
endpoint: PROVIDER_DEFAULTS.claude.endpoint,
|
||
key: '',
|
||
};
|
||
|
||
override connectedCallback(): void {
|
||
super.connectedCallback();
|
||
this.loadPersistedState_();
|
||
this.statusText = this.getStatusText_();
|
||
}
|
||
|
||
override render() {
|
||
return html`
|
||
<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>${this.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" .value="${this.provider}" @change="${this.onProviderChange_}">
|
||
<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"
|
||
.value="${this.endpoint}"
|
||
@change="${this.onEndpointChange_}"
|
||
>
|
||
</div>
|
||
|
||
<div class="control">
|
||
<label for="model">模型</label>
|
||
<input
|
||
id="model"
|
||
type="text"
|
||
placeholder="gpt-4o-mini"
|
||
.value="${this.model}"
|
||
@change="${this.onModelChange_}"
|
||
>
|
||
</div>
|
||
|
||
<div class="control" id="apiKeyWrap">
|
||
<label for="apiKey">API Key</label>
|
||
<input
|
||
id="apiKey"
|
||
type="password"
|
||
placeholder="仅本页使用,不会上传"
|
||
.value="${this.apiKey}"
|
||
@change="${this.onApiKeyChange_}"
|
||
>
|
||
</div>
|
||
|
||
<section>
|
||
<h2>快捷问题</h2>
|
||
<div class="quick-grid">
|
||
<button class="chip" data-prompt="请帮我总结这段对话的关键结论" @click="${this.onQuickPrompt_}">总结结论</button>
|
||
<button class="chip" data-prompt="给我一个适合 sgClaw 的自动化验证思路" @click="${this.onQuickPrompt_}">验证思路</button>
|
||
<button class="chip" data-prompt="把这件事情分解成 5 步执行计划" @click="${this.onQuickPrompt_}">分解任务</button>
|
||
</div>
|
||
</section>
|
||
|
||
<section>
|
||
<h2>帮助</h2>
|
||
<p class="note">输入消息后回车直接发送,Shift+Enter 换行。历史记录会保存在当前浏览器本地。</p>
|
||
<button class="toolbar-btn secondary" @click="${this.onExportChat_}" type="button">导出对话</button>
|
||
<button class="toolbar-btn secondary" @click="${this.onClearChat_}" type="button">清空会话</button>
|
||
<p class="tip">已保存 ${this.messages.length} 条消息,用户提问 ${this.messages.filter((m) => m.role === 'user').length} 条</p>
|
||
</section>
|
||
</aside>
|
||
|
||
<section class="chat-card">
|
||
<div class="chat-toolbar">
|
||
<button class="toolbar-btn" @click="${this.onFocusInput_}" type="button">开始对话</button>
|
||
<div class="status-line">当前:${this.provider === 'openai' ? 'OpenAI' : this.provider === 'claude' ? 'Claude' : '本地 Mock'} · ${this.model}</div>
|
||
</div>
|
||
|
||
<div class="messages" id="messages" @keydown="${this.onContainerKeyDown_}">
|
||
${this.messages.length === 0 ? html`
|
||
<div class="empty-state">尚无历史消息,发一条开始吧。</div>
|
||
` : html`
|
||
${this.messages.map((msg, idx) => this.renderMessage_(msg, idx))}
|
||
`}
|
||
</div>
|
||
|
||
<div class="composer">
|
||
<div class="input-line">
|
||
<textarea
|
||
id="userInput"
|
||
rows="2"
|
||
placeholder="输入你的问题,如:请帮我分析 sgClaw 的对话流程"
|
||
.value="${this.userInput}"
|
||
?disabled="${this.busy}"
|
||
@input="${this.onUserInputInput_}"
|
||
@keydown="${this.onUserInputKeyDown_}"
|
||
></textarea>
|
||
<button class="toolbar-btn" type="button" @click="${this.onSendMessage_}" ?disabled="${this.busy}">发送</button>
|
||
</div>
|
||
<p class="error-tip">${this.errorTip}</p>
|
||
<p class="tip">注意:聊天数据仅保存在本地浏览器,不会上传到服务器。</p>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
private renderMessage_(msg: ChatMessage, idx: number) {
|
||
return html`
|
||
<article class="bubble ${msg.role} ${msg.status === 'error' ? 'error' : ''}">
|
||
<div class="meta">
|
||
<span class="role-tag">${msg.role === 'user' ? '我' : 'sgClaw'}</span>
|
||
<span>${formatTime(msg.time || Date.now())}</span>
|
||
</div>
|
||
${msg.status === 'thinking' && msg.role === 'assistant'
|
||
? html`
|
||
<div class="typing" aria-label="助手正在输入">
|
||
<span class="typing-dot"></span>
|
||
<span class="typing-dot"></span>
|
||
<span class="typing-dot"></span>
|
||
</div>
|
||
`
|
||
: html`<div>${msg.content}</div>`
|
||
}
|
||
${msg.role === 'assistant' && msg.content ? html`
|
||
<button class="copy-btn" type="button" @click="${() => this.onCopyMessage_(idx)}">复制</button>
|
||
` : ''}
|
||
</article>
|
||
`;
|
||
}
|
||
|
||
private saveConfig_(): void {
|
||
const cfg: PersistedConfig = {
|
||
provider: this.provider,
|
||
openai: this.openai,
|
||
claude: this.claude,
|
||
};
|
||
localStorage.setItem(CONFIG_KEY, JSON.stringify(cfg));
|
||
}
|
||
|
||
private saveMessages_(): void {
|
||
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.messages));
|
||
}
|
||
|
||
private loadPersistedState_(): void {
|
||
try {
|
||
const rawConfig = localStorage.getItem(CONFIG_KEY);
|
||
if (rawConfig) {
|
||
const cfg = JSON.parse(rawConfig) as PersistedConfig;
|
||
if (cfg.provider) {
|
||
this.provider = cfg.provider;
|
||
}
|
||
if (cfg.openai) {
|
||
this.openai = {...this.openai, ...cfg.openai};
|
||
}
|
||
if (cfg.claude) {
|
||
this.claude = {...this.claude, ...cfg.claude};
|
||
}
|
||
}
|
||
|
||
const rawMessages = localStorage.getItem(STORAGE_KEY);
|
||
if (rawMessages) {
|
||
const list = JSON.parse(rawMessages);
|
||
if (Array.isArray(list) && list.length) {
|
||
const normalized = list
|
||
.filter((item) => item && typeof item.content === 'string' && (item.role === 'user' || item.role === 'assistant'))
|
||
.map((item) => {
|
||
const role = item.role as MessageRole;
|
||
const content = String(item.content);
|
||
const time = Number.isFinite(item.time) ? item.time : Date.now();
|
||
return {
|
||
role,
|
||
content,
|
||
time,
|
||
status: item.status === 'thinking' || item.status === 'error' ? item.status : 'done',
|
||
} as ChatMessage;
|
||
});
|
||
|
||
if (normalized.length) {
|
||
this.messages = normalized.slice(-80);
|
||
}
|
||
}
|
||
}
|
||
} catch (_e) {
|
||
// Ignore legacy state.
|
||
}
|
||
|
||
this.openai.key = window.__SGCLAW_TEST_OPENAI_KEY__ || this.openai.key || '';
|
||
this.claude.key = window.__SGCLAW_TEST_CLAUDE_KEY__ || this.claude.key || '';
|
||
|
||
this.syncProviderState_();
|
||
}
|
||
|
||
private syncProviderState_(): void {
|
||
const defaults = PROVIDER_DEFAULTS[this.provider];
|
||
if (this.provider === 'openai') {
|
||
this.apiKey = this.openai.key || '';
|
||
this.model = this.openai.model || defaults.model;
|
||
this.endpoint = this.openai.endpoint || defaults.endpoint;
|
||
} else if (this.provider === 'claude') {
|
||
this.apiKey = this.claude.key || '';
|
||
this.model = this.claude.model || defaults.model;
|
||
this.endpoint = this.claude.endpoint || defaults.endpoint;
|
||
} else {
|
||
this.apiKey = '';
|
||
this.model = defaults.model;
|
||
this.endpoint = defaults.endpoint;
|
||
}
|
||
|
||
this.statusText = this.getStatusText_();
|
||
this.saveConfig_();
|
||
}
|
||
|
||
private getStatusText_(): string {
|
||
if (this.busy) {
|
||
return '正在响应...';
|
||
}
|
||
|
||
if (this.provider === 'mock') {
|
||
return '离线 mock 模式';
|
||
}
|
||
|
||
if (this.apiKey) {
|
||
return `已配置 ${this.provider === 'openai' ? 'OpenAI' : 'Claude'}`;
|
||
}
|
||
|
||
return `未配置 ${this.provider === 'openai' ? 'OpenAI' : 'Claude'} Key,发送时将自动回退到 Mock`;
|
||
}
|
||
|
||
private validateProvider_(): boolean {
|
||
if (this.provider === 'mock') {
|
||
return true;
|
||
}
|
||
|
||
if (!this.apiKey) {
|
||
this.errorTip = `未配置 ${this.provider === 'openai' ? 'OpenAI' : 'Claude'} API Key,使用 mock 回复`;
|
||
this.statusText = this.getStatusText_();
|
||
return false;
|
||
}
|
||
|
||
if (!this.endpoint) {
|
||
this.errorTip = '请先填写接口地址';
|
||
this.statusText = this.getStatusText_();
|
||
return false;
|
||
}
|
||
|
||
if (!this.model) {
|
||
this.errorTip = '请先填写模型名称';
|
||
this.statusText = this.getStatusText_();
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
private buildHistory_(): Array<{role: MessageRole; content: string}> {
|
||
return this.messages
|
||
.filter((msg) => msg.role === 'user' || msg.role === 'assistant')
|
||
.slice(-20)
|
||
.map((msg) => ({role: msg.role, content: msg.content}));
|
||
}
|
||
|
||
private callOpenAI_(prompt: string): Promise<string> {
|
||
const body = {
|
||
model: this.model,
|
||
temperature: 0.35,
|
||
messages: this.buildHistory_().concat([{role: 'user' as const, content: prompt}]),
|
||
};
|
||
|
||
return fetch(this.endpoint, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
Authorization: `Bearer ${this.apiKey}`,
|
||
},
|
||
body: JSON.stringify(body),
|
||
}).then((resp) => {
|
||
if (!resp.ok) {
|
||
return resp.text().then((text) => {
|
||
throw new Error(`OpenAI 响应异常: ${resp.status} ${text.slice(0, 120)}`);
|
||
});
|
||
}
|
||
return resp.json();
|
||
}).then((data: {choices?: Array<{message?: {content?: string}}>}): string => {
|
||
if (!data.choices || !data.choices[0] || !data.choices[0].message) {
|
||
throw new Error('OpenAI 返回结构异常');
|
||
}
|
||
return data.choices[0].message.content || '(无文本返回)';
|
||
});
|
||
}
|
||
|
||
private callClaude_(prompt: string): Promise<string> {
|
||
const messages = this.buildHistory_().map((msg) => ({
|
||
role: msg.role === 'assistant' ? 'assistant' : 'user',
|
||
content: msg.content,
|
||
}));
|
||
|
||
return fetch(this.endpoint, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'x-api-key': this.apiKey,
|
||
'anthropic-version': '2023-06-01',
|
||
},
|
||
body: JSON.stringify({
|
||
model: this.model,
|
||
max_tokens: 1024,
|
||
messages: messages.concat([{role: 'user', content: prompt}]),
|
||
system: '你是 SGClaw 侧边界面内置助手,提供简洁、准确、可执行的技术建议。',
|
||
}),
|
||
}).then((resp) => {
|
||
if (!resp.ok) {
|
||
return resp.text().then((text) => {
|
||
throw new Error(`Claude 响应异常: ${resp.status} ${text.slice(0, 120)}`);
|
||
});
|
||
}
|
||
return resp.json();
|
||
}).then((data: {content?: Array<{type?: string, text?: string}>}) => {
|
||
if (!data.content || !Array.isArray(data.content) || data.content.length === 0) {
|
||
throw new Error('Claude 返回结构异常');
|
||
}
|
||
return data.content
|
||
.map((block) => (block.type === 'text' ? (block.text || '') : `[${block.type}]`))
|
||
.join('\n');
|
||
});
|
||
}
|
||
|
||
private postMessage_(prompt: string): Promise<string> {
|
||
if (!this.validateProvider_()) {
|
||
return Promise.resolve(fallbackMockReply(this.provider, prompt));
|
||
}
|
||
|
||
if (this.provider === 'openai') {
|
||
return this.callOpenAI_(prompt);
|
||
}
|
||
|
||
if (this.provider === 'claude') {
|
||
return this.callClaude_(prompt);
|
||
}
|
||
|
||
return Promise.resolve(fallbackMockReply(this.provider, prompt));
|
||
}
|
||
|
||
private adjustInputHeight_(): void {
|
||
const input = this.shadowRoot?.querySelector<HTMLTextAreaElement>('#userInput');
|
||
if (!input) {
|
||
return;
|
||
}
|
||
input.style.height = 'auto';
|
||
input.style.height = clampHeight(input.scrollHeight);
|
||
}
|
||
|
||
private onFocusInput_(): void {
|
||
const input = this.shadowRoot?.querySelector<HTMLTextAreaElement>('#userInput');
|
||
input?.focus();
|
||
}
|
||
|
||
private onProviderChange_(event: Event): void {
|
||
this.provider = (event.target as HTMLSelectElement).value as Provider;
|
||
this.syncProviderState_();
|
||
}
|
||
|
||
private onEndpointChange_(event: Event): void {
|
||
const value = (event.target as HTMLInputElement).value;
|
||
if (this.provider === 'openai') {
|
||
this.openai.endpoint = value;
|
||
} else if (this.provider === 'claude') {
|
||
this.claude.endpoint = value;
|
||
}
|
||
this.endpoint = value;
|
||
this.saveConfig_();
|
||
}
|
||
|
||
private onModelChange_(event: Event): void {
|
||
const value = (event.target as HTMLInputElement).value;
|
||
if (this.provider === 'openai') {
|
||
this.openai.model = value;
|
||
} else if (this.provider === 'claude') {
|
||
this.claude.model = value;
|
||
}
|
||
this.model = value;
|
||
this.saveConfig_();
|
||
this.statusText = this.getStatusText_();
|
||
}
|
||
|
||
private onApiKeyChange_(event: Event): void {
|
||
const value = (event.target as HTMLInputElement).value.trim();
|
||
if (this.provider === 'openai') {
|
||
this.openai.key = value;
|
||
} else if (this.provider === 'claude') {
|
||
this.claude.key = value;
|
||
}
|
||
this.apiKey = value;
|
||
this.errorTip = '';
|
||
this.saveConfig_();
|
||
this.statusText = this.getStatusText_();
|
||
}
|
||
|
||
private onUserInputInput_(event: Event): void {
|
||
this.errorTip = '';
|
||
this.userInput = (event.target as HTMLTextAreaElement).value;
|
||
this.adjustInputHeight_();
|
||
}
|
||
|
||
private onUserInputKeyDown_(event: KeyboardEvent): void {
|
||
if (event.key === 'Enter' && !event.shiftKey) {
|
||
event.preventDefault();
|
||
this.onSendMessage_();
|
||
}
|
||
}
|
||
|
||
private onContainerKeyDown_(event: KeyboardEvent): void {
|
||
if (event.key === 'Enter' && !event.shiftKey && event.target && (event.target as HTMLElement).tagName === 'TEXTAREA') {
|
||
event.preventDefault();
|
||
this.onSendMessage_();
|
||
}
|
||
}
|
||
|
||
private onQuickPrompt_(event: Event): void {
|
||
if (this.busy) {
|
||
return;
|
||
}
|
||
const prompt = (event.currentTarget as HTMLButtonElement).dataset.prompt || '';
|
||
this.userInput = prompt;
|
||
this.adjustInputHeight_();
|
||
this.requestUpdate();
|
||
this.onSendMessage_();
|
||
}
|
||
|
||
private onSendMessage_(): void {
|
||
const prompt = normalizeText(this.userInput);
|
||
if (!prompt || this.busy) {
|
||
return;
|
||
}
|
||
|
||
this.errorTip = '';
|
||
|
||
const userMsg: ChatMessage = {
|
||
role: 'user',
|
||
content: prompt,
|
||
time: Date.now(),
|
||
status: 'done',
|
||
};
|
||
const assistantMsg: ChatMessage = {
|
||
role: 'assistant',
|
||
content: '',
|
||
time: Date.now(),
|
||
status: 'thinking',
|
||
};
|
||
|
||
this.messages = [...this.messages, userMsg, assistantMsg];
|
||
const assistantIndex = this.messages.length - 1;
|
||
|
||
this.userInput = '';
|
||
this.adjustInputHeight_();
|
||
this.busy = true;
|
||
this.statusText = this.getStatusText_();
|
||
|
||
this.updateComplete.then(() => {
|
||
const container = this.shadowRoot?.querySelector<HTMLElement>('.messages');
|
||
if (container) {
|
||
container.scrollTop = container.scrollHeight;
|
||
}
|
||
});
|
||
|
||
this.postMessage_(prompt)
|
||
.then((reply) => {
|
||
this.messages = this.messages.map((item, index) => {
|
||
if (index === assistantIndex) {
|
||
return {
|
||
...item,
|
||
content: reply,
|
||
status: 'done',
|
||
time: Date.now(),
|
||
};
|
||
}
|
||
return item;
|
||
});
|
||
})
|
||
.catch((err: unknown) => {
|
||
this.messages = this.messages.map((item, index) => {
|
||
if (index === assistantIndex) {
|
||
return {
|
||
...item,
|
||
content: `对话失败:${err instanceof Error ? err.message : '未知错误'}`,
|
||
status: 'error',
|
||
time: Date.now(),
|
||
};
|
||
}
|
||
return item;
|
||
});
|
||
})
|
||
.finally(() => {
|
||
this.busy = false;
|
||
if (this.messages.length > MAX_SAVED_MESSAGES) {
|
||
this.messages = this.messages.slice(-MAX_SAVED_MESSAGES);
|
||
}
|
||
this.saveMessages_();
|
||
this.statusText = this.getStatusText_();
|
||
this.requestUpdate();
|
||
this.updateComplete.then(() => {
|
||
const container = this.shadowRoot?.querySelector<HTMLElement>('.messages');
|
||
if (container) {
|
||
container.scrollTop = container.scrollHeight;
|
||
}
|
||
const input = this.shadowRoot?.querySelector<HTMLTextAreaElement>('#userInput');
|
||
input?.focus();
|
||
});
|
||
});
|
||
}
|
||
|
||
private onCopyMessage_(index: number): void {
|
||
const target = this.messages[index]?.content || '';
|
||
if (!target) {
|
||
return;
|
||
}
|
||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||
navigator.clipboard.writeText(target);
|
||
return;
|
||
}
|
||
}
|
||
|
||
private onExportChat_(): void {
|
||
const payload = {
|
||
provider: this.provider,
|
||
model: this.model,
|
||
endpoint: this.endpoint,
|
||
exportedAt: new Date().toISOString(),
|
||
messages: this.messages,
|
||
};
|
||
const blob = new Blob([JSON.stringify(payload, null, 2)], {type: 'application/json'});
|
||
const 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);
|
||
}
|
||
|
||
private onClearChat_(): void {
|
||
if (!window.confirm('确定清空当前聊天记录吗?')) {
|
||
return;
|
||
}
|
||
this.messages = [{
|
||
role: 'assistant',
|
||
content: '记录已清空。请继续输入你的新问题。',
|
||
time: Date.now(),
|
||
status: 'done',
|
||
}];
|
||
this.saveMessages_();
|
||
this.statusText = this.getStatusText_();
|
||
this.requestUpdate();
|
||
}
|
||
}
|
||
|
||
customElements.define(SgClawChatFunctionApp.is, SgClawChatFunctionApp);
|