feat: integrate openclaw assistant workspace

This commit is contained in:
Haitao Pan 2026-03-12 12:18:25 +08:00
parent d65ea24956
commit 9a915ae080
29 changed files with 3015 additions and 561 deletions

View File

@ -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

View File

@ -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`

View File

@ -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

View File

@ -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`

View File

@ -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`

View File

@ -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`

View File

@ -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>

View 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())
}

View 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)
}
}

View 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')
}

View File

@ -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>

View File

@ -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>
)
}

View File

@ -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>
)
}

View 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>
)
}

View File

@ -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,
},
];

View File

@ -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

View File

@ -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>
)
}

View File

@ -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"

View 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>
)
}

View 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>
)
}

View File

@ -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: '发送',
},

View File

@ -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
View 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()
}

View File

@ -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>
)
}

View File

@ -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,
},
},
{

View File

@ -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>
)
}

View 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',
}
}

View 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)
}
}
}

View 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,
}),
},
),
)