From 4d9b17f975369c39afb928818842a3e6040f6590 Mon Sep 17 00:00:00 2001 From: zyl Date: Thu, 26 Mar 2026 18:13:34 +0800 Subject: [PATCH] frontend: replace sgClaw verification page with chat UI --- frontend/sgClaw验证/index.html | 1870 +++++++++-------- frontend/sgClaw验证/report-legacy.html | 910 ++++++++ .../sgClaw验证/sgclaw-chat-standalone.html | 1063 ++++++++++ .../sgClaw验证/superrpa_migration/README.md | 30 + .../superrpa_migration/sgclaw-chat.ts | 1140 ++++++++++ 5 files changed, 4154 insertions(+), 859 deletions(-) create mode 100644 frontend/sgClaw验证/report-legacy.html create mode 100644 frontend/sgClaw验证/sgclaw-chat-standalone.html create mode 100644 frontend/sgClaw验证/superrpa_migration/README.md create mode 100644 frontend/sgClaw验证/superrpa_migration/sgclaw-chat.ts diff --git a/frontend/sgClaw验证/index.html b/frontend/sgClaw验证/index.html index f6eb513..523703f 100644 --- a/frontend/sgClaw验证/index.html +++ b/frontend/sgClaw验证/index.html @@ -3,908 +3,1060 @@ - sgClaw AI Agent 验证报告 - - - - + sgClaw Chat +
+
+

SG Claw Chat

+
+ + 就绪 +
+
-
- -
-
-
sgClaw 验证系统加载中...
+
+ + +
+
+ +
当前:OpenAI · gpt-4o-mini
+
+ +
+ +
+
+ + +
+

+

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

+
+
+
-
- -
-
-

sgClaw · AI Agent 验证报告

-
- 业数融合一平台 · SuperRPA 智能增强层 - - {{ reportDate }} - - {{ overallStatus.label }} -
-
-
- - {{ isRunningAll ? '测试中...' : '一键全部验证' }} - - 导出报告 -
-
- - -
- -
-
- -
-
-
{{ stat.value }}
-
{{ stat.label }}
-
-
-
-
- - - -
- 系统架构拓扑 - 4 组件 -
-
-
-
-
-
Side Panel UI
-
Vue 2.6 + Element UI
-
- -
-
-
-
-
FunctionsUI IPC
-
-
-
-
SuperRPA Browser
-
C++ Chromium
-
- CommandRouter · - MAC Check · - PipeListener -
-
- -
-
-
-
-
STDIO Pipe (JSON Line)
-
-
-
-
sgClaw Agent
-
Rust / ZeroClaw
-
- ReAct Loop · - BrowserPipeTool -
-
- -
-
-
-
-
HTTPS API
-
-
-
-
LLM 服务
-
Claude / GPT / 本地
-
- -
-
-
-
-
- - - -
- 外网验证测试 -
- - {{ externalSummary.passed }}/{{ externalSummary.total }} 通过 - - - {{ isRunningExternal ? '执行中...' : '运行外网测试' }} - -
-
-
- 验证 sgClaw 在互联网可达环境下的外部服务连通性,包括 LLM API 调用、模型推理能力、 - Tool-use 协议兼容性等。适用于开发环境和具备外网访问的部署环境。 -
- - - - - - - - - - - - - - - - - -
- - - -
- 内网验证测试 -
- - {{ internalSummary.passed }}/{{ internalSummary.total }} 通过 - - - {{ isRunningInternal ? '执行中...' : '运行内网测试' }} - -
-
-
- 验证 sgClaw 在隔离内网环境(银河麒麟 V10 / 政企内网)下的核心能力, - 不依赖外网。包括 Pipe 通信、MAC 安全策略、Skill 加载、BrowserAction 执行、本地模型推理等。 -
- - - - - - - - - - - - - - - - - -
- - - -
- 端到端场景验证 -
- - {{ e2eSummary.passed }}/{{ e2eSummary.total }} 通过 - -
-
-
- 模拟真实用户场景,从自然语言指令到任务完成的全链路验证。覆盖主要业务系统的典型操作。 -
-
-
-
-
#{{ idx + 1 }}
-
-
{{ s.name }}
-
"{{ s.instruction }}"
-
-
- - - {{ statusLabel(s.status) }} - -
-
-
-
-
{{ si + 1 }}
-
- {{ step.action }} - {{ step.target }} -
-
- - {{ step.duration }}ms -
-
-
-
- 总步数: {{ s.metrics.steps }} - - 总耗时: {{ s.metrics.totalMs }}ms - - Token: {{ s.metrics.tokens }} -
-
-
-
- - - -
- 性能基准 -
-
-
-
{{ p.label }}
-
-
-
-
- {{ p.actual }} - 目标: {{ p.target }} -
-
-
-
- - - -
{{ detailDialog.content }}
-
-
-
- - - - - - - - - - - - + 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() + })() + diff --git a/frontend/sgClaw验证/report-legacy.html b/frontend/sgClaw验证/report-legacy.html new file mode 100644 index 0000000..f6eb513 --- /dev/null +++ b/frontend/sgClaw验证/report-legacy.html @@ -0,0 +1,910 @@ + + + + + + sgClaw AI Agent 验证报告 + + + + + + + + +
+ +
+
+
sgClaw 验证系统加载中...
+
+ +
+ +
+
+

sgClaw · AI Agent 验证报告

