feat: integrate openclaw assistant workspace
This commit is contained in:
parent
d65ea24956
commit
9a915ae080
13
.env.example
13
.env.example
@ -1,6 +1,13 @@
|
||||
# Moltbot Service URL
|
||||
# Defaults to https://moltbot.svc.plus if not set
|
||||
MOLTBOT_SERVICE_URL=https://moltbot.svc.plus
|
||||
# OpenClaw assistant integrations
|
||||
# Use environment variables to prefill the assistant and integrations page.
|
||||
# Values are read server-side and are not hardcoded into the UI.
|
||||
OPENCLAW_GATEWAY_REMOTE_URL=
|
||||
OPENCLAW_GATEWAY_TOKEN=
|
||||
VAULT_SERVER_URL=
|
||||
VAULT_NAMESPACE=
|
||||
VAULT_TOKEN=
|
||||
APISIX_AI_GATEWAY_URL=
|
||||
AI_GATEWAY_ACCESS_TOKEN=
|
||||
|
||||
# Giscus Configuration (GitHub Discussions Integration)
|
||||
# See https://giscus.app to generate these values
|
||||
|
||||
13
README.md
13
README.md
@ -36,6 +36,18 @@ yarn dev
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
AI 助手与集成页使用以下环境变量做服务端预填,不在前端 UI 中硬编码:
|
||||
|
||||
- `OPENCLAW_GATEWAY_REMOTE_URL`
|
||||
- `OPENCLAW_GATEWAY_TOKEN`
|
||||
- `VAULT_SERVER_URL`
|
||||
- `VAULT_NAMESPACE`
|
||||
- `VAULT_TOKEN`
|
||||
- `APISIX_AI_GATEWAY_URL`
|
||||
- `AI_GATEWAY_ACCESS_TOKEN`
|
||||
|
||||
建议参考 `/Users/shenlan/workspaces/cloud-neutral-toolkit/openclaw-deploy-example/.env` 填写,并同时查看 `docs/getting-started/installation.md`。
|
||||
|
||||
## 核心特性 & 技术栈 (Features & Tech Stack)
|
||||
|
||||
核心特性:
|
||||
@ -57,6 +69,7 @@ cp .env.example .env
|
||||
常用链接:
|
||||
* OIDC: `docs/integrations/oidc-auth.md`
|
||||
* Cloudflare Web Analytics: `docs/integrations/cloudflare-web-analytics.md`
|
||||
* Assistant / Integrations env setup: `docs/getting-started/installation.md`
|
||||
|
||||
其他:
|
||||
* Agent rules: `AGENTS.md`
|
||||
|
||||
@ -26,6 +26,20 @@ This directory follows a standard open-source documentation layout and mirrors t
|
||||
- Library layer (vendored): `packages/neurapress`
|
||||
- Build/runtime glue: `scripts`, `config`, `public`
|
||||
|
||||
## Assistant Integrations
|
||||
|
||||
The homepage AI assistant and `/panel/api` integrations page read their defaults from environment variables on the server side. Use `.env.example` plus `getting-started/installation.md` for the canonical setup.
|
||||
|
||||
Canonical variables:
|
||||
|
||||
- `OPENCLAW_GATEWAY_REMOTE_URL`
|
||||
- `OPENCLAW_GATEWAY_TOKEN`
|
||||
- `VAULT_SERVER_URL`
|
||||
- `VAULT_NAMESPACE`
|
||||
- `VAULT_TOKEN`
|
||||
- `APISIX_AI_GATEWAY_URL`
|
||||
- `AI_GATEWAY_ACCESS_TOKEN`
|
||||
|
||||
## Index
|
||||
|
||||
- Getting Started
|
||||
|
||||
@ -2,8 +2,44 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
- TODO: Add content specific to Installation.
|
||||
- Set up the local development environment for `console.svc.plus`.
|
||||
- Define the assistant and integrations defaults without hardcoding gateway values into the UI.
|
||||
|
||||
## Notes
|
||||
## Environment setup
|
||||
|
||||
- TODO: Link to related documents in this section.
|
||||
1. Copy the example file:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
2. Use `/Users/shenlan/workspaces/cloud-neutral-toolkit/openclaw-deploy-example/.env` as the reference source for deployment-aligned values when available.
|
||||
|
||||
## Assistant and integrations variables
|
||||
|
||||
These variables are read on the server side and used to prefill:
|
||||
|
||||
- the homepage AI assistant
|
||||
- the sidebar assistant dialog
|
||||
- the `/panel/api` integrations page
|
||||
|
||||
| Variable | Used by | Notes |
|
||||
|---|---|---|
|
||||
| `OPENCLAW_GATEWAY_REMOTE_URL` | OpenClaw assistant | Preferred remote WebSocket endpoint, for example `wss://openclaw.svc.plus:443` |
|
||||
| `OPENCLAW_GATEWAY_TOKEN` | OpenClaw assistant | Gateway token used by the server-side assistant bridge |
|
||||
| `VAULT_SERVER_URL` | Vault integration | Base Vault address for connectivity checks and defaults |
|
||||
| `VAULT_NAMESPACE` | Vault integration | Optional namespace when Vault Enterprise namespaces are used |
|
||||
| `VAULT_TOKEN` | Vault integration | Token used for Vault probe requests |
|
||||
| `APISIX_AI_GATEWAY_URL` | APISIX AI Gateway integration | Base HTTP(S) endpoint for AI gateway probing |
|
||||
| `AI_GATEWAY_ACCESS_TOKEN` | APISIX AI Gateway integration | Access token used for gateway probe requests |
|
||||
|
||||
## Behavior
|
||||
|
||||
- These values are not hardcoded into React components.
|
||||
- UI forms can still be overridden per request or per session when needed.
|
||||
- Empty values simply disable prefill; they do not break the page layout.
|
||||
|
||||
## Related documents
|
||||
|
||||
- `../README.md`
|
||||
- `../usage/config.md`
|
||||
|
||||
@ -22,3 +22,21 @@
|
||||
- 每个中文文件顶部会链接到对应的英文原文路径。
|
||||
- 如果中文内容尚未完善,会保留 `TODO` 占位,逐步补齐。
|
||||
|
||||
## AI 助手集成环境变量
|
||||
|
||||
首页 AI 助手和 `/panel/api` 集成页会在服务端读取环境变量做默认值预填,不会把网关地址或令牌硬编码进前端 UI。
|
||||
|
||||
建议优先查看:
|
||||
|
||||
- `../../.env.example`
|
||||
- `getting-started/installation.md`
|
||||
|
||||
当前约定的主变量:
|
||||
|
||||
- `OPENCLAW_GATEWAY_REMOTE_URL`
|
||||
- `OPENCLAW_GATEWAY_TOKEN`
|
||||
- `VAULT_SERVER_URL`
|
||||
- `VAULT_NAMESPACE`
|
||||
- `VAULT_TOKEN`
|
||||
- `APISIX_AI_GATEWAY_URL`
|
||||
- `AI_GATEWAY_ACCESS_TOKEN`
|
||||
|
||||
@ -4,8 +4,44 @@
|
||||
|
||||
## 目的
|
||||
|
||||
- TODO: 补充中文内容。
|
||||
- 为 `console.svc.plus` 准备本地运行环境。
|
||||
- 用环境变量给 AI 助手和集成功能做默认值预填,而不是把网关配置写死在 UI 里。
|
||||
|
||||
## 备注
|
||||
## 环境准备
|
||||
|
||||
- TODO: 链接到本章节相关文档。
|
||||
1. 复制示例文件:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
2. 如有联调环境,优先参考 `/Users/shenlan/workspaces/cloud-neutral-toolkit/openclaw-deploy-example/.env` 中对应值。
|
||||
|
||||
## AI 助手与集成变量
|
||||
|
||||
下面这些变量会由服务端读取,并用于以下场景的默认值预填:
|
||||
|
||||
- 首页 AI 助手
|
||||
- 侧栏 AI 助手
|
||||
- `/panel/api` 集成页
|
||||
|
||||
| 变量 | 用途 | 说明 |
|
||||
|---|---|---|
|
||||
| `OPENCLAW_GATEWAY_REMOTE_URL` | OpenClaw 助手 | 远端 WebSocket 地址,例如 `wss://openclaw.svc.plus:443` |
|
||||
| `OPENCLAW_GATEWAY_TOKEN` | OpenClaw 助手 | 服务端桥接 OpenClaw gateway 时使用的令牌 |
|
||||
| `VAULT_SERVER_URL` | Vault 集成 | Vault 服务地址,用于默认值和连通性探测 |
|
||||
| `VAULT_NAMESPACE` | Vault 集成 | Vault Enterprise namespace,可选 |
|
||||
| `VAULT_TOKEN` | Vault 集成 | Vault 探测请求使用的令牌 |
|
||||
| `APISIX_AI_GATEWAY_URL` | APISIX AI Gateway 集成 | AI gateway 的 HTTP(S) 地址 |
|
||||
| `AI_GATEWAY_ACCESS_TOKEN` | APISIX AI Gateway 集成 | AI gateway 探测请求使用的访问令牌 |
|
||||
|
||||
## 行为说明
|
||||
|
||||
- 这些变量不会被硬编码进前端 React 组件。
|
||||
- 页面中的输入项仍然支持会话级覆盖。
|
||||
- 变量留空只会取消默认值预填,不会影响现有布局。
|
||||
|
||||
## 相关文档
|
||||
|
||||
- `../README.md`
|
||||
- `../usage/config.md`
|
||||
|
||||
@ -1,18 +1,34 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { useEffect, type ReactNode } from 'react'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { ThemeProvider } from '../components/theme'
|
||||
import { LanguageProvider } from '../i18n/LanguageProvider'
|
||||
import { AskAIDialog } from '../components/AskAIDialog'
|
||||
import { useMoltbotStore } from '../lib/moltbotStore'
|
||||
import { cn } from '../lib/utils'
|
||||
import type { IntegrationDefaults } from '@/lib/openclaw/types'
|
||||
import { useOpenClawConsoleStore } from '@/state/openclawConsoleStore'
|
||||
|
||||
export function AppProviders({ children }: { children: ReactNode }) {
|
||||
const { isOpen, isMinimized, setIsOpen, setMinimized, close, mode, toggleOpen } = useMoltbotStore()
|
||||
export function AppProviders({
|
||||
children,
|
||||
assistantDefaults,
|
||||
}: {
|
||||
children: ReactNode
|
||||
assistantDefaults: IntegrationDefaults
|
||||
}) {
|
||||
const { isOpen, isMinimized, close, toggleOpen } = useMoltbotStore()
|
||||
const applyDefaults = useOpenClawConsoleStore((state) => state.applyDefaults)
|
||||
const pathname = usePathname()
|
||||
const isOpenClawWorkspace = pathname.startsWith('/services/openclaw')
|
||||
|
||||
// Always reserve space if open and not minimized, since we only have "Float/Sidebar" mode now
|
||||
// and user wants it to NEVER cover the homepage.
|
||||
const reserveSpace = isOpen && !isMinimized
|
||||
const reserveSpace = !isOpenClawWorkspace && isOpen && !isMinimized
|
||||
|
||||
useEffect(() => {
|
||||
applyDefaults(assistantDefaults)
|
||||
}, [applyDefaults, assistantDefaults])
|
||||
|
||||
return (
|
||||
<ThemeProvider>
|
||||
@ -25,11 +41,14 @@ export function AppProviders({ children }: { children: ReactNode }) {
|
||||
<div className="flex-1 flex flex-col w-full relative">
|
||||
{children}
|
||||
</div>
|
||||
<AskAIDialog
|
||||
open={isOpen}
|
||||
onMinimize={toggleOpen}
|
||||
onEnd={close}
|
||||
/>
|
||||
{!isOpenClawWorkspace ? (
|
||||
<AskAIDialog
|
||||
open={isOpen}
|
||||
defaults={assistantDefaults}
|
||||
onMinimize={toggleOpen}
|
||||
onEnd={close}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</LanguageProvider>
|
||||
|
||||
8
src/app/api/integrations/defaults/route.ts
Normal file
8
src/app/api/integrations/defaults/route.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { getConsoleIntegrationDefaults } from '@/server/consoleIntegrations'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function GET(): Promise<Response> {
|
||||
return Response.json(getConsoleIntegrationDefaults())
|
||||
}
|
||||
182
src/app/api/integrations/probe/route.ts
Normal file
182
src/app/api/integrations/probe/route.ts
Normal file
@ -0,0 +1,182 @@
|
||||
import type { NextRequest } from 'next/server'
|
||||
|
||||
import {
|
||||
resolveApisixProbeConfig,
|
||||
resolveOpenClawGatewayConfig,
|
||||
resolveVaultProbeConfig,
|
||||
} from '@/server/consoleIntegrations'
|
||||
import { OpenClawGatewayClient, OpenClawGatewayError } from '@/server/openclaw/gateway-client'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
type ProbeBody = {
|
||||
target?: 'openclaw' | 'vault' | 'apisix'
|
||||
gatewayUrl?: string
|
||||
gatewayToken?: string
|
||||
vaultUrl?: string
|
||||
vaultToken?: string
|
||||
apisixUrl?: string
|
||||
apisixToken?: string
|
||||
}
|
||||
|
||||
function jsonError(message: string, status = 400): Response {
|
||||
return Response.json({ error: message }, { status })
|
||||
}
|
||||
|
||||
async function probeOpenClaw(body: ProbeBody): Promise<Response> {
|
||||
const config = resolveOpenClawGatewayConfig({
|
||||
gatewayUrl: body.gatewayUrl,
|
||||
gatewayToken: body.gatewayToken,
|
||||
})
|
||||
|
||||
if (!config.gatewayUrl) {
|
||||
return jsonError('OpenClaw gateway URL is not configured.', 400)
|
||||
}
|
||||
|
||||
const client = new OpenClawGatewayClient()
|
||||
|
||||
try {
|
||||
await client.connect({
|
||||
gatewayUrl: config.gatewayUrl,
|
||||
gatewayToken: config.gatewayToken,
|
||||
})
|
||||
|
||||
const [statusPayload, healthPayload] = await Promise.all([client.status(), client.health()])
|
||||
|
||||
return Response.json({
|
||||
ok: true,
|
||||
target: 'openclaw',
|
||||
tokenSource: config.tokenSource,
|
||||
gatewayUrl: config.gatewayUrl,
|
||||
statusPayload,
|
||||
healthPayload,
|
||||
})
|
||||
} catch (error) {
|
||||
const gatewayError = error instanceof OpenClawGatewayError ? error : null
|
||||
return Response.json(
|
||||
{
|
||||
ok: false,
|
||||
target: 'openclaw',
|
||||
gatewayUrl: config.gatewayUrl,
|
||||
tokenSource: config.tokenSource,
|
||||
error: gatewayError?.message ?? 'Failed to probe OpenClaw gateway.',
|
||||
code: gatewayError?.code,
|
||||
},
|
||||
{ status: 502 },
|
||||
)
|
||||
} finally {
|
||||
await client.close()
|
||||
}
|
||||
}
|
||||
|
||||
async function probeVault(body: ProbeBody): Promise<Response> {
|
||||
const config = resolveVaultProbeConfig({
|
||||
vaultUrl: body.vaultUrl,
|
||||
vaultToken: body.vaultToken,
|
||||
})
|
||||
|
||||
if (!config.vaultUrl) {
|
||||
return jsonError('Vault URL is not configured.', 400)
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${config.vaultUrl}/v1/sys/health`, {
|
||||
method: 'GET',
|
||||
headers: config.vaultToken
|
||||
? {
|
||||
'X-Vault-Token': config.vaultToken,
|
||||
}
|
||||
: undefined,
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
const text = await response.text()
|
||||
|
||||
return Response.json({
|
||||
ok: response.ok,
|
||||
target: 'vault',
|
||||
vaultUrl: config.vaultUrl,
|
||||
tokenSource: config.tokenSource,
|
||||
status: response.status,
|
||||
body: text.slice(0, 2000),
|
||||
})
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{
|
||||
ok: false,
|
||||
target: 'vault',
|
||||
vaultUrl: config.vaultUrl,
|
||||
tokenSource: config.tokenSource,
|
||||
error: error instanceof Error ? error.message : 'Failed to probe Vault.',
|
||||
},
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function probeApisix(body: ProbeBody): Promise<Response> {
|
||||
const config = resolveApisixProbeConfig({
|
||||
apisixUrl: body.apisixUrl,
|
||||
apisixToken: body.apisixToken,
|
||||
})
|
||||
|
||||
if (!config.apisixUrl) {
|
||||
return jsonError('APISIX AI gateway URL is not configured.', 400)
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${config.apisixUrl}/v1/models`, {
|
||||
method: 'GET',
|
||||
headers: config.apisixToken
|
||||
? {
|
||||
Authorization: `Bearer ${config.apisixToken}`,
|
||||
}
|
||||
: undefined,
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
const text = await response.text()
|
||||
|
||||
return Response.json({
|
||||
ok: response.ok,
|
||||
target: 'apisix',
|
||||
apisixUrl: config.apisixUrl,
|
||||
tokenSource: config.tokenSource,
|
||||
status: response.status,
|
||||
body: text.slice(0, 2000),
|
||||
})
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{
|
||||
ok: false,
|
||||
target: 'apisix',
|
||||
apisixUrl: config.apisixUrl,
|
||||
tokenSource: config.tokenSource,
|
||||
error: error instanceof Error ? error.message : 'Failed to probe APISIX AI gateway.',
|
||||
},
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest): Promise<Response> {
|
||||
let body: ProbeBody
|
||||
|
||||
try {
|
||||
body = (await request.json()) as ProbeBody
|
||||
} catch {
|
||||
return jsonError('Invalid JSON body.', 400)
|
||||
}
|
||||
|
||||
switch (body.target) {
|
||||
case 'openclaw':
|
||||
return probeOpenClaw(body)
|
||||
case 'vault':
|
||||
return probeVault(body)
|
||||
case 'apisix':
|
||||
return probeApisix(body)
|
||||
default:
|
||||
return jsonError('Unsupported probe target.', 400)
|
||||
}
|
||||
}
|
||||
340
src/app/api/openclaw/assistant/route.ts
Normal file
340
src/app/api/openclaw/assistant/route.ts
Normal file
@ -0,0 +1,340 @@
|
||||
import type { NextRequest } from 'next/server'
|
||||
|
||||
import {
|
||||
extractMessageText,
|
||||
makeAgentSessionKey,
|
||||
matchesSessionKey,
|
||||
normalizeMainSessionKey,
|
||||
type GatewayChatAttachmentPayload,
|
||||
type OpenClawBootstrapResponse,
|
||||
type OpenClawStreamEvent,
|
||||
} from '@/lib/openclaw/types'
|
||||
import { resolveOpenClawGatewayConfig } from '@/server/consoleIntegrations'
|
||||
import { OpenClawGatewayClient, OpenClawGatewayError } from '@/server/openclaw/gateway-client'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
type BootstrapBody = {
|
||||
action: 'bootstrap'
|
||||
gatewayUrl?: string
|
||||
gatewayToken?: string
|
||||
agentId?: string
|
||||
sessionKey?: string
|
||||
}
|
||||
|
||||
type SendBody = {
|
||||
action: 'send'
|
||||
gatewayUrl?: string
|
||||
gatewayToken?: string
|
||||
agentId?: string
|
||||
sessionKey?: string
|
||||
message?: string
|
||||
thinking?: string
|
||||
attachments?: GatewayChatAttachmentPayload[]
|
||||
}
|
||||
|
||||
function jsonError(message: string, status = 400, code?: string): Response {
|
||||
return Response.json(
|
||||
{
|
||||
error: message,
|
||||
code,
|
||||
},
|
||||
{ status },
|
||||
)
|
||||
}
|
||||
|
||||
function resolveSessionKey(params: {
|
||||
sessionKey?: string
|
||||
agentId?: string
|
||||
mainSessionKey: string
|
||||
}): string {
|
||||
const explicit = params.sessionKey?.trim()
|
||||
if (explicit) {
|
||||
return explicit
|
||||
}
|
||||
|
||||
return makeAgentSessionKey(params.agentId?.trim() ?? '', params.mainSessionKey)
|
||||
}
|
||||
|
||||
async function handleBootstrap(body: BootstrapBody): Promise<Response> {
|
||||
const gateway = resolveOpenClawGatewayConfig({
|
||||
gatewayUrl: body.gatewayUrl,
|
||||
gatewayToken: body.gatewayToken,
|
||||
})
|
||||
|
||||
if (!gateway.gatewayUrl) {
|
||||
return jsonError('OpenClaw gateway URL is not configured.', 400, 'MISSING_GATEWAY_URL')
|
||||
}
|
||||
|
||||
const client = new OpenClawGatewayClient()
|
||||
|
||||
try {
|
||||
const connected = await client.connect({
|
||||
gatewayUrl: gateway.gatewayUrl,
|
||||
gatewayToken: gateway.gatewayToken,
|
||||
})
|
||||
|
||||
const mainSessionKey = normalizeMainSessionKey(connected.mainSessionKey)
|
||||
const activeSessionKey = resolveSessionKey({
|
||||
sessionKey: body.sessionKey,
|
||||
agentId: body.agentId,
|
||||
mainSessionKey,
|
||||
})
|
||||
|
||||
const [statusPayload, healthPayload, agents, sessions, messages] = await Promise.all([
|
||||
client.status(),
|
||||
client.health(),
|
||||
client.listAgents(),
|
||||
client.listSessions(body.agentId?.trim() || undefined, 24),
|
||||
client.loadHistory(activeSessionKey),
|
||||
])
|
||||
|
||||
const payload: OpenClawBootstrapResponse = {
|
||||
activeSessionKey,
|
||||
mainSessionKey,
|
||||
gatewayUrl: gateway.gatewayUrl,
|
||||
tokenSource: gateway.tokenSource,
|
||||
connectedAt: new Date().toISOString(),
|
||||
agents,
|
||||
sessions,
|
||||
messages,
|
||||
statusPayload,
|
||||
healthPayload,
|
||||
}
|
||||
|
||||
return Response.json(payload)
|
||||
} catch (error) {
|
||||
const gatewayError = error instanceof OpenClawGatewayError ? error : null
|
||||
return jsonError(
|
||||
gatewayError?.message ?? 'Failed to connect to OpenClaw gateway.',
|
||||
gatewayError?.code === 'OFFLINE' ? 503 : 502,
|
||||
gatewayError?.code,
|
||||
)
|
||||
} finally {
|
||||
await client.close()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSend(body: SendBody): Promise<Response> {
|
||||
const gateway = resolveOpenClawGatewayConfig({
|
||||
gatewayUrl: body.gatewayUrl,
|
||||
gatewayToken: body.gatewayToken,
|
||||
})
|
||||
|
||||
if (!gateway.gatewayUrl) {
|
||||
return jsonError('OpenClaw gateway URL is not configured.', 400, 'MISSING_GATEWAY_URL')
|
||||
}
|
||||
|
||||
const message = body.message?.trim() ?? ''
|
||||
const attachments = Array.isArray(body.attachments) ? body.attachments : []
|
||||
|
||||
if (!message && attachments.length === 0) {
|
||||
return jsonError('A message or attachment is required.', 400, 'EMPTY_MESSAGE')
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
let client: OpenClawGatewayClient | null = null
|
||||
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
let activeSessionKey = body.sessionKey?.trim() ?? 'main'
|
||||
let activeRunId = ''
|
||||
let finalized = false
|
||||
let latestAssistantText = ''
|
||||
let stopListening = () => {}
|
||||
let finalTimer: NodeJS.Timeout | null = null
|
||||
|
||||
const write = (event: OpenClawStreamEvent) => {
|
||||
controller.enqueue(encoder.encode(`${JSON.stringify(event)}\n`))
|
||||
}
|
||||
|
||||
const finalize = async (
|
||||
state: 'final' | 'aborted' | 'error',
|
||||
errorMessage?: string,
|
||||
): Promise<void> => {
|
||||
if (finalized) {
|
||||
return
|
||||
}
|
||||
|
||||
finalized = true
|
||||
stopListening()
|
||||
if (finalTimer) {
|
||||
clearTimeout(finalTimer)
|
||||
finalTimer = null
|
||||
}
|
||||
|
||||
try {
|
||||
const [messages, sessions] = client
|
||||
? await Promise.all([
|
||||
client.loadHistory(activeSessionKey),
|
||||
client.listSessions(body.agentId?.trim() || undefined, 24),
|
||||
])
|
||||
: [[], []]
|
||||
|
||||
write({
|
||||
type: 'final',
|
||||
state,
|
||||
messages,
|
||||
sessions,
|
||||
...(errorMessage ? { errorMessage } : {}),
|
||||
})
|
||||
} finally {
|
||||
controller.close()
|
||||
if (client) {
|
||||
await client.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
client = new OpenClawGatewayClient()
|
||||
const connected = await client.connect({
|
||||
gatewayUrl: gateway.gatewayUrl,
|
||||
gatewayToken: gateway.gatewayToken,
|
||||
})
|
||||
|
||||
const mainSessionKey = normalizeMainSessionKey(connected.mainSessionKey)
|
||||
activeSessionKey = resolveSessionKey({
|
||||
sessionKey: body.sessionKey,
|
||||
agentId: body.agentId,
|
||||
mainSessionKey,
|
||||
})
|
||||
|
||||
stopListening = client.onEvent((event) => {
|
||||
if (event.event === 'chat') {
|
||||
const payload = (event.payload ?? {}) as Record<string, unknown>
|
||||
const runId = typeof payload.runId === 'string' ? payload.runId : ''
|
||||
const state = typeof payload.state === 'string' ? payload.state : ''
|
||||
const incomingSessionKey =
|
||||
typeof payload.sessionKey === 'string' ? payload.sessionKey : activeSessionKey
|
||||
|
||||
if (runId && activeRunId && runId !== activeRunId) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!matchesSessionKey(incomingSessionKey, activeSessionKey)) {
|
||||
return
|
||||
}
|
||||
|
||||
const messagePayload = (payload.message ?? {}) as Record<string, unknown>
|
||||
const role = typeof messagePayload.role === 'string' ? messagePayload.role.toLowerCase() : ''
|
||||
const text = extractMessageText(messagePayload)
|
||||
|
||||
if (
|
||||
role === 'assistant' &&
|
||||
text &&
|
||||
(state === 'delta' || state === 'final') &&
|
||||
text !== latestAssistantText
|
||||
) {
|
||||
latestAssistantText = text
|
||||
write({
|
||||
type: 'delta',
|
||||
text,
|
||||
})
|
||||
}
|
||||
|
||||
if (state === 'error') {
|
||||
void finalize(
|
||||
'error',
|
||||
typeof payload.errorMessage === 'string' ? payload.errorMessage : 'Chat failed.',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (state === 'final' || state === 'aborted') {
|
||||
void finalize(state)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.event === 'agent') {
|
||||
const payload = (event.payload ?? {}) as Record<string, unknown>
|
||||
const runId = typeof payload.runId === 'string' ? payload.runId : ''
|
||||
if (!runId || !activeRunId || runId !== activeRunId) {
|
||||
return
|
||||
}
|
||||
|
||||
if (payload.stream !== 'assistant') {
|
||||
return
|
||||
}
|
||||
|
||||
const data = (payload.data ?? {}) as Record<string, unknown>
|
||||
const text =
|
||||
typeof data.text === 'string' && data.text.trim().length > 0
|
||||
? data.text
|
||||
: extractMessageText(data)
|
||||
|
||||
if (text && text !== latestAssistantText) {
|
||||
latestAssistantText = text
|
||||
write({
|
||||
type: 'delta',
|
||||
text,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
activeRunId = await client.sendChat({
|
||||
sessionKey: activeSessionKey,
|
||||
message: message || 'See attached.',
|
||||
thinking: body.thinking?.trim() || 'high',
|
||||
attachments,
|
||||
})
|
||||
|
||||
write({
|
||||
type: 'ack',
|
||||
runId: activeRunId,
|
||||
sessionKey: activeSessionKey,
|
||||
})
|
||||
|
||||
finalTimer = setTimeout(() => {
|
||||
void finalize('error', 'Gateway stream timed out.')
|
||||
}, 45000)
|
||||
} catch (error) {
|
||||
const gatewayError = error instanceof OpenClawGatewayError ? error : null
|
||||
write({
|
||||
type: 'error',
|
||||
message: gatewayError?.message ?? 'Failed to send message to OpenClaw gateway.',
|
||||
...(gatewayError?.code ? { code: gatewayError.code } : {}),
|
||||
})
|
||||
controller.close()
|
||||
if (client) {
|
||||
await client.close()
|
||||
}
|
||||
}
|
||||
},
|
||||
async cancel() {
|
||||
if (client) {
|
||||
await client.close()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'application/x-ndjson; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest): Promise<Response> {
|
||||
let body: BootstrapBody | SendBody
|
||||
|
||||
try {
|
||||
body = (await request.json()) as BootstrapBody | SendBody
|
||||
} catch {
|
||||
return jsonError('Invalid JSON body.', 400, 'INVALID_JSON')
|
||||
}
|
||||
|
||||
if (body.action === 'bootstrap') {
|
||||
return handleBootstrap(body)
|
||||
}
|
||||
|
||||
if (body.action === 'send') {
|
||||
return handleSend(body)
|
||||
}
|
||||
|
||||
return jsonError('Unsupported assistant action.', 400, 'UNSUPPORTED_ACTION')
|
||||
}
|
||||
@ -7,6 +7,7 @@ import type { Metadata } from 'next'
|
||||
import Script from 'next/script'
|
||||
import { Analytics } from '@vercel/analytics/react'
|
||||
import { AppProviders } from './AppProviders'
|
||||
import { getConsoleIntegrationDefaults } from '@/server/consoleIntegrations'
|
||||
|
||||
const DEFAULT_TITLE = 'Cloud-Neutral Console | Unified Cloud Native Tools'
|
||||
const DEFAULT_DESCRIPTION =
|
||||
@ -74,6 +75,8 @@ const bodyClassName = 'bg-[var(--color-background)] text-[var(--color-text)]'
|
||||
const GA_ID = 'G-T4VM8G4Q42'
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const assistantDefaults = getConsoleIntegrationDefaults()
|
||||
|
||||
return (
|
||||
<html {...htmlAttributes}>
|
||||
<head>
|
||||
@ -126,7 +129,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
{/* End Cloudflare Web Analytics */}
|
||||
</head>
|
||||
<body className={bodyClassName}>
|
||||
<AppProviders>{children}</AppProviders>
|
||||
<AppProviders assistantDefaults={assistantDefaults}>{children}</AppProviders>
|
||||
<Analytics />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -1,127 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import {
|
||||
PanelLeft,
|
||||
PanelRight,
|
||||
Maximize2,
|
||||
Minus,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@lib/utils'
|
||||
import {
|
||||
HeroSection,
|
||||
NextStepsSection,
|
||||
StatsSection,
|
||||
ShortcutsSection
|
||||
} from '@/app/page'
|
||||
import Footer from '@components/Footer'
|
||||
|
||||
import { useLanguage } from '@/i18n/LanguageProvider'
|
||||
import { translations } from '@/i18n/translations'
|
||||
|
||||
export type ChatLayoutMode = 'left' | 'right' | 'full'
|
||||
|
||||
export function MoltbotChat() {
|
||||
const router = useRouter()
|
||||
const { language } = useLanguage()
|
||||
const t = translations[language].askAI
|
||||
|
||||
// Layout state
|
||||
const [layout, setLayout] = useState<ChatLayoutMode>('full')
|
||||
|
||||
// Home content for side-by-side mode
|
||||
const HomeContent = () => (
|
||||
<main className="space-y-12 py-10">
|
||||
<HeroSection />
|
||||
<NextStepsSection />
|
||||
<StatsSection />
|
||||
<ShortcutsSection />
|
||||
<Footer />
|
||||
</main>
|
||||
)
|
||||
|
||||
// Render the chat interface (now an iframe)
|
||||
const renderChat = (isSidebar = false) => (
|
||||
<div className={cn(
|
||||
"flex flex-col rounded-2xl border border-primary/20 bg-background/80 backdrop-blur-md shadow-2xl overflow-hidden transition-all duration-300 h-full",
|
||||
isSidebar ? 'w-[420px] shrink-0 z-10' : 'w-full'
|
||||
)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-primary/10 px-6 py-4 bg-surface-muted/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-primary/10 border border-primary/20">
|
||||
<span className="text-xl">🦞</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-base font-semibold text-text leading-tight">{translations[language].chat}</p>
|
||||
<p className="text-[10px] text-primary/70 uppercase tracking-widest font-bold">Online</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setLayout('left') }}
|
||||
className={`p-1.5 rounded-lg transition-colors ${layout === 'left' ? 'text-primary bg-primary/10' : 'text-text-muted hover:text-text hover:bg-surface-muted'}`}
|
||||
title="Sidebar Left"
|
||||
>
|
||||
<PanelLeft className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setLayout('right') }}
|
||||
className={`p-1.5 rounded-lg transition-colors ${layout === 'right' ? 'text-primary bg-primary/10' : 'text-text-muted hover:text-text hover:bg-surface-muted'}`}
|
||||
title="Sidebar Right"
|
||||
>
|
||||
<PanelRight className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setLayout('full') }}
|
||||
className={`p-1.5 rounded-lg transition-colors ${layout === 'full' ? 'text-primary bg-primary/10' : 'text-text-muted hover:text-text hover:bg-surface-muted'}`}
|
||||
title="Fullscreen"
|
||||
>
|
||||
<Maximize2 className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); router.push('/') }}
|
||||
className={`p-1.5 rounded-lg transition-colors text-text-muted hover:text-text hover:bg-surface-muted`}
|
||||
title="Minimize"
|
||||
>
|
||||
<PanelRight className="size-4 rotate-180" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); router.push('/') }}
|
||||
className="p-1.5 text-text-muted hover:text-danger hover:bg-danger/10 rounded-lg transition-colors"
|
||||
title="Close"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Iframe Content */}
|
||||
<div className="flex-1 w-full h-full bg-white">
|
||||
<iframe
|
||||
src="https://clawdbot.svc.plus/chat?session=agent%3Amain%3Amain"
|
||||
className="w-full h-full border-0"
|
||||
title="Moltbot Chat"
|
||||
allow="clipboard-write"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (layout === 'full') {
|
||||
return renderChat(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full gap-4 relative overflow-hidden">
|
||||
{layout === 'left' && renderChat(true)}
|
||||
<div className="flex-1 h-full overflow-y-auto custom-scrollbar">
|
||||
<HomeContent />
|
||||
</div>
|
||||
{layout === 'right' && renderChat(true)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,17 +0,0 @@
|
||||
import { Suspense } from 'react'
|
||||
import { MoltbotChat } from './MoltbotChat'
|
||||
|
||||
export const metadata = {
|
||||
title: 'Moltbot Chat',
|
||||
description: 'Chat with your infrastructure assistant',
|
||||
}
|
||||
|
||||
export default function MoltbotPage() {
|
||||
return (
|
||||
<div className="w-full h-[calc(100vh-var(--app-shell-nav-offset))] p-4">
|
||||
<Suspense fallback={<div className="flex h-full items-center justify-center">Loading chat...</div>}>
|
||||
<MoltbotChat />
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
23
src/app/services/openclaw/page.tsx
Normal file
23
src/app/services/openclaw/page.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import { Suspense } from 'react'
|
||||
|
||||
import { OpenClawWorkspacePage } from '@/components/openclaw/OpenClawWorkspacePage'
|
||||
import { getConsoleIntegrationDefaults } from '@/server/consoleIntegrations'
|
||||
|
||||
export const metadata = {
|
||||
title: 'OpenClaw Assistant',
|
||||
description: 'OpenClaw gateway assistant workspace',
|
||||
}
|
||||
|
||||
export default function OpenClawPage() {
|
||||
const defaults = getConsoleIntegrationDefaults()
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-var(--app-shell-nav-offset))] w-full p-4">
|
||||
<Suspense
|
||||
fallback={<div className="flex h-full items-center justify-center">Loading assistant...</div>}
|
||||
>
|
||||
<OpenClawWorkspacePage defaults={defaults} />
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -295,13 +295,12 @@ export default function ServicesPage() {
|
||||
},
|
||||
{
|
||||
key: "moltbot",
|
||||
name: isChinese ? "Moltbot 服务" : "Moltbot Service",
|
||||
name: isChinese ? "OpenClaw 助手" : "OpenClaw Assistant",
|
||||
description: isChinese
|
||||
? "Moltbot 节点管理服务。"
|
||||
: "Moltbot node management service.",
|
||||
href: "https://clawdbot.svc.plus/",
|
||||
? "OpenClaw gateway 驱动的原生 AI 助手工作区。"
|
||||
: "Native AI assistant workspace powered by OpenClaw gateway.",
|
||||
href: "/services/openclaw",
|
||||
icon: ClawdbotLogo,
|
||||
external: true,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { Bot } from 'lucide-react'
|
||||
import { AskAIDialog, type InitialQuestionPayload } from './AskAIDialog'
|
||||
import { useMoltbotStore } from '@lib/moltbotStore'
|
||||
import { useAccess } from '@lib/accessControl'
|
||||
import { cn } from '@lib/utils'
|
||||
@ -10,11 +9,10 @@ import { translations } from '../i18n/translations'
|
||||
|
||||
type AskAIButtonProps = {
|
||||
variant?: 'floating' | 'navbar'
|
||||
initialQuestion?: InitialQuestionPayload
|
||||
}
|
||||
|
||||
export function AskAIButton({ variant = 'floating', initialQuestion }: AskAIButtonProps) {
|
||||
const { isOpen, isMinimized, setIsOpen, setMinimized, close, toggleOpen } = useMoltbotStore()
|
||||
export function AskAIButton({ variant = 'floating' }: AskAIButtonProps) {
|
||||
const { isOpen, isMinimized, toggleOpen } = useMoltbotStore()
|
||||
const { allowed, isLoading } = useAccess({ allowGuests: true })
|
||||
const { language } = useLanguage()
|
||||
const isFloating = variant === 'floating'
|
||||
@ -27,15 +25,6 @@ export function AskAIButton({ variant = 'floating', initialQuestion }: AskAIButt
|
||||
const handleOpen = () => {
|
||||
toggleOpen()
|
||||
}
|
||||
const handleMinimize = () => {
|
||||
if (isFloating) {
|
||||
setMinimized(true)
|
||||
}
|
||||
setIsOpen(false)
|
||||
}
|
||||
const handleEnd = () => {
|
||||
close()
|
||||
}
|
||||
|
||||
const buttonClassName = cn(
|
||||
isFloating
|
||||
|
||||
@ -1,409 +1,101 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { marked } from 'marked'
|
||||
import DOMPurify from 'dompurify'
|
||||
import { Minus, X, Maximize2 } from 'lucide-react'
|
||||
import { ChatBubble } from './ChatBubble'
|
||||
import { SourceHint } from './SourceHint'
|
||||
import { useLanguage } from '@i18n/LanguageProvider'
|
||||
import { translations } from '@i18n/translations'
|
||||
import { Maximize2, X } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useMoltbotStore } from '@lib/moltbotStore'
|
||||
import { cn } from '@lib/utils'
|
||||
|
||||
const MAX_MESSAGES = 20
|
||||
const MAX_CACHE_SIZE = 50
|
||||
|
||||
function normalizeInput(text: string) {
|
||||
return text
|
||||
.trim()
|
||||
.replace(/[\s.,!?;:,。!?;:]+$/u, '')
|
||||
.replace(/```[\s\S]*?```/g, '')
|
||||
}
|
||||
|
||||
function renderMarkdown(text: string) {
|
||||
// marked.parse has a return type of string | Promise<string>
|
||||
// but in our usage it executes synchronously. Cast to string to
|
||||
// satisfy the DOMPurify.sanitize type expectations.
|
||||
return DOMPurify.sanitize(marked.parse(text) as string)
|
||||
}
|
||||
import { OpenClawAssistantPane } from '@/components/openclaw/OpenClawAssistantPane'
|
||||
import type { IntegrationDefaults } from '@/lib/openclaw/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type InitialQuestionPayload = {
|
||||
key: number
|
||||
text: string
|
||||
}
|
||||
|
||||
export function AskAIDialog({
|
||||
open,
|
||||
onMinimize,
|
||||
onEnd,
|
||||
initialQuestion
|
||||
}: {
|
||||
type AskAIDialogProps = {
|
||||
open: boolean
|
||||
defaults?: IntegrationDefaults
|
||||
onMinimize: () => void
|
||||
onEnd: () => void
|
||||
initialQuestion?: InitialQuestionPayload
|
||||
}) {
|
||||
const [question, setQuestion] = useState('')
|
||||
const [messages, setMessages] = useState<{ sender: 'user' | 'ai'; text: string }[]>([])
|
||||
const [sources, setSources] = useState<any[]>([])
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
const debounceRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const cacheRef = useRef(
|
||||
new Map<string, { answer: string; sources: any[]; timestamp: number }>()
|
||||
)
|
||||
const requestIdRef = useRef(0)
|
||||
const processedInitialRef = useRef<number | null>(null)
|
||||
}
|
||||
|
||||
const { language } = useLanguage()
|
||||
const t = translations[language].askAI
|
||||
export function AskAIDialog({
|
||||
open,
|
||||
defaults,
|
||||
onMinimize,
|
||||
onEnd,
|
||||
initialQuestion,
|
||||
}: AskAIDialogProps) {
|
||||
const router = useRouter()
|
||||
|
||||
const { mode, setMode } = useMoltbotStore()
|
||||
|
||||
function handleMaximize() {
|
||||
onEnd() // Close the dialog
|
||||
const url = `/services/openclaw/chats${question ? `?q=${encodeURIComponent(question)}` : ''}`
|
||||
router.push(url)
|
||||
const resolvedDefaults: IntegrationDefaults = defaults ?? {
|
||||
openclawUrl: '',
|
||||
openclawTokenConfigured: false,
|
||||
vaultUrl: '',
|
||||
vaultNamespace: '',
|
||||
vaultTokenConfigured: false,
|
||||
apisixUrl: '',
|
||||
apisixTokenConfigured: false,
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
abortRef.current?.abort()
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const streamChat = useCallback(async (
|
||||
url: string,
|
||||
body: any,
|
||||
update: (text: string, src?: any[]) => void,
|
||||
timeout = 15000
|
||||
) => {
|
||||
abortRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
let timer: NodeJS.Timeout | null = null
|
||||
const scheduleAbort = () => {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = setTimeout(() => controller.abort(), timeout)
|
||||
}
|
||||
scheduleAbort()
|
||||
abortRef.current = controller
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal
|
||||
})
|
||||
|
||||
if (!res.ok) throw new Error('Request failed')
|
||||
|
||||
const contentType = res.headers.get('content-type') || ''
|
||||
// Handle non-streaming JSON responses directly.
|
||||
if (!contentType.includes('text/event-stream')) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
let answer = typeof data === 'string' ? data : data.answer || ''
|
||||
let retrieved = data.chunks || data.sources || []
|
||||
update(answer, retrieved)
|
||||
return { answer, retrieved }
|
||||
}
|
||||
|
||||
const reader = res.body?.getReader()
|
||||
if (!reader) throw new Error('No reader')
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
let answer = ''
|
||||
let retrieved: any[] = []
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
scheduleAbort()
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const parts = buffer.split('\n\n')
|
||||
buffer = parts.pop() || ''
|
||||
|
||||
for (const part of parts) {
|
||||
const line = part.split('\n').find(l => l.startsWith('data:'))
|
||||
if (!line) {
|
||||
answer += part
|
||||
update(answer, retrieved)
|
||||
continue
|
||||
}
|
||||
const dataStr = line.replace(/^data: ?/, '').trim()
|
||||
if (dataStr === '[DONE]') continue
|
||||
try {
|
||||
const json = JSON.parse(dataStr)
|
||||
if (json.answer) answer += json.answer
|
||||
else if (typeof json === 'string') answer += json
|
||||
if (json.chunks) retrieved = json.chunks
|
||||
if (json.sources) retrieved = json.sources
|
||||
} catch {
|
||||
answer += dataStr
|
||||
}
|
||||
update(answer, retrieved)
|
||||
}
|
||||
}
|
||||
|
||||
update(answer, retrieved)
|
||||
return { answer, retrieved }
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer)
|
||||
if (abortRef.current === controller) abortRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const performAsk = useCallback(async (override?: string) => {
|
||||
const inputValue = override ?? question
|
||||
const normalized = normalizeInput(inputValue)
|
||||
if (!normalized) return
|
||||
const now = Date.now()
|
||||
const cached = cacheRef.current.get(normalized)
|
||||
if (cached && now - cached.timestamp < 10000) {
|
||||
cacheRef.current.delete(normalized)
|
||||
cacheRef.current.set(normalized, { ...cached, timestamp: now })
|
||||
const userMessage = {
|
||||
sender: 'user' as const,
|
||||
text: renderMarkdown(normalized)
|
||||
}
|
||||
const aiMessage = {
|
||||
sender: 'ai' as const,
|
||||
text: renderMarkdown(cached.answer)
|
||||
}
|
||||
setMessages(prev => [...prev, userMessage, aiMessage].slice(-MAX_MESSAGES))
|
||||
setSources(cached.sources)
|
||||
setQuestion('')
|
||||
return
|
||||
}
|
||||
|
||||
const id = ++requestIdRef.current
|
||||
const userMessage = {
|
||||
sender: 'user' as const,
|
||||
text: renderMarkdown(normalized)
|
||||
}
|
||||
const history = [...messages.slice(-MAX_MESSAGES + 1), userMessage]
|
||||
setMessages(prev => [...prev, userMessage, { sender: 'ai', text: '' }])
|
||||
setQuestion('')
|
||||
|
||||
const updateAI = (text: string, src?: any[]) => {
|
||||
if (id !== requestIdRef.current) return
|
||||
setMessages(prev => {
|
||||
const next = [...prev]
|
||||
next[next.length - 1] = { sender: 'ai', text: renderMarkdown(text) }
|
||||
return next
|
||||
})
|
||||
if (src) setSources(src)
|
||||
}
|
||||
|
||||
try {
|
||||
let ragAnswer = ''
|
||||
let ragRetrieved: any[] = []
|
||||
let ragError: any = null
|
||||
|
||||
try {
|
||||
const result = await streamChat(
|
||||
'/api/rag/query',
|
||||
{ question: normalized, history },
|
||||
updateAI
|
||||
)
|
||||
ragAnswer = result?.answer ?? ''
|
||||
ragRetrieved = Array.isArray(result?.retrieved) ? result.retrieved : []
|
||||
} catch (err: any) {
|
||||
if (err?.name === 'AbortError') {
|
||||
throw err
|
||||
}
|
||||
ragError = err
|
||||
}
|
||||
|
||||
if (ragError) {
|
||||
console.warn('RAG query failed, falling back to /api/askai', ragError)
|
||||
}
|
||||
|
||||
let answer = ragAnswer
|
||||
let retrieved = ragRetrieved
|
||||
|
||||
if (!answer || retrieved.length === 0 || ragError) {
|
||||
if (!ragError && !answer) {
|
||||
console.warn('RAG query returned empty answer, falling back to /api/askai')
|
||||
} else if (!ragError && retrieved.length === 0) {
|
||||
console.warn('RAG query returned no relevant chunks, falling back to /api/askai')
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await streamChat(
|
||||
'/api/askai',
|
||||
{ question: normalized, history },
|
||||
updateAI
|
||||
)
|
||||
if (result?.answer) {
|
||||
answer = result.answer
|
||||
}
|
||||
if (Array.isArray(result?.retrieved) && result.retrieved.length > 0) {
|
||||
retrieved = result.retrieved
|
||||
}
|
||||
} catch (fallbackError: any) {
|
||||
if (fallbackError?.name === 'AbortError') {
|
||||
throw fallbackError
|
||||
}
|
||||
console.error('Fallback /api/askai failed', fallbackError)
|
||||
if (!answer) {
|
||||
updateAI('Sorry, I could not find an answer at this time.')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (!answer) {
|
||||
answer = 'Sorry, I could not find an answer at this time.'
|
||||
updateAI(answer)
|
||||
return
|
||||
}
|
||||
if (retrieved.length === 0) {
|
||||
answer +=
|
||||
'\n\n_Note: No relevant documents were found; this answer may be inaccurate._'
|
||||
updateAI(answer)
|
||||
}
|
||||
}
|
||||
|
||||
if (cacheRef.current.size >= MAX_CACHE_SIZE) {
|
||||
const oldest = cacheRef.current.keys().next().value
|
||||
if (oldest !== undefined) {
|
||||
cacheRef.current.delete(oldest)
|
||||
}
|
||||
}
|
||||
cacheRef.current.set(normalized, {
|
||||
answer,
|
||||
sources: retrieved,
|
||||
timestamp: now
|
||||
})
|
||||
} catch (err: any) {
|
||||
if (id !== requestIdRef.current) return
|
||||
let message = 'Something went wrong. Please try again later.'
|
||||
if (err.name === 'AbortError') message = 'Request was cancelled.'
|
||||
else if (err.message?.includes('Failed to fetch'))
|
||||
message = 'Network error. Please check your connection.'
|
||||
else if (err.message) message = err.message
|
||||
updateAI(message)
|
||||
}
|
||||
}, [messages, question, streamChat])
|
||||
|
||||
function handleAsk() {
|
||||
abortRef.current?.abort()
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
debounceRef.current = setTimeout(() => performAsk(), 300)
|
||||
}
|
||||
|
||||
function handleEnd() {
|
||||
setMessages([])
|
||||
setQuestion('')
|
||||
setSources([])
|
||||
processedInitialRef.current = null
|
||||
function handleMaximize(): void {
|
||||
onEnd()
|
||||
const query = initialQuestion?.text?.trim()
|
||||
? `?q=${encodeURIComponent(initialQuestion.text.trim())}`
|
||||
: ''
|
||||
router.push(`/services/openclaw${query}`)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
processedInitialRef.current = null
|
||||
return
|
||||
}
|
||||
if (!initialQuestion) return
|
||||
if (processedInitialRef.current === initialQuestion.key) return
|
||||
const normalizedInitial = normalizeInput(initialQuestion.text)
|
||||
if (!normalizedInitial) return
|
||||
processedInitialRef.current = initialQuestion.key
|
||||
setQuestion(normalizedInitial)
|
||||
performAsk(normalizedInitial)
|
||||
}, [initialQuestion, open, performAsk])
|
||||
|
||||
|
||||
// if (!open) return null // Removed to allow animation/persistence if needed, or controlled by parent
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
cn(
|
||||
"z-[40] transition-all duration-300 ease-in-out fixed right-0 bottom-0 border-l border-surface-border bg-background/95 backdrop-blur shadow-xl",
|
||||
!open && "translate-x-full" // Slide out instead of unmounting if we want animation, but the component returns null if !open. Let's change it to always render but slide.
|
||||
// Actually, the current component returns null if !open. Let's keep it simple for now and just change the styling to be non-modal.
|
||||
)
|
||||
}
|
||||
className={cn(
|
||||
'fixed bottom-0 right-0 z-[40] border-l border-[color:var(--color-surface-border)] bg-[var(--color-background)]/95 shadow-xl backdrop-blur',
|
||||
)}
|
||||
style={{
|
||||
width: '400px',
|
||||
top: 'var(--app-shell-nav-offset, 64px)', // Fallback to 64px
|
||||
top: 'var(--app-shell-nav-offset, 64px)',
|
||||
height: 'calc(100vh - var(--app-shell-nav-offset, 64px))',
|
||||
display: open ? 'block' : 'none' // Or we can rely on paren passing 'open' boolean.
|
||||
// The original component returned null if !open. To support animation we'd need to change that.
|
||||
// But for "persistent sidebar", simply showing/hiding is fine.
|
||||
display: open ? 'block' : 'none',
|
||||
}}
|
||||
>
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<div className="flex items-center justify-between gap-3 border-b border-surface-border px-4 py-3 bg-surface-muted/30">
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="flex items-center justify-between gap-3 border-b border-[color:var(--color-surface-border)] px-4 py-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-text-muted">{t.title}</p>
|
||||
<h2 className="text-sm font-bold text-text">{t.subtitle}</h2>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--color-text-subtle)]">
|
||||
OpenClaw
|
||||
</p>
|
||||
<h2 className="text-sm font-semibold text-[var(--color-heading)]">AI Assistant</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-text-muted">
|
||||
|
||||
<div className="flex items-center gap-1 text-[var(--color-text-subtle)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleMaximize}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg hover:bg-surface-muted transition-colors"
|
||||
title="Full Screen"
|
||||
className="rounded-xl p-2 transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-text)]"
|
||||
title="Open workspace"
|
||||
>
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onMinimize} // This acts as close/toggle for the sidebar
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg hover:bg-surface-muted transition-colors"
|
||||
title="Close Sidebar"
|
||||
type="button"
|
||||
onClick={onMinimize}
|
||||
className="rounded-xl p-2 transition hover:bg-[var(--color-surface-muted)] hover:text-[var(--color-text)]"
|
||||
title="Close sidebar"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-4 overflow-y-auto px-4 py-5 text-text">
|
||||
{messages.length === 0 && (
|
||||
<div className="text-center text-sm text-text-muted mt-10">
|
||||
<p>How can I help you today?</p>
|
||||
</div>
|
||||
)}
|
||||
{messages.map((m, idx) => (
|
||||
<ChatBubble key={idx} message={m.text} type={m.sender} />
|
||||
))}
|
||||
{sources.length > 0 && <SourceHint sources={sources} />}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-surface-border px-4 py-3 bg-background">
|
||||
<div className="relative rounded-xl border border-surface-border bg-surface-muted/30 shadow-sm focus-within:border-primary/50 focus-within:ring-1 focus-within:ring-primary/20 transition-all">
|
||||
<textarea
|
||||
className="w-full resize-none bg-transparent p-3 text-sm text-text outline-none placeholder:text-text-muted/70"
|
||||
rows={3}
|
||||
placeholder={t.placeholder}
|
||||
value={question}
|
||||
onChange={e => setQuestion(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleAsk()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center justify-between px-2 pb-2">
|
||||
<div />
|
||||
<button
|
||||
onClick={handleAsk}
|
||||
className="flex items-center justify-center rounded-lg bg-primary px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-primary-hover disabled:opacity-50"
|
||||
disabled={!question.trim()}
|
||||
>
|
||||
{t.ask}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1">
|
||||
<OpenClawAssistantPane
|
||||
defaults={resolvedDefaults}
|
||||
initialQuestion={initialQuestion?.text}
|
||||
initialQuestionKey={initialQuestion?.key}
|
||||
variant="sidebar"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div >
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -274,7 +274,7 @@ export default function Navbar() {
|
||||
key: "chat",
|
||||
label: labels.chat,
|
||||
icon: MessageSquare,
|
||||
href: "/services/openclaw/chats",
|
||||
href: "/services/openclaw",
|
||||
active: pathname?.startsWith("/services/openclaw"),
|
||||
},
|
||||
{
|
||||
@ -655,7 +655,7 @@ export default function Navbar() {
|
||||
<div className="flex-1 p-4">
|
||||
<div className="space-y-1">
|
||||
<Link
|
||||
href="/services/openclaw/chats"
|
||||
href="/services/openclaw"
|
||||
className={`flex items-center px-4 py-3 rounded-xl text-sm font-medium transition-colors ${pathname?.startsWith("/services/openclaw")
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-text hover:bg-surface-muted"
|
||||
|
||||
836
src/components/openclaw/OpenClawAssistantPane.tsx
Normal file
836
src/components/openclaw/OpenClawAssistantPane.tsx
Normal file
@ -0,0 +1,836 @@
|
||||
'use client'
|
||||
|
||||
import { type ChangeEvent, type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import DOMPurify from 'dompurify'
|
||||
import html2canvas from 'html2canvas'
|
||||
import { marked } from 'marked'
|
||||
import {
|
||||
Bot,
|
||||
BrainCircuit,
|
||||
Camera,
|
||||
ChevronRight,
|
||||
Link2,
|
||||
Loader2,
|
||||
Paperclip,
|
||||
RefreshCw,
|
||||
SendHorizonal,
|
||||
Settings2,
|
||||
Sparkles,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
makeAgentSessionKey,
|
||||
type GatewayAgentSummary,
|
||||
type GatewayChatAttachmentPayload,
|
||||
type GatewayChatMessage,
|
||||
type GatewaySessionSummary,
|
||||
type IntegrationDefaults,
|
||||
type OpenClawBootstrapResponse,
|
||||
type OpenClawStreamEvent,
|
||||
} from '@/lib/openclaw/types'
|
||||
import { useOpenClawConsoleStore } from '@/state/openclawConsoleStore'
|
||||
|
||||
type OpenClawAssistantPaneProps = {
|
||||
defaults: IntegrationDefaults
|
||||
initialQuestion?: string
|
||||
initialQuestionKey?: number
|
||||
variant?: 'page' | 'sidebar'
|
||||
}
|
||||
|
||||
type ComposerAttachment = GatewayChatAttachmentPayload & {
|
||||
id: string
|
||||
size: number
|
||||
previewUrl?: string
|
||||
}
|
||||
|
||||
type ConnectionState = 'idle' | 'connecting' | 'ready' | 'error'
|
||||
|
||||
const QUICK_ACTIONS = ['写代码', '分析日志', '梳理方案', '排查部署', '生成步骤']
|
||||
const THINKING_OPTIONS = [
|
||||
{ value: 'low', label: 'Low' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
{ value: 'high', label: 'High' },
|
||||
{ value: 'max', label: 'Max' },
|
||||
] as const
|
||||
const MODE_OPTIONS = [
|
||||
{ value: 'ask', label: 'Ask' },
|
||||
{ value: 'craft', label: 'Craft' },
|
||||
{ value: 'plan', label: 'Plan' },
|
||||
] as const
|
||||
|
||||
function renderMarkdown(value: string): string {
|
||||
return DOMPurify.sanitize(marked.parse(value) as string)
|
||||
}
|
||||
|
||||
function randomId(): string {
|
||||
return `${Date.now().toString(16)}-${Math.random().toString(16).slice(2, 10)}`
|
||||
}
|
||||
|
||||
function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||
let binary = ''
|
||||
const bytes = new Uint8Array(buffer)
|
||||
for (const value of bytes) {
|
||||
binary += String.fromCharCode(value)
|
||||
}
|
||||
return window.btoa(binary)
|
||||
}
|
||||
|
||||
function formatTimestamp(value?: number): string {
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
try {
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(new Date(value))
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function composePrompt(params: {
|
||||
mode: 'ask' | 'craft' | 'plan'
|
||||
prompt: string
|
||||
attachments: ComposerAttachment[]
|
||||
}): string {
|
||||
const attachmentBlock = params.attachments.length
|
||||
? `Attached files:\n${params.attachments.map((item) => `- ${item.fileName}`).join('\n')}\n\n`
|
||||
: ''
|
||||
|
||||
switch (params.mode) {
|
||||
case 'craft':
|
||||
return `${attachmentBlock}Craft a polished result for this request:\n${params.prompt}`
|
||||
case 'plan':
|
||||
return `${attachmentBlock}Create a clear execution plan for this task:\n${params.prompt}`
|
||||
default:
|
||||
return `${attachmentBlock}${params.prompt}`
|
||||
}
|
||||
}
|
||||
|
||||
function pickAutoAgent(agents: GatewayAgentSummary[], prompt: string): GatewayAgentSummary | undefined {
|
||||
const input = prompt.toLowerCase()
|
||||
|
||||
const findByName = (name: string) =>
|
||||
agents.find((agent) => agent.name.toLowerCase().includes(name) || agent.id.toLowerCase().includes(name))
|
||||
|
||||
if (
|
||||
input.includes('browser') ||
|
||||
input.includes('website') ||
|
||||
input.includes('网页') ||
|
||||
input.includes('抓取')
|
||||
) {
|
||||
return findByName('browser')
|
||||
}
|
||||
|
||||
if (
|
||||
input.includes('research') ||
|
||||
input.includes('analysis') ||
|
||||
input.includes('compare') ||
|
||||
input.includes('分析') ||
|
||||
input.includes('调研')
|
||||
) {
|
||||
return findByName('research')
|
||||
}
|
||||
|
||||
if (
|
||||
input.includes('code') ||
|
||||
input.includes('deploy') ||
|
||||
input.includes('log') ||
|
||||
input.includes('bug') ||
|
||||
input.includes('代码') ||
|
||||
input.includes('部署') ||
|
||||
input.includes('日志')
|
||||
) {
|
||||
return findByName('coding')
|
||||
}
|
||||
|
||||
return findByName('coding') ?? findByName('research') ?? findByName('browser')
|
||||
}
|
||||
|
||||
async function fileToAttachment(file: File): Promise<ComposerAttachment> {
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const content = arrayBufferToBase64(arrayBuffer)
|
||||
|
||||
return {
|
||||
id: randomId(),
|
||||
type: file.type.startsWith('image/') ? 'image' : 'file',
|
||||
mimeType: file.type || 'application/octet-stream',
|
||||
fileName: file.name,
|
||||
content,
|
||||
size: file.size,
|
||||
previewUrl: file.type.startsWith('image/') ? URL.createObjectURL(file) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function OpenClawAssistantPane({
|
||||
defaults,
|
||||
initialQuestion,
|
||||
initialQuestionKey,
|
||||
variant = 'page',
|
||||
}: OpenClawAssistantPaneProps) {
|
||||
const router = useRouter()
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
const bootstrappedRef = useRef(false)
|
||||
const lastInitialQuestionKeyRef = useRef<number | null>(null)
|
||||
|
||||
const [connectionState, setConnectionState] = useState<ConnectionState>('idle')
|
||||
const [gatewayStatus, setGatewayStatus] = useState<Record<string, unknown>>({})
|
||||
const [gatewayHealth, setGatewayHealth] = useState<Record<string, unknown>>({})
|
||||
const [gatewayTokenSource, setGatewayTokenSource] = useState<'env' | 'request' | 'none'>('none')
|
||||
const [mainSessionKey, setMainSessionKey] = useState('main')
|
||||
const [messages, setMessages] = useState<GatewayChatMessage[]>([])
|
||||
const [sessions, setSessions] = useState<GatewaySessionSummary[]>([])
|
||||
const [agents, setAgents] = useState<GatewayAgentSummary[]>([])
|
||||
const [streamingText, setStreamingText] = useState('')
|
||||
const [composerValue, setComposerValue] = useState(initialQuestion ?? '')
|
||||
const [attachments, setAttachments] = useState<ComposerAttachment[]>([])
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
const [isSending, setIsSending] = useState(false)
|
||||
const [isCapturing, setIsCapturing] = useState(false)
|
||||
|
||||
const defaultsLoaded = useOpenClawConsoleStore((state) => state.defaultsLoaded)
|
||||
const applyDefaults = useOpenClawConsoleStore((state) => state.applyDefaults)
|
||||
const openclawUrl = useOpenClawConsoleStore((state) => state.openclawUrl)
|
||||
const openclawToken = useOpenClawConsoleStore((state) => state.openclawToken)
|
||||
const assistantMode = useOpenClawConsoleStore((state) => state.assistantMode)
|
||||
const thinking = useOpenClawConsoleStore((state) => state.thinking)
|
||||
const selectedAgentId = useOpenClawConsoleStore((state) => state.selectedAgentId)
|
||||
const selectedSessionKey = useOpenClawConsoleStore((state) => state.selectedSessionKey)
|
||||
const setAssistantMode = useOpenClawConsoleStore((state) => state.setAssistantMode)
|
||||
const setThinking = useOpenClawConsoleStore((state) => state.setThinking)
|
||||
const setSelectedAgentId = useOpenClawConsoleStore((state) => state.setSelectedAgentId)
|
||||
const setSelectedSessionKey = useOpenClawConsoleStore((state) => state.setSelectedSessionKey)
|
||||
|
||||
const compact = variant === 'sidebar'
|
||||
|
||||
useEffect(() => {
|
||||
applyDefaults(defaults)
|
||||
}, [applyDefaults, defaults])
|
||||
|
||||
const activeSession = useMemo(
|
||||
() => sessions.find((session) => session.key === selectedSessionKey),
|
||||
[selectedSessionKey, sessions],
|
||||
)
|
||||
|
||||
const healthBadge = useMemo(() => {
|
||||
const serverHealth = gatewayHealth.status
|
||||
const connectionSummary = gatewayStatus.connection
|
||||
|
||||
if (typeof serverHealth === 'string' && serverHealth.trim().length > 0) {
|
||||
return serverHealth
|
||||
}
|
||||
|
||||
if (typeof connectionSummary === 'string' && connectionSummary.trim().length > 0) {
|
||||
return connectionSummary
|
||||
}
|
||||
|
||||
switch (connectionState) {
|
||||
case 'ready':
|
||||
return 'online'
|
||||
case 'connecting':
|
||||
return 'connecting'
|
||||
case 'error':
|
||||
return 'error'
|
||||
default:
|
||||
return 'offline'
|
||||
}
|
||||
}, [connectionState, gatewayHealth.status, gatewayStatus.connection])
|
||||
|
||||
const renderedMessages = useMemo(
|
||||
() =>
|
||||
messages.map((message) => ({
|
||||
...message,
|
||||
html: renderMarkdown(message.text || ''),
|
||||
})),
|
||||
[messages],
|
||||
)
|
||||
|
||||
const connectGateway = useCallback(async (nextSessionKey?: string, nextAgentId?: string): Promise<void> => {
|
||||
if (!openclawUrl.trim()) {
|
||||
setConnectionState('error')
|
||||
setErrorMessage('未配置 OpenClaw gateway 地址,请先到接口集成页面填写。')
|
||||
return
|
||||
}
|
||||
|
||||
setConnectionState('connecting')
|
||||
setErrorMessage('')
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/openclaw/assistant', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action: 'bootstrap',
|
||||
gatewayUrl: openclawUrl,
|
||||
gatewayToken: openclawToken,
|
||||
agentId: nextAgentId ?? selectedAgentId,
|
||||
sessionKey: nextSessionKey ?? selectedSessionKey,
|
||||
}),
|
||||
})
|
||||
|
||||
const payload = (await response.json()) as OpenClawBootstrapResponse | { error?: string }
|
||||
|
||||
if (!response.ok || 'error' in payload) {
|
||||
throw new Error((payload as { error?: string }).error || 'Failed to bootstrap assistant.')
|
||||
}
|
||||
|
||||
const data = payload as OpenClawBootstrapResponse
|
||||
|
||||
setConnectionState('ready')
|
||||
setAgents(data.agents)
|
||||
setSessions(data.sessions)
|
||||
setMessages(data.messages)
|
||||
setGatewayStatus(data.statusPayload)
|
||||
setGatewayHealth(data.healthPayload)
|
||||
setGatewayTokenSource(data.tokenSource)
|
||||
setMainSessionKey(data.mainSessionKey)
|
||||
setSelectedSessionKey(data.activeSessionKey)
|
||||
setStreamingText('')
|
||||
} catch (error) {
|
||||
setConnectionState('error')
|
||||
setErrorMessage(error instanceof Error ? error.message : 'Failed to connect to OpenClaw gateway.')
|
||||
}
|
||||
}, [openclawToken, openclawUrl, selectedAgentId, selectedSessionKey, setSelectedSessionKey])
|
||||
|
||||
async function addFiles(files: FileList | File[]): Promise<void> {
|
||||
const nextAttachments = await Promise.all(Array.from(files).map((file) => fileToAttachment(file)))
|
||||
setAttachments((current) => [...current, ...nextAttachments])
|
||||
}
|
||||
|
||||
async function capturePage(): Promise<void> {
|
||||
setIsCapturing(true)
|
||||
setErrorMessage('')
|
||||
|
||||
try {
|
||||
const canvas = await html2canvas(document.body, {
|
||||
backgroundColor: null,
|
||||
scale: Math.min(window.devicePixelRatio || 1, 2),
|
||||
logging: false,
|
||||
useCORS: true,
|
||||
})
|
||||
|
||||
const blob = await new Promise<Blob | null>((resolve) => {
|
||||
canvas.toBlob(resolve, 'image/png', 0.95)
|
||||
})
|
||||
|
||||
if (!blob) {
|
||||
throw new Error('截图生成失败。')
|
||||
}
|
||||
|
||||
const attachment = await fileToAttachment(
|
||||
new File([blob], `console-capture-${Date.now()}.png`, { type: 'image/png' }),
|
||||
)
|
||||
|
||||
setAttachments((current) => [...current, attachment])
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : 'Failed to capture screenshot.')
|
||||
} finally {
|
||||
setIsCapturing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const sendMessage = useCallback(async (promptOverride?: string): Promise<void> => {
|
||||
const rawPrompt = (promptOverride ?? composerValue).trim()
|
||||
if (!rawPrompt && attachments.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const autoAgent = pickAutoAgent(agents, rawPrompt)
|
||||
const effectiveAgentId = selectedAgentId || autoAgent?.id || ''
|
||||
const effectiveSessionKey =
|
||||
selectedSessionKey ||
|
||||
makeAgentSessionKey(effectiveAgentId, mainSessionKey)
|
||||
|
||||
const prompt = composePrompt({
|
||||
mode: assistantMode,
|
||||
prompt: rawPrompt || 'See attached.',
|
||||
attachments,
|
||||
})
|
||||
|
||||
setErrorMessage('')
|
||||
setIsSending(true)
|
||||
setStreamingText('')
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
{
|
||||
id: randomId(),
|
||||
role: 'user',
|
||||
text: rawPrompt || 'See attached.',
|
||||
timestampMs: Date.now(),
|
||||
},
|
||||
])
|
||||
setComposerValue('')
|
||||
|
||||
if (effectiveAgentId && effectiveAgentId !== selectedAgentId) {
|
||||
setSelectedAgentId(effectiveAgentId)
|
||||
}
|
||||
|
||||
if (effectiveSessionKey !== selectedSessionKey) {
|
||||
setSelectedSessionKey(effectiveSessionKey)
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/openclaw/assistant', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action: 'send',
|
||||
gatewayUrl: openclawUrl,
|
||||
gatewayToken: openclawToken,
|
||||
agentId: effectiveAgentId,
|
||||
sessionKey: effectiveSessionKey,
|
||||
message: prompt,
|
||||
thinking,
|
||||
attachments,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
const payload = await response.json().catch(() => ({ error: 'Failed to send message.' }))
|
||||
throw new Error(payload.error || 'Failed to send message.')
|
||||
}
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read()
|
||||
if (done) {
|
||||
break
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ''
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) {
|
||||
continue
|
||||
}
|
||||
|
||||
const event = JSON.parse(trimmed) as OpenClawStreamEvent
|
||||
|
||||
if (event.type === 'delta') {
|
||||
setStreamingText(event.text)
|
||||
continue
|
||||
}
|
||||
|
||||
if (event.type === 'final') {
|
||||
setMessages(event.messages)
|
||||
setSessions(event.sessions)
|
||||
setStreamingText('')
|
||||
setSelectedSessionKey(effectiveSessionKey)
|
||||
if (event.errorMessage) {
|
||||
setErrorMessage(event.errorMessage)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (event.type === 'error') {
|
||||
setErrorMessage(event.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : 'Failed to send message.')
|
||||
setStreamingText('')
|
||||
} finally {
|
||||
setAttachments([])
|
||||
setIsSending(false)
|
||||
textareaRef.current?.focus()
|
||||
}
|
||||
}, [
|
||||
agents,
|
||||
assistantMode,
|
||||
attachments,
|
||||
composerValue,
|
||||
mainSessionKey,
|
||||
openclawToken,
|
||||
openclawUrl,
|
||||
selectedAgentId,
|
||||
selectedSessionKey,
|
||||
setSelectedAgentId,
|
||||
setSelectedSessionKey,
|
||||
thinking,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (!defaultsLoaded || bootstrappedRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!openclawUrl.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
bootstrappedRef.current = true
|
||||
void connectGateway()
|
||||
}, [connectGateway, defaultsLoaded, openclawUrl])
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialQuestion || connectionState !== 'ready') {
|
||||
return
|
||||
}
|
||||
|
||||
const resolvedKey = initialQuestionKey ?? 1
|
||||
if (lastInitialQuestionKeyRef.current === resolvedKey) {
|
||||
return
|
||||
}
|
||||
|
||||
lastInitialQuestionKeyRef.current = resolvedKey
|
||||
setComposerValue(initialQuestion)
|
||||
void sendMessage(initialQuestion)
|
||||
}, [connectionState, initialQuestion, initialQuestionKey, sendMessage])
|
||||
|
||||
function handleTextareaKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
void sendMessage()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAttachmentChange(event: ChangeEvent<HTMLInputElement>): Promise<void> {
|
||||
if (!event.target.files?.length) {
|
||||
return
|
||||
}
|
||||
await addFiles(event.target.files)
|
||||
event.target.value = ''
|
||||
}
|
||||
|
||||
const containerClassName = cn(
|
||||
'flex h-full min-h-0 flex-col overflow-hidden rounded-[var(--radius-2xl)] border border-[color:var(--color-surface-border)] bg-[var(--color-surface-elevated)] shadow-[var(--shadow-md)]',
|
||||
compact ? 'rounded-none border-0 shadow-none' : '',
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={containerClassName}>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*,.log,.txt,.md,.json,.yaml,.yml,.pdf"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
void handleAttachmentChange(event)
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 border-b border-[color:var(--color-surface-border)] px-4 py-3">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-[color:var(--color-surface-border)] bg-[var(--color-surface-muted)] px-3 py-1 text-xs font-medium text-[var(--color-text-subtle)]">
|
||||
<span
|
||||
className={cn(
|
||||
'h-2.5 w-2.5 rounded-full',
|
||||
connectionState === 'ready'
|
||||
? 'bg-emerald-500'
|
||||
: connectionState === 'connecting'
|
||||
? 'bg-amber-400'
|
||||
: connectionState === 'error'
|
||||
? 'bg-rose-500'
|
||||
: 'bg-[var(--color-text-subtle)]/40',
|
||||
)}
|
||||
/>
|
||||
{healthBadge}
|
||||
<span className="text-[var(--color-text-subtle)]/60">·</span>
|
||||
{gatewayTokenSource === 'env' ? 'env token' : gatewayTokenSource === 'request' ? 'session token' : 'no token'}
|
||||
</div>
|
||||
|
||||
<div className="min-w-[180px] flex-1">
|
||||
<select
|
||||
value={selectedAgentId}
|
||||
onChange={(event) => {
|
||||
setSelectedAgentId(event.target.value)
|
||||
setSelectedSessionKey('')
|
||||
void connectGateway('', event.target.value)
|
||||
}}
|
||||
className="w-full rounded-full border border-[color:var(--color-surface-border)] bg-[var(--color-surface)] px-3 py-2 text-sm text-[var(--color-text)] outline-none transition focus:border-[color:var(--color-primary)]"
|
||||
>
|
||||
<option value="">Main agent</option>
|
||||
{agents.map((agent) => (
|
||||
<option key={agent.id} value={agent.id}>
|
||||
{agent.emoji ? `${agent.emoji} ` : ''}
|
||||
{agent.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void connectGateway()
|
||||
}}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-[color:var(--color-surface-border)] px-3 py-2 text-xs font-semibold text-[var(--color-text)] transition hover:border-[color:var(--color-primary-border)] hover:bg-[var(--color-surface-muted)]"
|
||||
>
|
||||
{connectionState === 'connecting' ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
|
||||
Reconnect
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/panel/api')}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-[color:var(--color-primary-border)] bg-[var(--color-primary-muted)] px-3 py-2 text-xs font-semibold text-[var(--color-primary)] transition hover:opacity-90"
|
||||
>
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
Integrations
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="border-b border-[color:var(--color-surface-border)] px-4 py-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{sessions.slice(0, compact ? 4 : 8).map((session) => (
|
||||
<button
|
||||
key={session.key}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedSessionKey(session.key)
|
||||
void connectGateway(session.key)
|
||||
}}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs transition',
|
||||
session.key === selectedSessionKey
|
||||
? 'border-[color:var(--color-primary)] bg-[var(--color-primary-muted)] text-[var(--color-primary)]'
|
||||
: 'border-[color:var(--color-surface-border)] bg-[var(--color-surface)] text-[var(--color-text-subtle)] hover:border-[color:var(--color-primary-border)]',
|
||||
)}
|
||||
>
|
||||
<Bot className="h-3.5 w-3.5" />
|
||||
<span>{session.derivedTitle || session.displayName || session.key}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 py-4">
|
||||
{!openclawUrl.trim() ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 rounded-[var(--radius-2xl)] border border-dashed border-[color:var(--color-surface-border)] bg-[var(--color-surface-muted)]/40 px-6 text-center">
|
||||
<Sparkles className="h-8 w-8 text-[var(--color-primary)]" />
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-base font-semibold text-[var(--color-heading)]">配置 OpenClaw gateway</h3>
|
||||
<p className="text-sm text-[var(--color-text-subtle)]">
|
||||
当前没有可用的 OpenClaw 地址。先到集成页填写 gateway / vault / APISIX,再回来启动助手。
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/panel/api')}
|
||||
className="inline-flex items-center gap-2 rounded-full bg-[var(--color-primary)] px-4 py-2 text-sm font-semibold text-[var(--color-primary-foreground)]"
|
||||
>
|
||||
打开接口集成
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : renderedMessages.length === 0 && !streamingText ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-5 rounded-[var(--radius-2xl)] border border-dashed border-[color:var(--color-surface-border)] bg-[var(--color-surface-muted)]/40 px-6 text-center">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-[var(--color-primary-muted)] text-[var(--color-primary)]">
|
||||
<Sparkles className="h-7 w-7" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-base font-semibold text-[var(--color-heading)]">OpenClaw Assistant</h3>
|
||||
<p className="text-sm text-[var(--color-text-subtle)]">
|
||||
侧栏模式与主页布局保持不变,消息将直接送到 OpenClaw gateway。你可以上传文件、贴图,或直接截当前页给助手分析。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{QUICK_ACTIONS.map((action) => (
|
||||
<button
|
||||
key={action}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setComposerValue(action)
|
||||
textareaRef.current?.focus()
|
||||
}}
|
||||
className="rounded-full border border-[color:var(--color-surface-border)] bg-[var(--color-surface)] px-3 py-1.5 text-xs font-medium text-[var(--color-text-subtle)] transition hover:border-[color:var(--color-primary-border)] hover:text-[var(--color-primary)]"
|
||||
>
|
||||
{action}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{renderedMessages.map((message) => {
|
||||
const isUser = message.role === 'user'
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
className={cn('flex', isUser ? 'justify-end' : 'justify-start')}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'max-w-[88%] rounded-[var(--radius-xl)] px-4 py-3 shadow-[var(--shadow-sm)]',
|
||||
isUser
|
||||
? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]'
|
||||
: 'border border-[color:var(--color-surface-border)] bg-[var(--color-surface)] text-[var(--color-text)]',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'prose prose-sm max-w-none break-words whitespace-pre-wrap',
|
||||
isUser ? 'prose-invert' : '',
|
||||
)}
|
||||
dangerouslySetInnerHTML={{ __html: message.html }}
|
||||
/>
|
||||
{message.timestampMs ? (
|
||||
<p className={cn('mt-2 text-[11px]', isUser ? 'text-white/70' : 'text-[var(--color-text-subtle)]')}>
|
||||
{formatTimestamp(message.timestampMs)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{streamingText ? (
|
||||
<div className="flex justify-start">
|
||||
<div className="max-w-[88%] rounded-[var(--radius-xl)] border border-[color:var(--color-surface-border)] bg-[var(--color-surface)] px-4 py-3 text-[var(--color-text)] shadow-[var(--shadow-sm)]">
|
||||
<div
|
||||
className="prose prose-sm max-w-none break-words whitespace-pre-wrap"
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(streamingText) }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[color:var(--color-surface-border)] px-4 py-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{MODE_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => setAssistantMode(option.value)}
|
||||
className={cn(
|
||||
'rounded-full px-3 py-1.5 text-xs font-semibold transition',
|
||||
assistantMode === option.value
|
||||
? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]'
|
||||
: 'border border-[color:var(--color-surface-border)] text-[var(--color-text-subtle)] hover:border-[color:var(--color-primary-border)]',
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div className="ml-auto flex items-center gap-2 rounded-full border border-[color:var(--color-surface-border)] bg-[var(--color-surface)] px-3 py-1.5 text-xs text-[var(--color-text-subtle)]">
|
||||
<BrainCircuit className="h-3.5 w-3.5" />
|
||||
<select
|
||||
value={thinking}
|
||||
onChange={(event) => setThinking(event.target.value as typeof thinking)}
|
||||
className="bg-transparent text-xs outline-none"
|
||||
>
|
||||
{THINKING_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{attachments.length > 0 ? (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{attachments.map((attachment) => (
|
||||
<div
|
||||
key={attachment.id}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-[color:var(--color-surface-border)] bg-[var(--color-surface-muted)] px-3 py-1.5 text-xs text-[var(--color-text)]"
|
||||
>
|
||||
{attachment.type === 'image' ? <Camera className="h-3.5 w-3.5" /> : <Paperclip className="h-3.5 w-3.5" />}
|
||||
<span>{attachment.fileName}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setAttachments((current) => current.filter((item) => item.id !== attachment.id))
|
||||
}}
|
||||
className="text-[var(--color-text-subtle)] transition hover:text-[var(--color-text)]"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{errorMessage ? (
|
||||
<div className="mt-3 rounded-[var(--radius-xl)] border border-[color:var(--color-danger-border)] bg-[var(--color-danger-muted)]/40 px-3 py-2 text-sm text-[var(--color-danger-foreground)]">
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-3 rounded-[var(--radius-2xl)] border border-[color:var(--color-surface-border)] bg-[var(--color-surface)] p-3 shadow-[var(--shadow-sm)]">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={compact ? 4 : 5}
|
||||
value={composerValue}
|
||||
placeholder="向 OpenClaw 助手描述任务,或先截个图再发。"
|
||||
onChange={(event) => setComposerValue(event.target.value)}
|
||||
onKeyDown={handleTextareaKeyDown}
|
||||
onPaste={(event) => {
|
||||
const clipboardFiles = Array.from(event.clipboardData.files)
|
||||
if (clipboardFiles.length > 0) {
|
||||
event.preventDefault()
|
||||
void addFiles(clipboardFiles)
|
||||
}
|
||||
}}
|
||||
className="w-full resize-none bg-transparent text-sm leading-6 text-[var(--color-text)] outline-none placeholder:text-[var(--color-text-subtle)]/70"
|
||||
/>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-[color:var(--color-surface-border)] px-3 py-1.5 text-xs font-semibold text-[var(--color-text)] transition hover:border-[color:var(--color-primary-border)] hover:bg-[var(--color-surface-muted)]"
|
||||
>
|
||||
<Paperclip className="h-3.5 w-3.5" />
|
||||
附件
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void capturePage()
|
||||
}}
|
||||
disabled={isCapturing}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-[color:var(--color-surface-border)] px-3 py-1.5 text-xs font-semibold text-[var(--color-text)] transition hover:border-[color:var(--color-primary-border)] hover:bg-[var(--color-surface-muted)] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{isCapturing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Camera className="h-3.5 w-3.5" />}
|
||||
当前页截图
|
||||
</button>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2 text-xs text-[var(--color-text-subtle)]">
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
<span>{activeSession?.derivedTitle || activeSession?.displayName || selectedSessionKey || 'main'}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void sendMessage()
|
||||
}}
|
||||
disabled={isSending || (!composerValue.trim() && attachments.length === 0)}
|
||||
className="inline-flex items-center gap-2 rounded-full bg-[var(--color-primary)] px-4 py-2 text-sm font-semibold text-[var(--color-primary-foreground)] transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{isSending ? <Loader2 className="h-4 w-4 animate-spin" /> : <SendHorizonal className="h-4 w-4" />}
|
||||
发送
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
113
src/components/openclaw/OpenClawWorkspacePage.tsx
Normal file
113
src/components/openclaw/OpenClawWorkspacePage.tsx
Normal file
@ -0,0 +1,113 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { Maximize2, PanelLeft, PanelRight, X } from 'lucide-react'
|
||||
|
||||
import Footer from '@components/Footer'
|
||||
import { HeroSection, NextStepsSection, ShortcutsSection, StatsSection } from '@/app/page'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { IntegrationDefaults } from '@/lib/openclaw/types'
|
||||
|
||||
import { OpenClawAssistantPane } from './OpenClawAssistantPane'
|
||||
|
||||
type ChatLayoutMode = 'left' | 'right' | 'full'
|
||||
|
||||
type OpenClawWorkspacePageProps = {
|
||||
defaults: IntegrationDefaults
|
||||
}
|
||||
|
||||
export function OpenClawWorkspacePage({ defaults }: OpenClawWorkspacePageProps) {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const [layout, setLayout] = useState<ChatLayoutMode>('full')
|
||||
const initialQuestion = searchParams.get('q') ?? undefined
|
||||
|
||||
const homeContent = (
|
||||
<main className="space-y-12 py-10">
|
||||
<HeroSection />
|
||||
<NextStepsSection />
|
||||
<StatsSection />
|
||||
<ShortcutsSection />
|
||||
<Footer />
|
||||
</main>
|
||||
)
|
||||
|
||||
const assistantPane = (
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden rounded-[var(--radius-2xl)] border border-[color:var(--color-surface-border)] bg-[var(--color-surface-elevated)] shadow-[var(--shadow-md)]">
|
||||
<div className="flex items-center justify-between gap-3 border-b border-[color:var(--color-surface-border)] px-5 py-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--color-text-subtle)]">
|
||||
OpenClaw
|
||||
</p>
|
||||
<h1 className="text-lg font-semibold text-[var(--color-heading)]">AI Assistant Workspace</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 text-[var(--color-text-subtle)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLayout('left')}
|
||||
className={cn(
|
||||
'rounded-xl p-2 transition hover:bg-[var(--color-surface-muted)]',
|
||||
layout === 'left' ? 'bg-[var(--color-primary-muted)] text-[var(--color-primary)]' : '',
|
||||
)}
|
||||
title="Sidebar Left"
|
||||
>
|
||||
<PanelLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLayout('right')}
|
||||
className={cn(
|
||||
'rounded-xl p-2 transition hover:bg-[var(--color-surface-muted)]',
|
||||
layout === 'right' ? 'bg-[var(--color-primary-muted)] text-[var(--color-primary)]' : '',
|
||||
)}
|
||||
title="Sidebar Right"
|
||||
>
|
||||
<PanelRight className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLayout('full')}
|
||||
className={cn(
|
||||
'rounded-xl p-2 transition hover:bg-[var(--color-surface-muted)]',
|
||||
layout === 'full' ? 'bg-[var(--color-primary-muted)] text-[var(--color-primary)]' : '',
|
||||
)}
|
||||
title="Fullscreen"
|
||||
>
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/')}
|
||||
className="rounded-xl p-2 transition hover:bg-[var(--color-danger-muted)]/40 hover:text-[var(--color-danger-foreground)]"
|
||||
title="Close"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
<OpenClawAssistantPane
|
||||
defaults={defaults}
|
||||
initialQuestion={initialQuestion}
|
||||
initialQuestionKey={initialQuestion ? 1 : undefined}
|
||||
variant="page"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (layout === 'full') {
|
||||
return <div className="h-full min-h-0">{assistantPane}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 gap-4 overflow-hidden">
|
||||
{layout === 'left' ? <div className="w-[460px] shrink-0">{assistantPane}</div> : null}
|
||||
<div className="min-w-0 flex-1 overflow-y-auto">{homeContent}</div>
|
||||
{layout === 'right' ? <div className="w-[460px] shrink-0">{assistantPane}</div> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1428,7 +1428,7 @@ export const translations: Record<'en' | 'zh', Translation> = {
|
||||
},
|
||||
askAI: {
|
||||
title: 'AI Assistant',
|
||||
subtitle: 'Do anything with Moltbot AI',
|
||||
subtitle: 'Work through infrastructure tasks with OpenClaw',
|
||||
placeholder: 'Type your question...',
|
||||
ask: 'Ask',
|
||||
},
|
||||
@ -2072,7 +2072,7 @@ export const translations: Record<'en' | 'zh', Translation> = {
|
||||
},
|
||||
askAI: {
|
||||
title: 'AI 助手',
|
||||
subtitle: '使用 Moltbot AI 做任何事',
|
||||
subtitle: '通过 OpenClaw 处理运维与协作任务',
|
||||
placeholder: '输入您的指令或问题...',
|
||||
ask: '发送',
|
||||
},
|
||||
|
||||
@ -74,7 +74,7 @@ export const createNavConfig = (
|
||||
{
|
||||
key: "chat",
|
||||
label: (lang) => (lang === "zh" ? "AI 助手" : "AI Assistant"),
|
||||
href: "/services/openclaw/chats",
|
||||
href: "/services/openclaw",
|
||||
icon: MessageSquare,
|
||||
active: (pathname) => pathname?.startsWith("/services/openclaw"),
|
||||
showOn: "both",
|
||||
|
||||
151
src/lib/openclaw/types.ts
Normal file
151
src/lib/openclaw/types.ts
Normal file
@ -0,0 +1,151 @@
|
||||
export type AssistantMode = 'ask' | 'craft' | 'plan'
|
||||
|
||||
export type ThinkingLevel = 'low' | 'medium' | 'high' | 'max'
|
||||
|
||||
export type GatewayTokenSource = 'env' | 'request' | 'none'
|
||||
|
||||
export type GatewayAgentSummary = {
|
||||
id: string
|
||||
name: string
|
||||
emoji?: string
|
||||
theme?: string
|
||||
}
|
||||
|
||||
export type GatewaySessionSummary = {
|
||||
key: string
|
||||
displayName?: string
|
||||
updatedAtMs?: number
|
||||
derivedTitle?: string
|
||||
lastMessagePreview?: string
|
||||
model?: string
|
||||
thinkingLevel?: string
|
||||
abortedLastRun?: boolean
|
||||
}
|
||||
|
||||
export type GatewayChatMessage = {
|
||||
id: string
|
||||
role: string
|
||||
text: string
|
||||
timestampMs?: number
|
||||
toolCallId?: string
|
||||
toolName?: string
|
||||
stopReason?: string
|
||||
pending?: boolean
|
||||
error?: boolean
|
||||
}
|
||||
|
||||
export type GatewayChatAttachmentPayload = {
|
||||
type: 'image' | 'file'
|
||||
mimeType: string
|
||||
fileName: string
|
||||
content: string
|
||||
}
|
||||
|
||||
export type OpenClawBootstrapResponse = {
|
||||
activeSessionKey: string
|
||||
mainSessionKey: string
|
||||
gatewayUrl: string
|
||||
tokenSource: GatewayTokenSource
|
||||
connectedAt: string
|
||||
agents: GatewayAgentSummary[]
|
||||
sessions: GatewaySessionSummary[]
|
||||
messages: GatewayChatMessage[]
|
||||
statusPayload: Record<string, unknown>
|
||||
healthPayload: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type OpenClawStreamEvent =
|
||||
| {
|
||||
type: 'ack'
|
||||
runId: string
|
||||
sessionKey: string
|
||||
}
|
||||
| {
|
||||
type: 'delta'
|
||||
text: string
|
||||
}
|
||||
| {
|
||||
type: 'final'
|
||||
state: 'final' | 'aborted' | 'error'
|
||||
messages: GatewayChatMessage[]
|
||||
sessions: GatewaySessionSummary[]
|
||||
errorMessage?: string
|
||||
}
|
||||
| {
|
||||
type: 'error'
|
||||
message: string
|
||||
code?: string
|
||||
}
|
||||
|
||||
export type IntegrationDefaults = {
|
||||
openclawUrl: string
|
||||
openclawTokenConfigured: boolean
|
||||
vaultUrl: string
|
||||
vaultNamespace: string
|
||||
vaultTokenConfigured: boolean
|
||||
apisixUrl: string
|
||||
apisixTokenConfigured: boolean
|
||||
}
|
||||
|
||||
export function normalizeMainSessionKey(value?: string | null): string {
|
||||
const trimmed = value?.trim() ?? ''
|
||||
return trimmed.length > 0 ? trimmed : 'main'
|
||||
}
|
||||
|
||||
export function makeAgentSessionKey(agentId: string, baseKey: string): string {
|
||||
const normalizedBase = normalizeMainSessionKey(baseKey)
|
||||
const trimmedAgent = agentId.trim()
|
||||
return trimmedAgent.length > 0 ? `agent:${trimmedAgent}:${normalizedBase}` : normalizedBase
|
||||
}
|
||||
|
||||
export function matchesSessionKey(incoming: string, current: string): boolean {
|
||||
const left = incoming.trim().toLowerCase()
|
||||
const right = current.trim().toLowerCase()
|
||||
|
||||
if (left === right) {
|
||||
return true
|
||||
}
|
||||
|
||||
return (
|
||||
(left === 'agent:main:main' && right === 'main') ||
|
||||
(left === 'main' && right === 'agent:main:main')
|
||||
)
|
||||
}
|
||||
|
||||
export function extractMessageText(message: Record<string, unknown>): string {
|
||||
const directContent = message.content
|
||||
if (typeof directContent === 'string') {
|
||||
return directContent
|
||||
}
|
||||
|
||||
if (!Array.isArray(directContent)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const parts: string[] = []
|
||||
|
||||
for (const part of directContent) {
|
||||
if (!part || typeof part !== 'object') {
|
||||
continue
|
||||
}
|
||||
|
||||
const entry = part as Record<string, unknown>
|
||||
const inlineText =
|
||||
typeof entry.text === 'string'
|
||||
? entry.text
|
||||
: typeof entry.thinking === 'string'
|
||||
? entry.thinking
|
||||
: ''
|
||||
|
||||
if (inlineText.trim().length > 0) {
|
||||
parts.push(inlineText.trim())
|
||||
continue
|
||||
}
|
||||
|
||||
if (typeof entry.content === 'string' && entry.content.trim().length > 0) {
|
||||
parts.push(entry.content.trim())
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join('\n').trim()
|
||||
}
|
||||
@ -0,0 +1,451 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { ArrowRight, CheckCircle2, Link2, Loader2, ShieldCheck, Workflow } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
import type { IntegrationDefaults } from '@/lib/openclaw/types'
|
||||
import { useOpenClawConsoleStore } from '@/state/openclawConsoleStore'
|
||||
|
||||
import Card from './Card'
|
||||
|
||||
type ProbeTarget = 'openclaw' | 'vault' | 'apisix'
|
||||
|
||||
type ProbeState = {
|
||||
ok: boolean
|
||||
status?: number
|
||||
tokenSource?: string
|
||||
body?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
type IntegrationsConsoleProps = {
|
||||
defaults?: IntegrationDefaults
|
||||
}
|
||||
|
||||
function StatusBadge({
|
||||
title,
|
||||
ok,
|
||||
}: {
|
||||
title: string
|
||||
ok?: boolean
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-2 rounded-full px-3 py-1 text-xs font-semibold ${
|
||||
ok
|
||||
? 'bg-emerald-500/10 text-emerald-600'
|
||||
: 'bg-[var(--color-surface-muted)] text-[var(--color-text-subtle)]'
|
||||
}`}
|
||||
>
|
||||
<span className={`h-2 w-2 rounded-full ${ok ? 'bg-emerald-500' : 'bg-[var(--color-text-subtle)]/50'}`} />
|
||||
{title}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
hint?: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<label className="flex flex-col gap-2 text-sm">
|
||||
<div className="space-y-1">
|
||||
<span className="font-medium text-[var(--color-text)]">{label}</span>
|
||||
{hint ? <p className="text-xs text-[var(--color-text-subtle)]">{hint}</p> : null}
|
||||
</div>
|
||||
{children}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function inputClassName(type: 'input' | 'textarea' = 'input'): string {
|
||||
return [
|
||||
'w-full rounded-[var(--radius-xl)] border border-[color:var(--color-surface-border)] bg-[var(--color-surface)] px-4 py-3 text-sm text-[var(--color-text)] outline-none transition',
|
||||
'focus:border-[color:var(--color-primary)] focus:ring-2 focus:ring-[color:var(--color-primary-muted)]',
|
||||
type === 'textarea' ? 'min-h-[120px] resize-y' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
const EMPTY_DEFAULTS: IntegrationDefaults = {
|
||||
openclawUrl: '',
|
||||
openclawTokenConfigured: false,
|
||||
vaultUrl: '',
|
||||
vaultNamespace: '',
|
||||
vaultTokenConfigured: false,
|
||||
apisixUrl: '',
|
||||
apisixTokenConfigured: false,
|
||||
}
|
||||
|
||||
export function IntegrationsConsole({ defaults }: IntegrationsConsoleProps) {
|
||||
const router = useRouter()
|
||||
const [loadingTarget, setLoadingTarget] = useState<ProbeTarget | null>(null)
|
||||
const [resolvedDefaults, setResolvedDefaults] = useState<IntegrationDefaults>(defaults ?? EMPTY_DEFAULTS)
|
||||
const [probeResults, setProbeResults] = useState<Record<ProbeTarget, ProbeState>>({
|
||||
openclaw: { ok: false },
|
||||
vault: { ok: false },
|
||||
apisix: { ok: false },
|
||||
})
|
||||
|
||||
const applyDefaults = useOpenClawConsoleStore((state) => state.applyDefaults)
|
||||
const openclawUrl = useOpenClawConsoleStore((state) => state.openclawUrl)
|
||||
const openclawToken = useOpenClawConsoleStore((state) => state.openclawToken)
|
||||
const vaultUrl = useOpenClawConsoleStore((state) => state.vaultUrl)
|
||||
const vaultNamespace = useOpenClawConsoleStore((state) => state.vaultNamespace)
|
||||
const vaultToken = useOpenClawConsoleStore((state) => state.vaultToken)
|
||||
const apisixUrl = useOpenClawConsoleStore((state) => state.apisixUrl)
|
||||
const apisixToken = useOpenClawConsoleStore((state) => state.apisixToken)
|
||||
const setOpenclawUrl = useOpenClawConsoleStore((state) => state.setOpenclawUrl)
|
||||
const setOpenclawToken = useOpenClawConsoleStore((state) => state.setOpenclawToken)
|
||||
const setVaultUrl = useOpenClawConsoleStore((state) => state.setVaultUrl)
|
||||
const setVaultNamespace = useOpenClawConsoleStore((state) => state.setVaultNamespace)
|
||||
const setVaultToken = useOpenClawConsoleStore((state) => state.setVaultToken)
|
||||
const setApisixUrl = useOpenClawConsoleStore((state) => state.setApisixUrl)
|
||||
const setApisixToken = useOpenClawConsoleStore((state) => state.setApisixToken)
|
||||
|
||||
useEffect(() => {
|
||||
applyDefaults(resolvedDefaults)
|
||||
}, [applyDefaults, resolvedDefaults])
|
||||
|
||||
useEffect(() => {
|
||||
if (defaults) {
|
||||
setResolvedDefaults(defaults)
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch('/api/integrations/defaults', { cache: 'no-store' })
|
||||
if (!response.ok) {
|
||||
return
|
||||
}
|
||||
const payload = (await response.json()) as IntegrationDefaults
|
||||
if (!cancelled) {
|
||||
setResolvedDefaults(payload)
|
||||
}
|
||||
} catch {
|
||||
// Ignore; the form still works with manual input only.
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [defaults])
|
||||
|
||||
const summary = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'openclaw',
|
||||
label: 'OpenClaw Gateway',
|
||||
configured: Boolean(openclawUrl.trim()),
|
||||
tokenConfigured: resolvedDefaults.openclawTokenConfigured || Boolean(openclawToken.trim()),
|
||||
},
|
||||
{
|
||||
key: 'vault',
|
||||
label: 'Vault Server',
|
||||
configured: Boolean(vaultUrl.trim()),
|
||||
tokenConfigured: resolvedDefaults.vaultTokenConfigured || Boolean(vaultToken.trim()),
|
||||
},
|
||||
{
|
||||
key: 'apisix',
|
||||
label: 'APISIX AI Gateway',
|
||||
configured: Boolean(apisixUrl.trim()),
|
||||
tokenConfigured: resolvedDefaults.apisixTokenConfigured || Boolean(apisixToken.trim()),
|
||||
},
|
||||
],
|
||||
[
|
||||
apisixToken,
|
||||
apisixUrl,
|
||||
openclawToken,
|
||||
openclawUrl,
|
||||
resolvedDefaults.apisixTokenConfigured,
|
||||
resolvedDefaults.openclawTokenConfigured,
|
||||
resolvedDefaults.vaultTokenConfigured,
|
||||
vaultToken,
|
||||
vaultUrl,
|
||||
],
|
||||
)
|
||||
|
||||
async function probe(target: ProbeTarget): Promise<void> {
|
||||
setLoadingTarget(target)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/integrations/probe', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
target,
|
||||
gatewayUrl: openclawUrl,
|
||||
gatewayToken: openclawToken,
|
||||
vaultUrl,
|
||||
vaultToken,
|
||||
apisixUrl,
|
||||
apisixToken,
|
||||
}),
|
||||
})
|
||||
|
||||
const payload = (await response.json()) as ProbeState
|
||||
setProbeResults((current) => ({
|
||||
...current,
|
||||
[target]: {
|
||||
ok: Boolean(payload.ok),
|
||||
status: payload.status,
|
||||
tokenSource: payload.tokenSource,
|
||||
body: payload.body,
|
||||
error: payload.error,
|
||||
},
|
||||
}))
|
||||
} catch (error) {
|
||||
setProbeResults((current) => ({
|
||||
...current,
|
||||
[target]: {
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : 'Probe failed.',
|
||||
},
|
||||
}))
|
||||
} finally {
|
||||
setLoadingTarget(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card className="space-y-5">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--color-text-subtle)]">
|
||||
Assistant Integrations
|
||||
</p>
|
||||
<h1 className="text-2xl font-semibold text-[var(--color-heading)]">OpenClaw / Vault / APISIX</h1>
|
||||
<p className="max-w-3xl text-sm text-[var(--color-text-subtle)]">
|
||||
这里是 console.svc.plus 主页 AI 助手的统一接入层。地址可以来自环境变量,token 既可以走服务端环境,也可以只在当前浏览器会话里临时覆盖。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/services/openclaw')}
|
||||
className="inline-flex items-center gap-2 rounded-full bg-[var(--color-primary)] px-4 py-2 text-sm font-semibold text-[var(--color-primary-foreground)]"
|
||||
>
|
||||
打开助手主页
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
{summary.map((item) => (
|
||||
<div
|
||||
key={item.key}
|
||||
className="rounded-[var(--radius-xl)] border border-[color:var(--color-surface-border)] bg-[var(--color-surface)] p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-[var(--color-text)]">{item.label}</p>
|
||||
<p className="mt-1 text-xs text-[var(--color-text-subtle)]">
|
||||
{item.configured ? 'address ready' : 'missing address'} ·{' '}
|
||||
{item.tokenConfigured ? 'token ready' : 'token pending'}
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge title={item.configured ? 'configured' : 'draft'} ok={item.configured} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="space-y-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-[var(--color-primary-muted)] text-[var(--color-primary)]">
|
||||
<Link2 className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-lg font-semibold text-[var(--color-heading)]">OpenClaw Gateway</h2>
|
||||
<p className="text-sm text-[var(--color-text-subtle)]">
|
||||
侧栏助手与 `/services/openclaw` 页面都会走这里的配置。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[1.3fr_1fr]">
|
||||
<Field label="WebSocket URL" hint="例如 wss://openclaw.svc.plus:443">
|
||||
<input
|
||||
type="text"
|
||||
value={openclawUrl}
|
||||
onChange={(event) => setOpenclawUrl(event.target.value)}
|
||||
className={inputClassName()}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Gateway Token" hint="优先使用当前会话值,留空时回退到服务端环境变量。">
|
||||
<input
|
||||
type="password"
|
||||
value={openclawToken}
|
||||
onChange={(event) => setOpenclawToken(event.target.value)}
|
||||
className={inputClassName()}
|
||||
placeholder={resolvedDefaults.openclawTokenConfigured ? 'server env configured' : 'paste shared token'}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void probe('openclaw')
|
||||
}}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-[color:var(--color-surface-border)] px-4 py-2 text-sm font-medium text-[var(--color-text)] transition hover:border-[color:var(--color-primary-border)] hover:bg-[var(--color-surface-muted)]"
|
||||
>
|
||||
{loadingTarget === 'openclaw' ? <Loader2 className="h-4 w-4 animate-spin" /> : <CheckCircle2 className="h-4 w-4" />}
|
||||
测试 OpenClaw
|
||||
</button>
|
||||
<StatusBadge
|
||||
title={probeResults.openclaw.ok ? 'gateway reachable' : 'not checked'}
|
||||
ok={probeResults.openclaw.ok}
|
||||
/>
|
||||
<span className="text-xs text-[var(--color-text-subtle)]">
|
||||
token source: {probeResults.openclaw.tokenSource || (resolvedDefaults.openclawTokenConfigured ? 'env' : 'session')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{probeResults.openclaw.error ? (
|
||||
<div className="rounded-[var(--radius-xl)] border border-[color:var(--color-danger-border)] bg-[var(--color-danger-muted)]/40 px-4 py-3 text-sm text-[var(--color-danger-foreground)]">
|
||||
{probeResults.openclaw.error}
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<Card className="space-y-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-[var(--color-primary-muted)] text-[var(--color-primary)]">
|
||||
<ShieldCheck className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-lg font-semibold text-[var(--color-heading)]">Vault Server</h2>
|
||||
<p className="text-sm text-[var(--color-text-subtle)]">用于托管敏感凭证与运行时密钥。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Field label="Vault URL" hint="支持 public 或 local-first 域名。">
|
||||
<input
|
||||
type="text"
|
||||
value={vaultUrl}
|
||||
onChange={(event) => setVaultUrl(event.target.value)}
|
||||
className={inputClassName()}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Namespace" hint="可选,用于多租户或分区管理。">
|
||||
<input
|
||||
type="text"
|
||||
value={vaultNamespace}
|
||||
onChange={(event) => setVaultNamespace(event.target.value)}
|
||||
className={inputClassName()}
|
||||
placeholder="admin"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Vault Token" hint="留空时回退到服务端环境变量。">
|
||||
<input
|
||||
type="password"
|
||||
value={vaultToken}
|
||||
onChange={(event) => setVaultToken(event.target.value)}
|
||||
className={inputClassName()}
|
||||
placeholder={resolvedDefaults.vaultTokenConfigured ? 'server env configured' : 'paste vault token'}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void probe('vault')
|
||||
}}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-[color:var(--color-surface-border)] px-4 py-2 text-sm font-medium text-[var(--color-text)] transition hover:border-[color:var(--color-primary-border)] hover:bg-[var(--color-surface-muted)]"
|
||||
>
|
||||
{loadingTarget === 'vault' ? <Loader2 className="h-4 w-4 animate-spin" /> : <CheckCircle2 className="h-4 w-4" />}
|
||||
测试 Vault
|
||||
</button>
|
||||
<StatusBadge title={probeResults.vault.ok ? 'vault reachable' : 'not checked'} ok={probeResults.vault.ok} />
|
||||
</div>
|
||||
|
||||
{probeResults.vault.error ? (
|
||||
<div className="rounded-[var(--radius-xl)] border border-[color:var(--color-danger-border)] bg-[var(--color-danger-muted)]/40 px-4 py-3 text-sm text-[var(--color-danger-foreground)]">
|
||||
{probeResults.vault.error}
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
<Card className="space-y-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-[var(--color-primary-muted)] text-[var(--color-primary)]">
|
||||
<Workflow className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-lg font-semibold text-[var(--color-heading)]">APISIX AI Gateway</h2>
|
||||
<p className="text-sm text-[var(--color-text-subtle)]">统一承接模型路由、鉴权和治理入口。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Field label="Gateway URL" hint="建议填写 OpenAI-compatible `/v1` 前缀所在地址。">
|
||||
<input
|
||||
type="text"
|
||||
value={apisixUrl}
|
||||
onChange={(event) => setApisixUrl(event.target.value)}
|
||||
className={inputClassName()}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Access Token" hint="留空时回退到服务端环境变量。">
|
||||
<input
|
||||
type="password"
|
||||
value={apisixToken}
|
||||
onChange={(event) => setApisixToken(event.target.value)}
|
||||
className={inputClassName()}
|
||||
placeholder={resolvedDefaults.apisixTokenConfigured ? 'server env configured' : 'paste ai gateway token'}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="rounded-[var(--radius-xl)] border border-[color:var(--color-surface-border)] bg-[var(--color-surface-muted)]/40 px-4 py-3 text-sm text-[var(--color-text-subtle)]">
|
||||
当前实现优先补齐 console.svc.plus 主页 AI 助手所需的接入能力。更复杂的 APISIX profile/file-driven 配置仍保留在部署侧,不在这里硬编码。
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void probe('apisix')
|
||||
}}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-[color:var(--color-surface-border)] px-4 py-2 text-sm font-medium text-[var(--color-text)] transition hover:border-[color:var(--color-primary-border)] hover:bg-[var(--color-surface-muted)]"
|
||||
>
|
||||
{loadingTarget === 'apisix' ? <Loader2 className="h-4 w-4 animate-spin" /> : <CheckCircle2 className="h-4 w-4" />}
|
||||
测试 APISIX
|
||||
</button>
|
||||
<StatusBadge
|
||||
title={probeResults.apisix.ok ? 'gateway reachable' : 'not checked'}
|
||||
ok={probeResults.apisix.ok}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{probeResults.apisix.error ? (
|
||||
<div className="rounded-[var(--radius-xl)] border border-[color:var(--color-danger-border)] bg-[var(--color-danger-muted)]/40 px-4 py-3 text-sm text-[var(--color-danger-foreground)]">
|
||||
{probeResults.apisix.error}
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -45,8 +45,8 @@ export const userCenterExtension: DashboardExtension = {
|
||||
{
|
||||
id: 'apis',
|
||||
path: '/panel/api',
|
||||
label: 'APIs',
|
||||
description: '洞察后端服务',
|
||||
label: 'Integrations',
|
||||
description: '统一管理 OpenClaw、Vault 与 AI Gateway',
|
||||
icon: Code,
|
||||
loader: () => import('./routes/api'),
|
||||
guard: { requireLogin: true },
|
||||
@ -54,10 +54,10 @@ export const userCenterExtension: DashboardExtension = {
|
||||
sidebar: { section: 'productivity', order: 11 },
|
||||
featureFlag: {
|
||||
id: 'user-center.api',
|
||||
title: 'API 监控',
|
||||
description: '启用 API 状态与特性开关页面。',
|
||||
title: '接口集成',
|
||||
description: '启用 OpenClaw、Vault 与 APISIX AI Gateway 集成页面。',
|
||||
envVar: 'NEXT_PUBLIC_FEATURE_API_MODULE',
|
||||
defaultEnabled: false,
|
||||
defaultEnabled: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@ -1,19 +1,17 @@
|
||||
import Breadcrumbs from '@/app/panel/components/Breadcrumbs'
|
||||
import Card from '../components/Card'
|
||||
|
||||
import { IntegrationsConsole } from '../components/IntegrationsConsole'
|
||||
|
||||
export default function UserCenterApiRoute() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-6">
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Dashboard', href: '/panel' },
|
||||
{ label: 'APIs', href: '/panel/api' },
|
||||
{ label: 'Integrations', href: '/panel/api' },
|
||||
]}
|
||||
/>
|
||||
<Card>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">API Status</h1>
|
||||
<p className="mt-2 text-sm text-gray-600">View backend API health and toggle feature matrices.</p>
|
||||
</Card>
|
||||
<IntegrationsConsole />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
141
src/server/consoleIntegrations.ts
Normal file
141
src/server/consoleIntegrations.ts
Normal file
@ -0,0 +1,141 @@
|
||||
import 'server-only'
|
||||
|
||||
import type { IntegrationDefaults } from '@/lib/openclaw/types'
|
||||
|
||||
const OPENCLAW_URL_KEYS = [
|
||||
'OPENCLAW_GATEWAY_REMOTE_URL',
|
||||
'OPENCLAW_GATEWAY_URL',
|
||||
'OPENCLAW_GATEWAY_WS_URL',
|
||||
'OPENCLAW_GATEWAY_ADDR',
|
||||
] as const
|
||||
|
||||
const OPENCLAW_TOKEN_KEYS = ['OPENCLAW_GATEWAY_TOKEN'] as const
|
||||
|
||||
const APISIX_URL_KEYS = [
|
||||
'APISIX_AI_GATEWAY_URL',
|
||||
'AI_GATEWAY_URL',
|
||||
'AI_GATEWAY_BASE_URL',
|
||||
'API_GATEWAY_URL',
|
||||
] as const
|
||||
|
||||
const APISIX_TOKEN_KEYS = ['AI_GATEWAY_ACCESS_TOKEN'] as const
|
||||
|
||||
const VAULT_URL_KEYS = ['VAULT_SERVER_URL', 'VAULT_ADDR', 'vault_addr'] as const
|
||||
const VAULT_NAMESPACE_KEYS = ['VAULT_NAMESPACE'] as const
|
||||
const VAULT_TOKEN_KEYS = ['VAULT_TOKEN', 'VAULT_SERVER_ROOT_ACCESS_TOKEN'] as const
|
||||
|
||||
function readEnvValue(...keys: readonly string[]): string | undefined {
|
||||
for (const key of keys) {
|
||||
const value = process.env[key]
|
||||
if (typeof value !== 'string') {
|
||||
continue
|
||||
}
|
||||
|
||||
const trimmed = value.trim()
|
||||
if (trimmed.length > 0) {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function trimTrailingSlash(value: string): string {
|
||||
return value.endsWith('/') ? value.slice(0, -1) : value
|
||||
}
|
||||
|
||||
function normalizeHttpUrl(value?: string): string {
|
||||
const raw = value?.trim() ?? ''
|
||||
if (raw.length === 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const prefixed = raw.includes('://') ? raw : `https://${raw}`
|
||||
|
||||
try {
|
||||
return trimTrailingSlash(new URL(prefixed).toString())
|
||||
} catch {
|
||||
return trimTrailingSlash(prefixed)
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeWsUrl(value?: string): string {
|
||||
const raw = value?.trim() ?? ''
|
||||
if (raw.length === 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const prefixed = raw.includes('://') ? raw : `wss://${raw}`
|
||||
|
||||
try {
|
||||
const url = new URL(prefixed)
|
||||
if (url.protocol === 'http:') {
|
||||
url.protocol = 'ws:'
|
||||
} else if (url.protocol === 'https:') {
|
||||
url.protocol = 'wss:'
|
||||
}
|
||||
return trimTrailingSlash(url.toString())
|
||||
} catch {
|
||||
return trimTrailingSlash(prefixed)
|
||||
}
|
||||
}
|
||||
|
||||
export function getConsoleIntegrationDefaults(): IntegrationDefaults {
|
||||
return {
|
||||
openclawUrl: normalizeWsUrl(readEnvValue(...OPENCLAW_URL_KEYS)),
|
||||
openclawTokenConfigured: Boolean(readEnvValue(...OPENCLAW_TOKEN_KEYS)),
|
||||
vaultUrl: normalizeHttpUrl(readEnvValue(...VAULT_URL_KEYS)),
|
||||
vaultNamespace: readEnvValue(...VAULT_NAMESPACE_KEYS) ?? '',
|
||||
vaultTokenConfigured: Boolean(readEnvValue(...VAULT_TOKEN_KEYS)),
|
||||
apisixUrl: normalizeHttpUrl(readEnvValue(...APISIX_URL_KEYS)),
|
||||
apisixTokenConfigured: Boolean(readEnvValue(...APISIX_TOKEN_KEYS)),
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveOpenClawGatewayConfig(overrides?: {
|
||||
gatewayUrl?: string
|
||||
gatewayToken?: string
|
||||
}): { gatewayUrl: string; gatewayToken: string; tokenSource: 'env' | 'request' | 'none' } {
|
||||
const requestUrl = normalizeWsUrl(overrides?.gatewayUrl)
|
||||
const envUrl = normalizeWsUrl(readEnvValue(...OPENCLAW_URL_KEYS))
|
||||
const requestToken = overrides?.gatewayToken?.trim() ?? ''
|
||||
const envToken = readEnvValue(...OPENCLAW_TOKEN_KEYS) ?? ''
|
||||
|
||||
return {
|
||||
gatewayUrl: requestUrl || envUrl,
|
||||
gatewayToken: requestToken || envToken,
|
||||
tokenSource: requestToken ? 'request' : envToken ? 'env' : 'none',
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveApisixProbeConfig(overrides?: {
|
||||
apisixUrl?: string
|
||||
apisixToken?: string
|
||||
}): { apisixUrl: string; apisixToken: string; tokenSource: 'env' | 'request' | 'none' } {
|
||||
const requestUrl = normalizeHttpUrl(overrides?.apisixUrl)
|
||||
const envUrl = normalizeHttpUrl(readEnvValue(...APISIX_URL_KEYS))
|
||||
const requestToken = overrides?.apisixToken?.trim() ?? ''
|
||||
const envToken = readEnvValue(...APISIX_TOKEN_KEYS) ?? ''
|
||||
|
||||
return {
|
||||
apisixUrl: requestUrl || envUrl,
|
||||
apisixToken: requestToken || envToken,
|
||||
tokenSource: requestToken ? 'request' : envToken ? 'env' : 'none',
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveVaultProbeConfig(overrides?: {
|
||||
vaultUrl?: string
|
||||
vaultToken?: string
|
||||
}): { vaultUrl: string; vaultToken: string; tokenSource: 'env' | 'request' | 'none' } {
|
||||
const requestUrl = normalizeHttpUrl(overrides?.vaultUrl)
|
||||
const envUrl = normalizeHttpUrl(readEnvValue(...VAULT_URL_KEYS))
|
||||
const requestToken = overrides?.vaultToken?.trim() ?? ''
|
||||
const envToken = readEnvValue(...VAULT_TOKEN_KEYS) ?? ''
|
||||
|
||||
return {
|
||||
vaultUrl: requestUrl || envUrl,
|
||||
vaultToken: requestToken || envToken,
|
||||
tokenSource: requestToken ? 'request' : envToken ? 'env' : 'none',
|
||||
}
|
||||
}
|
||||
439
src/server/openclaw/gateway-client.ts
Normal file
439
src/server/openclaw/gateway-client.ts
Normal file
@ -0,0 +1,439 @@
|
||||
import 'server-only'
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
import {
|
||||
extractMessageText,
|
||||
normalizeMainSessionKey,
|
||||
type GatewayAgentSummary,
|
||||
type GatewayChatAttachmentPayload,
|
||||
type GatewayChatMessage,
|
||||
type GatewaySessionSummary,
|
||||
} from '@/lib/openclaw/types'
|
||||
|
||||
const OPENCLAW_PROTOCOL_VERSION = 3
|
||||
const DEFAULT_OPERATOR_SCOPES = [
|
||||
'operator.admin',
|
||||
'operator.read',
|
||||
'operator.write',
|
||||
'operator.approvals',
|
||||
'operator.pairing',
|
||||
] as const
|
||||
|
||||
type PendingRequest = {
|
||||
resolve: (value: unknown) => void
|
||||
reject: (reason?: unknown) => void
|
||||
timeout: NodeJS.Timeout
|
||||
}
|
||||
|
||||
type GatewayEventFrame = {
|
||||
type: 'event'
|
||||
event: string
|
||||
seq?: number
|
||||
payload?: unknown
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {}
|
||||
}
|
||||
|
||||
function asArray(value: unknown): unknown[] {
|
||||
return Array.isArray(value) ? value : []
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value : undefined
|
||||
}
|
||||
|
||||
function numberValue(value: unknown): number | undefined {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : undefined
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function boolValue(value: unknown): boolean | undefined {
|
||||
return typeof value === 'boolean' ? value : undefined
|
||||
}
|
||||
|
||||
function toText(value: unknown): string {
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
|
||||
if (value instanceof ArrayBuffer) {
|
||||
return Buffer.from(value).toString('utf8')
|
||||
}
|
||||
|
||||
if (ArrayBuffer.isView(value)) {
|
||||
return Buffer.from(value.buffer, value.byteOffset, value.byteLength).toString('utf8')
|
||||
}
|
||||
|
||||
if (value instanceof Blob) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return String(value ?? '')
|
||||
}
|
||||
|
||||
function randomId(): string {
|
||||
return `${Date.now().toString(16)}-${randomUUID().slice(0, 8)}`
|
||||
}
|
||||
|
||||
function resolveGatewayUrl(urlRaw: string): string {
|
||||
const trimmed = urlRaw.trim()
|
||||
const url = new URL(trimmed.includes('://') ? trimmed : `wss://${trimmed}`)
|
||||
|
||||
if (url.protocol === 'http:') {
|
||||
url.protocol = 'ws:'
|
||||
} else if (url.protocol === 'https:') {
|
||||
url.protocol = 'wss:'
|
||||
}
|
||||
|
||||
if (!url.port) {
|
||||
url.port = url.protocol === 'wss:' ? '443' : '80'
|
||||
}
|
||||
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
export class OpenClawGatewayError extends Error {
|
||||
readonly code?: string
|
||||
|
||||
constructor(message: string, code?: string) {
|
||||
super(message)
|
||||
this.name = 'OpenClawGatewayError'
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
export class OpenClawGatewayClient {
|
||||
private socket: WebSocket | null = null
|
||||
private pending = new Map<string, PendingRequest>()
|
||||
private listeners = new Set<(event: GatewayEventFrame) => void>()
|
||||
private handleMessageRef = (event: MessageEvent) => {
|
||||
void this.handleMessage(event)
|
||||
}
|
||||
private handleCloseRef = () => {
|
||||
this.failPending(new OpenClawGatewayError('Gateway connection closed', 'SOCKET_CLOSED'))
|
||||
}
|
||||
private handleErrorRef = () => {
|
||||
this.failPending(new OpenClawGatewayError('Gateway transport error', 'SOCKET_ERROR'))
|
||||
}
|
||||
|
||||
async connect(params: {
|
||||
gatewayUrl: string
|
||||
gatewayToken: string
|
||||
clientId?: string
|
||||
clientLabel?: string
|
||||
}): Promise<{ mainSessionKey: string }> {
|
||||
const url = resolveGatewayUrl(params.gatewayUrl)
|
||||
const socket = new WebSocket(url)
|
||||
this.socket = socket
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new OpenClawGatewayError('Gateway open timeout', 'OPEN_TIMEOUT'))
|
||||
}, 8000)
|
||||
|
||||
socket.addEventListener(
|
||||
'open',
|
||||
() => {
|
||||
clearTimeout(timeout)
|
||||
resolve()
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
|
||||
socket.addEventListener(
|
||||
'error',
|
||||
() => {
|
||||
clearTimeout(timeout)
|
||||
reject(new OpenClawGatewayError('Gateway open failed', 'OPEN_FAILED'))
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
})
|
||||
|
||||
socket.addEventListener('message', this.handleMessageRef)
|
||||
socket.addEventListener('close', this.handleCloseRef)
|
||||
socket.addEventListener('error', this.handleErrorRef)
|
||||
|
||||
const payload = asRecord(
|
||||
await this.request('connect', {
|
||||
minProtocol: OPENCLAW_PROTOCOL_VERSION,
|
||||
maxProtocol: OPENCLAW_PROTOCOL_VERSION,
|
||||
client: {
|
||||
id: params.clientId ?? 'console-openclaw-proxy',
|
||||
displayName: params.clientLabel ?? 'console.svc.plus Assistant',
|
||||
version: '1.0.0',
|
||||
platform: 'node',
|
||||
mode: 'ui',
|
||||
instanceId: `console-${randomUUID().slice(0, 8)}`,
|
||||
},
|
||||
locale: 'zh-CN',
|
||||
userAgent: 'console.svc.plus/openclaw',
|
||||
role: 'operator',
|
||||
scopes: DEFAULT_OPERATOR_SCOPES,
|
||||
caps: ['tool-events'],
|
||||
...(params.gatewayToken.trim()
|
||||
? {
|
||||
auth: {
|
||||
token: params.gatewayToken.trim(),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}, 12000),
|
||||
)
|
||||
|
||||
const snapshot = asRecord(payload.snapshot)
|
||||
const sessionDefaults = asRecord(snapshot.sessionDefaults)
|
||||
|
||||
return {
|
||||
mainSessionKey: normalizeMainSessionKey(stringValue(sessionDefaults.mainSessionKey)),
|
||||
}
|
||||
}
|
||||
|
||||
onEvent(listener: (event: GatewayEventFrame) => void): () => void {
|
||||
this.listeners.add(listener)
|
||||
return () => {
|
||||
this.listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
async request(method: string, params?: Record<string, unknown>, timeoutMs = 15000): Promise<unknown> {
|
||||
const socket = this.socket
|
||||
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
throw new OpenClawGatewayError('Gateway is offline', 'OFFLINE')
|
||||
}
|
||||
|
||||
const id = randomId()
|
||||
const frame = {
|
||||
type: 'req',
|
||||
id,
|
||||
method,
|
||||
...(params ? { params } : {}),
|
||||
}
|
||||
|
||||
return new Promise<unknown>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
this.pending.delete(id)
|
||||
reject(new OpenClawGatewayError(`${method} timed out`, 'RPC_TIMEOUT'))
|
||||
}, timeoutMs)
|
||||
|
||||
this.pending.set(id, { resolve, reject, timeout })
|
||||
socket.send(JSON.stringify(frame))
|
||||
})
|
||||
}
|
||||
|
||||
async health(): Promise<Record<string, unknown>> {
|
||||
return asRecord(await this.request('health'))
|
||||
}
|
||||
|
||||
async status(): Promise<Record<string, unknown>> {
|
||||
return asRecord(await this.request('status'))
|
||||
}
|
||||
|
||||
async listAgents(): Promise<GatewayAgentSummary[]> {
|
||||
const payload = asRecord(await this.request('agents.list', {}, 15000))
|
||||
|
||||
return asArray(payload.agents).map((item) => {
|
||||
const entry = asRecord(item)
|
||||
const identity = asRecord(entry.identity)
|
||||
|
||||
return {
|
||||
id: stringValue(entry.id) ?? 'unknown',
|
||||
name: stringValue(entry.name) ?? stringValue(identity.name) ?? 'Agent',
|
||||
emoji: stringValue(identity.emoji),
|
||||
theme: stringValue(identity.theme),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async listSessions(agentId?: string, limit = 24): Promise<GatewaySessionSummary[]> {
|
||||
const payload = asRecord(
|
||||
await this.request(
|
||||
'sessions.list',
|
||||
{
|
||||
includeGlobal: true,
|
||||
includeUnknown: false,
|
||||
includeDerivedTitles: true,
|
||||
includeLastMessage: true,
|
||||
limit,
|
||||
...(agentId?.trim() ? { agentId: agentId.trim() } : {}),
|
||||
},
|
||||
15000,
|
||||
),
|
||||
)
|
||||
|
||||
return asArray(payload.sessions).map((item) => {
|
||||
const entry = asRecord(item)
|
||||
return {
|
||||
key: stringValue(entry.key) ?? 'main',
|
||||
displayName: stringValue(entry.displayName) ?? stringValue(entry.label),
|
||||
updatedAtMs: numberValue(entry.updatedAt),
|
||||
derivedTitle: stringValue(entry.derivedTitle),
|
||||
lastMessagePreview: stringValue(entry.lastMessagePreview),
|
||||
model: stringValue(entry.model),
|
||||
thinkingLevel: stringValue(entry.thinkingLevel),
|
||||
abortedLastRun: boolValue(entry.abortedLastRun),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async loadHistory(sessionKey: string, limit = 120): Promise<GatewayChatMessage[]> {
|
||||
const payload = asRecord(
|
||||
await this.request(
|
||||
'chat.history',
|
||||
{
|
||||
sessionKey,
|
||||
limit,
|
||||
},
|
||||
15000,
|
||||
),
|
||||
)
|
||||
|
||||
return asArray(payload.messages).map((item) => {
|
||||
const entry = asRecord(item)
|
||||
return {
|
||||
id: stringValue(entry.id) ?? randomId(),
|
||||
role: stringValue(entry.role) ?? 'assistant',
|
||||
text: extractMessageText(entry),
|
||||
timestampMs: numberValue(entry.timestamp),
|
||||
toolCallId: stringValue(entry.toolCallId) ?? stringValue(entry.tool_call_id),
|
||||
toolName: stringValue(entry.toolName) ?? stringValue(entry.tool_name),
|
||||
stopReason: stringValue(entry.stopReason),
|
||||
pending: false,
|
||||
error: false,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async sendChat(params: {
|
||||
sessionKey: string
|
||||
message: string
|
||||
thinking: string
|
||||
attachments?: GatewayChatAttachmentPayload[]
|
||||
}): Promise<string> {
|
||||
const runId = randomId()
|
||||
|
||||
const payload = asRecord(
|
||||
await this.request(
|
||||
'chat.send',
|
||||
{
|
||||
sessionKey: params.sessionKey,
|
||||
message: params.message,
|
||||
thinking: params.thinking,
|
||||
timeoutMs: 30000,
|
||||
idempotencyKey: runId,
|
||||
...(params.attachments && params.attachments.length > 0
|
||||
? {
|
||||
attachments: params.attachments,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
35000,
|
||||
),
|
||||
)
|
||||
|
||||
return stringValue(payload.runId) ?? runId
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
for (const pending of this.pending.values()) {
|
||||
clearTimeout(pending.timeout)
|
||||
}
|
||||
this.pending.clear()
|
||||
|
||||
const socket = this.socket
|
||||
this.socket = null
|
||||
|
||||
if (!socket) {
|
||||
return
|
||||
}
|
||||
|
||||
socket.removeEventListener('message', this.handleMessageRef)
|
||||
socket.removeEventListener('close', this.handleCloseRef)
|
||||
socket.removeEventListener('error', this.handleErrorRef)
|
||||
|
||||
if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) {
|
||||
socket.close()
|
||||
}
|
||||
}
|
||||
|
||||
private async handleMessage(event: MessageEvent): Promise<void> {
|
||||
const text = toText(event.data)
|
||||
let payload: Record<string, unknown>
|
||||
|
||||
try {
|
||||
payload = JSON.parse(text) as Record<string, unknown>
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const type = stringValue(payload.type)
|
||||
|
||||
if (type === 'event') {
|
||||
const frame = {
|
||||
type: 'event' as const,
|
||||
event: stringValue(payload.event) ?? '',
|
||||
seq: numberValue(payload.seq),
|
||||
payload: payload.payload,
|
||||
}
|
||||
|
||||
for (const listener of this.listeners) {
|
||||
listener(frame)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (type !== 'res') {
|
||||
return
|
||||
}
|
||||
|
||||
const id = stringValue(payload.id)
|
||||
if (!id) {
|
||||
return
|
||||
}
|
||||
|
||||
const pending = this.pending.get(id)
|
||||
if (!pending) {
|
||||
return
|
||||
}
|
||||
|
||||
this.pending.delete(id)
|
||||
clearTimeout(pending.timeout)
|
||||
|
||||
const ok = boolValue(payload.ok) ?? false
|
||||
|
||||
if (!ok) {
|
||||
const error = asRecord(payload.error)
|
||||
pending.reject(
|
||||
new OpenClawGatewayError(
|
||||
stringValue(error.message) ?? 'Gateway request failed',
|
||||
stringValue(error.code),
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
pending.resolve(payload.payload)
|
||||
}
|
||||
|
||||
private failPending(error: OpenClawGatewayError): void {
|
||||
for (const [id, pending] of this.pending.entries()) {
|
||||
clearTimeout(pending.timeout)
|
||||
pending.reject(error)
|
||||
this.pending.delete(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
90
src/state/openclawConsoleStore.ts
Normal file
90
src/state/openclawConsoleStore.ts
Normal file
@ -0,0 +1,90 @@
|
||||
'use client'
|
||||
|
||||
import { create } from 'zustand'
|
||||
import { createJSONStorage, persist } from 'zustand/middleware'
|
||||
|
||||
import type { AssistantMode, IntegrationDefaults, ThinkingLevel } from '@/lib/openclaw/types'
|
||||
|
||||
type OpenClawConsoleState = {
|
||||
defaultsLoaded: boolean
|
||||
openclawUrl: string
|
||||
openclawToken: string
|
||||
vaultUrl: string
|
||||
vaultNamespace: string
|
||||
vaultToken: string
|
||||
apisixUrl: string
|
||||
apisixToken: string
|
||||
assistantMode: AssistantMode
|
||||
thinking: ThinkingLevel
|
||||
selectedAgentId: string
|
||||
selectedSessionKey: string
|
||||
applyDefaults: (defaults: IntegrationDefaults) => void
|
||||
setOpenclawUrl: (value: string) => void
|
||||
setOpenclawToken: (value: string) => void
|
||||
setVaultUrl: (value: string) => void
|
||||
setVaultNamespace: (value: string) => void
|
||||
setVaultToken: (value: string) => void
|
||||
setApisixUrl: (value: string) => void
|
||||
setApisixToken: (value: string) => void
|
||||
setAssistantMode: (value: AssistantMode) => void
|
||||
setThinking: (value: ThinkingLevel) => void
|
||||
setSelectedAgentId: (value: string) => void
|
||||
setSelectedSessionKey: (value: string) => void
|
||||
}
|
||||
|
||||
export const useOpenClawConsoleStore = create<OpenClawConsoleState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
defaultsLoaded: false,
|
||||
openclawUrl: '',
|
||||
openclawToken: '',
|
||||
vaultUrl: '',
|
||||
vaultNamespace: '',
|
||||
vaultToken: '',
|
||||
apisixUrl: '',
|
||||
apisixToken: '',
|
||||
assistantMode: 'ask',
|
||||
thinking: 'high',
|
||||
selectedAgentId: '',
|
||||
selectedSessionKey: '',
|
||||
applyDefaults: (defaults) => {
|
||||
const current = get()
|
||||
set({
|
||||
defaultsLoaded: true,
|
||||
openclawUrl: current.openclawUrl || defaults.openclawUrl,
|
||||
vaultUrl: current.vaultUrl || defaults.vaultUrl,
|
||||
vaultNamespace: current.vaultNamespace || defaults.vaultNamespace,
|
||||
apisixUrl: current.apisixUrl || defaults.apisixUrl,
|
||||
})
|
||||
},
|
||||
setOpenclawUrl: (openclawUrl) => set({ openclawUrl }),
|
||||
setOpenclawToken: (openclawToken) => set({ openclawToken }),
|
||||
setVaultUrl: (vaultUrl) => set({ vaultUrl }),
|
||||
setVaultNamespace: (vaultNamespace) => set({ vaultNamespace }),
|
||||
setVaultToken: (vaultToken) => set({ vaultToken }),
|
||||
setApisixUrl: (apisixUrl) => set({ apisixUrl }),
|
||||
setApisixToken: (apisixToken) => set({ apisixToken }),
|
||||
setAssistantMode: (assistantMode) => set({ assistantMode }),
|
||||
setThinking: (thinking) => set({ thinking }),
|
||||
setSelectedAgentId: (selectedAgentId) => set({ selectedAgentId }),
|
||||
setSelectedSessionKey: (selectedSessionKey) => set({ selectedSessionKey }),
|
||||
}),
|
||||
{
|
||||
name: 'openclaw-console-session',
|
||||
storage: createJSONStorage(() => sessionStorage),
|
||||
partialize: (state) => ({
|
||||
openclawUrl: state.openclawUrl,
|
||||
openclawToken: state.openclawToken,
|
||||
vaultUrl: state.vaultUrl,
|
||||
vaultNamespace: state.vaultNamespace,
|
||||
vaultToken: state.vaultToken,
|
||||
apisixUrl: state.apisixUrl,
|
||||
apisixToken: state.apisixToken,
|
||||
assistantMode: state.assistantMode,
|
||||
thinking: state.thinking,
|
||||
selectedAgentId: state.selectedAgentId,
|
||||
selectedSessionKey: state.selectedSessionKey,
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
Loading…
Reference in New Issue
Block a user