feat: 添加连接设置弹窗,支持动态配置 WebSocket 连接

This commit is contained in:
oudecheng 2026-06-12 19:17:50 +08:00
parent 6f8c4a7ce8
commit 43cea50df8
4 changed files with 224 additions and 10 deletions

View File

@ -1,11 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Zap, ArrowLeft, Bot, Clock, Sun, Moon, PanelRightOpen, X, Brain } from 'lucide-react' import { Zap, ArrowLeft, Bot, Clock, Sun, Moon, PanelRightOpen, X, Brain, Wifi } from 'lucide-react'
import { ChatContainer } from './components/Chat/ChatContainer' import { ChatContainer } from './components/Chat/ChatContainer'
import { TopicList } from './components/Sidebar/TopicList' import { TopicList } from './components/Sidebar/TopicList'
import { SchedulerJobList } from './components/Sidebar/SchedulerJobList' import { SchedulerJobList } from './components/Sidebar/SchedulerJobList'
import { MemoryPanel } from './components/Panel/MemoryPanel' import { MemoryPanel } from './components/Panel/MemoryPanel'
import { SkillList } from './components/Panel/SkillList' import { SkillList } from './components/Panel/SkillList'
import { TodoPanel } from './components/Panel/TodoPanel' import { TodoPanel } from './components/Panel/TodoPanel'
import { SettingsModal, getGatewaySettings, buildWsUrl, type GatewaySettings } from './components/Settings/SettingsModal'
import { ConnectionStatus } from './components/ConnectionStatus' import { ConnectionStatus } from './components/ConnectionStatus'
import { ChannelSelector } from './components/Header/ChannelSelector' import { ChannelSelector } from './components/Header/ChannelSelector'
import { SessionSelector } from './components/Header/SessionSelector' import { SessionSelector } from './components/Header/SessionSelector'
@ -13,9 +14,13 @@ import { useWebSocket } from './hooks/useWebSocket'
import { useChat } from './hooks/useChat' import { useChat } from './hooks/useChat'
import type { ChatMessage, Command, Attachment, SchedulerJobSessionLookup } from './types/protocol' import type { ChatMessage, Command, Attachment, SchedulerJobSessionLookup } from './types/protocol'
const WS_URL = 'ws://127.0.0.1:19876/ws' function getInitialSettings(): GatewaySettings {
return getGatewaySettings()
}
function App() { function App() {
const [gatewaySettings, setGatewaySettings] = useState<GatewaySettings>(getInitialSettings)
const wsUrl = buildWsUrl(gatewaySettings)
const lastAutoSwitchedTopicRef = useRef<string | null>(null) const lastAutoSwitchedTopicRef = useRef<string | null>(null)
const { const {
@ -81,7 +86,7 @@ function App() {
} = useChat() } = useChat()
const { status, sendMessage } = useWebSocket({ const { status, sendMessage } = useWebSocket({
url: WS_URL, url: wsUrl,
onMessage: handleServerMessage, onMessage: handleServerMessage,
}) })
@ -128,6 +133,13 @@ function App() {
return localStorage.getItem('picobot-show-thinking') !== 'false' return localStorage.getItem('picobot-show-thinking') !== 'false'
}) })
const [settingsOpen, setSettingsOpen] = useState(false)
const handleSaveGatewaySettings = useCallback((newSettings: GatewaySettings) => {
setGatewaySettings(newSettings)
setSettingsOpen(false)
}, [])
useEffect(() => { useEffect(() => {
localStorage.setItem('picobot-show-thinking', String(showThinking)) localStorage.setItem('picobot-show-thinking', String(showThinking))
}, [showThinking]) }, [showThinking])
@ -481,6 +493,14 @@ function App() {
> >
<Brain className="h-4 w-4" /> <Brain className="h-4 w-4" />
</button> </button>
<button
onClick={() => setSettingsOpen(true)}
className="flex h-8 w-8 items-center justify-center rounded-lg text-[var(--text-muted)] hover:text-[var(--accent-cyan)] hover:bg-[var(--overlay-hover)] transition-all"
title="连接设置"
aria-label="Connection settings"
>
<Wifi className="h-4 w-4" />
</button>
</div> </div>
<div className="flex items-center gap-4 text-sm text-[var(--text-secondary)]"> <div className="flex items-center gap-4 text-sm text-[var(--text-secondary)]">
<ChannelSelector <ChannelSelector
@ -702,6 +722,14 @@ function App() {
requestTodoList={requestTodoList} requestTodoList={requestTodoList}
sendCommand={sendMemoryCommand} sendCommand={sendMemoryCommand}
/> />
{/* 连接设置弹窗 */}
{settingsOpen && (
<SettingsModal
onClose={() => setSettingsOpen(false)}
onSave={handleSaveGatewaySettings}
/>
)}
</div> </div>
) )
} }

View File