+
+ 业数融合一平台 · SuperRPA 智能增强层 + + {{ reportDate }} + + {{ overallStatus.label }} +
+
+
+ + {{ isRunningAll ? '测试中...' : '一键全部验证' }} + + 导出报告 +
+
+ + +
+ +
+
+ +
+
+
{{ stat.value }}
+
{{ stat.label }}
+
+
+
+
+ + + +
+ 系统架构拓扑 + 4 组件 +
+
+
+
+
+
Side Panel UI
+
Vue 2.6 + Element UI
+
+ +
+
+
+
+
FunctionsUI IPC
+
+
+
+
SuperRPA Browser
+
C++ Chromium
+
+ CommandRouter · + MAC Check · + PipeListener +
+
+ +
+
+
+
+
STDIO Pipe (JSON Line)
+
+
+
+
sgClaw Agent
+
Rust / ZeroClaw
+
+ ReAct Loop · + BrowserPipeTool +
+
+ +
+
+
+
+
HTTPS API
+
+
+
+
LLM 服务
+
Claude / GPT / 本地
+
+ +
+
+
+
+
+ + + +
+ 外网验证测试 +
+ + {{ externalSummary.passed }}/{{ externalSummary.total }} 通过 + + + {{ isRunningExternal ? '执行中...' : '运行外网测试' }} + +
+
+
+ 验证 sgClaw 在互联网可达环境下的外部服务连通性,包括 LLM API 调用、模型推理能力、 + Tool-use 协议兼容性等。适用于开发环境和具备外网访问的部署环境。 +
+ + + + + + + + + + + + + + + + + +
+ + + +
+ 内网验证测试 +
+ + {{ internalSummary.passed }}/{{ internalSummary.total }} 通过 + + + {{ isRunningInternal ? '执行中...' : '运行内网测试' }} + +
+
+
+ 验证 sgClaw 在隔离内网环境(银河麒麟 V10 / 政企内网)下的核心能力, + 不依赖外网。包括 Pipe 通信、MAC 安全策略、Skill 加载、BrowserAction 执行、本地模型推理等。 +
+ + + + + + + + + + + + + + + + + +
+ + + +
+ 端到端场景验证 +
+ + {{ e2eSummary.passed }}/{{ e2eSummary.total }} 通过 + +
+
+
+ 模拟真实用户场景,从自然语言指令到任务完成的全链路验证。覆盖主要业务系统的典型操作。 +
+
+
+
+
#{{ idx + 1 }}
+
+
{{ s.name }}
+
"{{ s.instruction }}"
+
+
+ + + {{ statusLabel(s.status) }} + +
+
+
+
+
{{ si + 1 }}
+
+ {{ step.action }} + {{ step.target }} +
+
+ + {{ step.duration }}ms +
+
+
+
+ 总步数: {{ s.metrics.steps }} + + 总耗时: {{ s.metrics.totalMs }}ms + + Token: {{ s.metrics.tokens }} +
+
+
+
+ + + +
+ 性能基准 +
+
+
+
{{ p.label }}
+
+
+
+
+ {{ p.actual }} + 目标: {{ p.target }} +
+
+
+
+ + + +
{{ detailDialog.content }}
+
+
+
+ + + + + + + + + + + + + + diff --git a/frontend/sgClaw验证/sgclaw-chat-standalone.html b/frontend/sgClaw验证/sgclaw-chat-standalone.html new file mode 100644 index 0000000..bb18ac0 --- /dev/null +++ b/frontend/sgClaw验证/sgclaw-chat-standalone.html @@ -0,0 +1,1063 @@ + + + + + + + sgClaw Chat (SuperRPA迁移稿) + + + +
+
+

SG Claw Chat

+
+ + 就绪 +
+
+ +
+ + +
+
+ +
当前:OpenAI · gpt-4o-mini
+
+ +
+ +
+
+ + +
+

+

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

+
+
+
+
+ + + + diff --git a/frontend/sgClaw验证/superrpa_migration/README.md b/frontend/sgClaw验证/superrpa_migration/README.md new file mode 100644 index 0000000..86a7f93 --- /dev/null +++ b/frontend/sgClaw验证/superrpa_migration/README.md @@ -0,0 +1,30 @@ +# sgClaw 聊天界面:SuperRPA 迁移稿 + +该目录用于在当前仓库先验证新聊天页面,再手动迁移到: +`/home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/` + +## 可直接迁移文件 + +- `sgclaw-chat.ts`:新的 Lit 组件版聊天页(对应 `sgclaw-chat` Function 的主实现)。 + +## 迁移步骤(建议) + +1. 备份原文件: + - `cp /home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat.ts /tmp/sgclaw-chat-backup.ts` + +2. 复制新文件: + - `cp frontend/sgClaw验证/superrpa_migration/sgclaw-chat.ts /home/zyl/projects/superRpa/src/chrome/browser/resources/superrpa/devtools/functions/sgclaw-chat/sgclaw-chat.ts` + +3. (可选)保留兼容: + - 现有 `sgclaw-chat.html.ts` 与 `sgclaw-chat.css.ts` 仍是占位导出,不影响本组件内联模板; + - 如有项目 lint/格式规范要求,可再拆分为独立 html.ts/css.ts。 + +4. 重新加载 Functions 页面验证:访问对应的 `sgclaw-chat` 功能入口。 + +## 注意 + +- 当前版本保留 localStorage 键: + - `sgclaw-chat-ui-v1` + - `sgclaw-chat-messages-v1` +- 未检测到 API Key 时会自动降级到 mock 回答。 +- 已支持 OpenAI / Claude / mock 三种模式。 diff --git a/frontend/sgClaw验证/superrpa_migration/sgclaw-chat.ts b/frontend/sgClaw验证/superrpa_migration/sgclaw-chat.ts new file mode 100644 index 0000000..f895a54 --- /dev/null +++ b/frontend/sgClaw验证/superrpa_migration/sgclaw-chat.ts @@ -0,0 +1,1140 @@ +// 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);