// 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; claude?: Partial; } const CONFIG_KEY = 'sgclaw-chat-ui-v1'; const STORAGE_KEY = 'sgclaw-chat-messages-v1'; const PROVIDER_DEFAULTS: Record> = { 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, 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`

SG Claw Chat

${this.statusText}
当前:${this.provider === 'openai' ? 'OpenAI' : this.provider === 'claude' ? 'Claude' : '本地 Mock'} · ${this.model}
${this.messages.length === 0 ? html`
尚无历史消息,发一条开始吧。
` : html` ${this.messages.map((msg, idx) => this.renderMessage_(msg, idx))} `}

${this.errorTip}

注意:聊天数据仅保存在本地浏览器,不会上传到服务器。

`; } private renderMessage_(msg: ChatMessage, idx: number) { return html`
${msg.role === 'user' ? '我' : 'sgClaw'} ${formatTime(msg.time || Date.now())}
${msg.status === 'thinking' && msg.role === 'assistant' ? html`
` : html`
${msg.content}
` } ${msg.role === 'assistant' && msg.content ? html` ` : ''}
`; } 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 { 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 { 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 { 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('#userInput'); if (!input) { return; } input.style.height = 'auto'; input.style.height = clampHeight(input.scrollHeight); } private onFocusInput_(): void { const input = this.shadowRoot?.querySelector('#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('.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('.messages'); if (container) { container.scrollTop = container.scrollHeight; } const input = this.shadowRoot?.querySelector('#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);