@ -0,0 +1,171 @@
import { useState, useEffect } from 'react'
import { X, Wifi, RotateCcw } from 'lucide-react'
export interface GatewaySettings {
host: string
port: number
}
const DEFAULT_HOST = '127.0.0.1'
const DEFAULT_PORT = 19876
export function getGatewaySettings(): GatewaySettings {
try {
const host = localStorage.getItem('picobot-gateway-host') || DEFAULT_HOST
const portStr = localStorage.getItem('picobot-gateway-port')
const port = portStr ? parseInt(portStr, 10) : DEFAULT_PORT
return { host, port: isNaN(port) ? DEFAULT_PORT : port }
} catch {
return { host: DEFAULT_HOST, port: DEFAULT_PORT }
}
}
export function buildWsUrl(settings: GatewaySettings): string {
return `ws://${settings.host}:${settings.port}/ws`
}
interface SettingsModalProps {
onClose: () => void
onSave: (settings: GatewaySettings) => void
}
export function SettingsModal({ onClose, onSave }: SettingsModalProps) {
const [host, setHost] = useState(DEFAULT_HOST)
const [port, setPort] = useState(String(DEFAULT_PORT))
const [error, setError] = useState('')
useEffect(() => {
const settings = getGatewaySettings()
setHost(settings.host)
setPort(String(settings.port))
}, [])
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [onClose])
const handleSave = () => {
const trimmedHost = host.trim()
const portNum = parseInt(port, 10)
if (!trimmedHost) {
setError('主机地址不能为空')
return
}
if (isNaN(portNum) || portNum < 1 || portNum > 65535) {
setError('端口号必须在 1-65535 之间')
return
}
setError('')
localStorage.setItem('picobot-gateway-host', trimmedHost)
localStorage.setItem('picobot-gateway-port', String(portNum))
onSave({ host: trimmedHost, port: portNum })
}
const handleReset = () => {
setHost(DEFAULT_HOST)
setPort(String(DEFAULT_PORT))
setError('')
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm animate-fade-in"
onClick={onClose}
>
{/* Modal container */}
<div
className="relative w-[90vw] max-w-md rounded-2xl border border-[var(--border-color)] bg-[var(--bg-secondary)] shadow-2xl flex flex-col overflow-hidden animate-scale-in"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center gap-3 shrink-0 px-6 py-4 border-b border-[var(--border-color)] bg-[var(--bg-tertiary)]/50">
<Wifi className="h-5 w-5 text-[var(--accent-cyan)]" />
<span className="text-lg font-semibold text-[var(--text-primary)]"></span>
<button
onClick={onClose}
className="ml-auto p-2 rounded-lg text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-hover)] transition-colors"
aria-label="关闭"
title="关闭 (Esc)"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Body */}
<div className="flex-1 p-6 space-y-5">
{/* Host */}
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-1.5">
</label>
<input
type="text"
value={host}
onChange={(e) => { setHost(e.target.value); setError('') }}
placeholder="127.0.0.1"
className="w-full px-3 py-2.5 rounded-xl bg-[var(--bg-tertiary)] border border-[var(--border-color)] text-[var(--text-primary)] text-sm placeholder:text-[var(--text-muted)] focus:outline-none focus:border-[var(--accent-cyan)] focus:ring-2 focus:ring-[var(--focus-ring)] transition-colors"
/>
</div>
{/* Port */}
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-1.5">
</label>
<input
type="number"
value={port}
onChange={(e) => { setPort(e.target.value); setError('') }}
placeholder="19876"
min={1}
max={65535}
className="w-full px-3 py-2.5 rounded-xl bg-[var(--bg-tertiary)] border border-[var(--border-color)] text-[var(--text-primary)] text-sm placeholder:text-[var(--text-muted)] focus:outline-none focus:border-[var(--accent-cyan)] focus:ring-2 focus:ring-[var(--focus-ring)] transition-colors"
/>
</div>
{/* Error */}
{error && (
<div className="text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
{error}
</div>
)}
{/* Current URL preview */}
<div className="text-xs text-[var(--text-muted)] bg-[var(--overlay-dim)] rounded-lg px-3 py-2 font-mono">
ws://{host.trim() || '...'}:{port || '...'}/ws
</div>
</div>
{/* Footer */}
<div className="shrink-0 px-6 py-3 border-t border-[var(--border-color)] bg-[var(--bg-tertiary)]/30 flex items-center gap-3">
<button
onClick={handleReset}
className="flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:bg-[var(--overlay-hover)] transition-colors"
>
<RotateCcw className="h-3.5 w-3.5" />
</button>
<div className="flex-1" />
<button
onClick={onClose}
className="px-4 py-2 rounded-lg text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-hover)] transition-colors"
>
</button>
<button
onClick={handleSave}
className="px-5 py-2 rounded-lg text-sm font-medium text-white bg-[var(--accent-cyan)]/20 border border-[var(--accent-cyan)]/30 hover:bg-[var(--accent-cyan)]/30 hover:border-[var(--accent-cyan)]/50 transition-all"
>
</button>
</div>
</div>
</div>
)
}

View File

@ -108,15 +108,28 @@ export function useWebSocket({
return false return false
}, []) }, [])
// Auto connect on mount // Auto connect on mount, reconnect when url changes
useEffect(() => { const prevUrlRef = useRef(url)
connect()
useEffect(() => {
// 首次挂载,或者 url 发生了变化,都要重连
if (prevUrlRef.current !== url) {
console.log(`WebSocket URL changed: ${prevUrlRef.current}${url}`)
// 先断开旧连接
disconnect()
prevUrlRef.current = url
}
// 短暂延迟确保旧 socket 已关闭
const timer = setTimeout(() => {
connect()
}, 50)
// Cleanup on unmount
return () => { return () => {
clearTimeout(timer)
disconnect() disconnect()
} }
}, [connect, disconnect]) }, [url, connect, disconnect])
return { return {
status, status,

View File

@ -2,6 +2,8 @@ import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import { resolve } from 'path' import { resolve } from 'path'
const gatewayPort = process.env.VITE_GATEWAY_PORT || '19876'
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
@ -14,10 +16,10 @@ export default defineConfig({
port: 3000, port: 3000,
proxy: { proxy: {
'/ws': { '/ws': {
target: 'ws://127.0.0.1:19876', target: `ws://127.0.0.1:${gatewayPort}`,
ws: true, ws: true,
}, },
'/health': 'http://127.0.0.1:19876', '/health': `http://127.0.0.1:${gatewayPort}`,
}, },
}, },
build: { build: {