Files
claw/frontend/archive/sgClaw验证-已归档/superrpa_migration/sgclaw-chat.ts

1141 lines
32 KiB
TypeScript
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.
// 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);