feat: 添加连接设置弹窗,支持动态配置 WebSocket 连接
This commit is contained in:
parent
6f8c4a7ce8
commit
43cea50df8
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
171
web/src/components/Settings/SettingsModal.tsx
Normal file
171
web/src/components/Settings/SettingsModal.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user