mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-07 04:50:57 +08:00
Improve NOFXi agent product handling
This commit is contained in:
@@ -1,5 +1,12 @@
|
||||
import { useRef, useState, useCallback, useEffect, useImperativeHandle, forwardRef } from 'react'
|
||||
import { ArrowUp } from 'lucide-react'
|
||||
import {
|
||||
useRef,
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
forwardRef,
|
||||
} from 'react'
|
||||
import { ArrowUp, Square } from 'lucide-react'
|
||||
|
||||
export interface ChatInputHandle {
|
||||
focus: () => void
|
||||
@@ -10,43 +17,60 @@ export interface ChatInputHandle {
|
||||
interface ChatInputProps {
|
||||
language: string
|
||||
loading: boolean
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
onSend: (text: string) => void
|
||||
onStop: () => void
|
||||
}
|
||||
|
||||
export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
||||
function ChatInput({ language, loading, onSend }, ref) {
|
||||
const [input, setInput] = useState('')
|
||||
function ChatInput(
|
||||
{ language, loading, value, onChange, onSend, onStop },
|
||||
ref
|
||||
) {
|
||||
const [composing, setComposing] = useState(false)
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: () => inputRef.current?.focus(),
|
||||
clear: () => {
|
||||
setInput('')
|
||||
if (inputRef.current) inputRef.current.style.height = 'auto'
|
||||
},
|
||||
getValue: () => input,
|
||||
}))
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
focus: () => inputRef.current?.focus(),
|
||||
clear: () => {
|
||||
onChange('')
|
||||
if (inputRef.current) inputRef.current.style.height = 'auto'
|
||||
},
|
||||
getValue: () => value,
|
||||
}),
|
||||
[onChange, value]
|
||||
)
|
||||
|
||||
const resizeInput = useCallback(() => {
|
||||
const el = inputRef.current
|
||||
if (!el) return
|
||||
el.style.height = 'auto'
|
||||
el.style.height = Math.min(el.scrollHeight, 150) + 'px'
|
||||
}, [])
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setInput(e.target.value)
|
||||
const el = e.target
|
||||
el.style.height = 'auto'
|
||||
el.style.height = Math.min(el.scrollHeight, 150) + 'px'
|
||||
onChange(e.target.value)
|
||||
},
|
||||
[]
|
||||
[onChange]
|
||||
)
|
||||
|
||||
const handleSend = () => {
|
||||
const msg = input.trim()
|
||||
const msg = value.trim()
|
||||
if (!msg || loading) return
|
||||
setInput('')
|
||||
onChange('')
|
||||
if (inputRef.current) inputRef.current.style.height = 'auto'
|
||||
onSend(msg)
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
resizeInput()
|
||||
}, [resizeInput, value])
|
||||
|
||||
// Keyboard shortcut: Cmd+K to focus
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -84,7 +108,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
||||
>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
value={value}
|
||||
onChange={handleInputChange}
|
||||
onCompositionStart={() => setComposing(true)}
|
||||
onCompositionEnd={() => setComposing(false)}
|
||||
@@ -115,26 +139,40 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={loading || !input.trim()}
|
||||
onClick={loading ? onStop : handleSend}
|
||||
disabled={!loading && !value.trim()}
|
||||
title={
|
||||
loading
|
||||
? language === 'zh'
|
||||
? '停止当前回复'
|
||||
: 'Stop current response'
|
||||
: language === 'zh'
|
||||
? '发送'
|
||||
: 'Send'
|
||||
}
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 12,
|
||||
border: 'none',
|
||||
background:
|
||||
loading || !input.trim()
|
||||
background: loading
|
||||
? 'rgba(239,68,68,0.16)'
|
||||
: !value.trim()
|
||||
? 'rgba(255,255,255,0.04)'
|
||||
: 'linear-gradient(135deg, #F0B90B, #d4a30a)',
|
||||
color: loading || !input.trim() ? '#3c3c52' : '#000',
|
||||
cursor: loading || !input.trim() ? 'not-allowed' : 'pointer',
|
||||
color: loading ? '#f87171' : !value.trim() ? '#3c3c52' : '#000',
|
||||
cursor: !loading && !value.trim() ? 'not-allowed' : 'pointer',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
flexShrink: 0,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
<ArrowUp size={16} strokeWidth={2.5} />
|
||||
{loading ? (
|
||||
<Square size={13} strokeWidth={2.6} fill="currentColor" />
|
||||
) : (
|
||||
<ArrowUp size={16} strokeWidth={2.5} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -15,6 +15,10 @@ export function chatStorageKey(userId?: string) {
|
||||
return `nofxi-agent-chat:${userId || 'guest'}`
|
||||
}
|
||||
|
||||
export function chatDraftStorageKey(userId?: string) {
|
||||
return `nofxi-agent-chat-draft:${userId || 'guest'}`
|
||||
}
|
||||
|
||||
export function getStoredAuthUserId(storage: Storage = window.localStorage) {
|
||||
try {
|
||||
const raw = storage.getItem('auth_user')
|
||||
@@ -65,8 +69,38 @@ export function persistAgentMessages<T>(
|
||||
storage.setItem(chatStorageKey(userId), JSON.stringify(messages))
|
||||
}
|
||||
|
||||
export function loadAgentDraft(storage: Storage, userId?: string) {
|
||||
try {
|
||||
return storage.getItem(chatDraftStorageKey(userId)) || ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export function persistAgentDraft(
|
||||
storage: Storage,
|
||||
userId: string | undefined,
|
||||
draft: string
|
||||
) {
|
||||
storage.setItem(chatDraftStorageKey(userId), draft)
|
||||
}
|
||||
|
||||
export function clearAgentDraft(storage: Storage, userId?: string) {
|
||||
for (const key of [
|
||||
chatDraftStorageKey(userId),
|
||||
chatDraftStorageKey('guest'),
|
||||
]) {
|
||||
storage.removeItem(key)
|
||||
}
|
||||
}
|
||||
|
||||
export function prepareAgentMessagesForPersistence<
|
||||
T extends { streaming?: boolean; text?: string; steps?: unknown[]; time?: string }
|
||||
T extends {
|
||||
streaming?: boolean
|
||||
text?: string
|
||||
steps?: unknown[]
|
||||
time?: string
|
||||
},
|
||||
>(messages: T[]): T[] {
|
||||
return messages.map((message) => {
|
||||
if (!message.streaming) {
|
||||
@@ -89,7 +123,10 @@ export function migrateAgentMessages(storage: Storage, userId?: string) {
|
||||
const targetMessages = loadMessagesFromKey(storage, targetKey)
|
||||
if (targetMessages.length > 0) return
|
||||
|
||||
for (const sourceKey of [chatStorageKey('guest'), LEGACY_AGENT_CHAT_STORAGE_KEY]) {
|
||||
for (const sourceKey of [
|
||||
chatStorageKey('guest'),
|
||||
LEGACY_AGENT_CHAT_STORAGE_KEY,
|
||||
]) {
|
||||
const sourceMessages = loadMessagesFromKey(storage, sourceKey)
|
||||
if (sourceMessages.length === 0) continue
|
||||
storage.setItem(targetKey, JSON.stringify(sourceMessages))
|
||||
@@ -101,4 +138,5 @@ export function clearAgentMessages(storage: Storage, userId?: string) {
|
||||
for (const key of candidateStorageKeys(userId)) {
|
||||
storage.removeItem(key)
|
||||
}
|
||||
clearAgentDraft(storage, userId)
|
||||
}
|
||||
|
||||
@@ -19,17 +19,17 @@ import { WelcomeScreen } from '../components/agent/WelcomeScreen'
|
||||
import { ChatMessages } from '../components/agent/ChatMessages'
|
||||
import { ChatInput, type ChatInputHandle } from '../components/agent/ChatInput'
|
||||
import { UserPreferencesPanel } from '../components/agent/UserPreferencesPanel'
|
||||
import {
|
||||
useAgentChatStore,
|
||||
} from '../stores/agentChatStore'
|
||||
import { useAgentChatStore } from '../stores/agentChatStore'
|
||||
import type { AgentMessage as Message, AgentStep } from '../types/agent'
|
||||
import {
|
||||
chatStorageKey,
|
||||
clearAgentMessages,
|
||||
getStoredAuthUserId,
|
||||
loadAgentDraft,
|
||||
loadAgentMessages,
|
||||
migrateAgentMessages,
|
||||
prepareAgentMessagesForPersistence,
|
||||
persistAgentDraft,
|
||||
persistAgentMessages,
|
||||
} from '../lib/agentChatStorage'
|
||||
|
||||
@@ -50,6 +50,34 @@ function cleanupActiveAgentStream() {
|
||||
activeStreamReader = null
|
||||
}
|
||||
|
||||
function stopActiveAgentStream(userId?: string, language = 'zh') {
|
||||
if (!activeStreamAbortController && !activeStreamReader) return
|
||||
const stoppedText =
|
||||
language === 'zh' ? '已中止当前回复。' : 'Stopped the current response.'
|
||||
const now = new Date().toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
patchMessagesInStore(
|
||||
(prev) =>
|
||||
prev.map((m) => {
|
||||
if (m.role !== 'bot' || !m.streaming) return m
|
||||
const text = m.text?.trim()
|
||||
? `${m.text.trimEnd()}\n\n${stoppedText}`
|
||||
: stoppedText
|
||||
return {
|
||||
...m,
|
||||
text,
|
||||
streaming: false,
|
||||
time: m.time || now,
|
||||
}
|
||||
}),
|
||||
userId
|
||||
)
|
||||
cleanupActiveAgentStream()
|
||||
useAgentChatStore.getState().setLoading(false)
|
||||
}
|
||||
|
||||
function persistMessagesSnapshotForUser(userId?: string) {
|
||||
const { hydrated, messages } = useAgentChatStore.getState()
|
||||
if (!hydrated) return
|
||||
@@ -109,6 +137,7 @@ async function runAgentStream(params: {
|
||||
if (text.trim() === '/clear') {
|
||||
try {
|
||||
clearAgentMessages(window.localStorage, storageUserId)
|
||||
useAgentChatStore.getState().setDraftText('')
|
||||
} catch {
|
||||
// Ignore storage cleanup failure.
|
||||
}
|
||||
@@ -191,9 +220,7 @@ async function runAgentStream(params: {
|
||||
patchMessagesInStore(
|
||||
(prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === botId
|
||||
? { ...m, text: finalText, time: now() }
|
||||
: m
|
||||
m.id === botId ? { ...m, text: finalText, time: now() } : m
|
||||
),
|
||||
storageUserId
|
||||
)
|
||||
@@ -424,10 +451,12 @@ export function AgentChatPage() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(() => window.innerWidth > 1024)
|
||||
const storageKey = chatStorageKey(user?.id || storageUserId)
|
||||
const messages = useAgentChatStore((state) => state.messages)
|
||||
const draftText = useAgentChatStore((state) => state.draftText)
|
||||
const loading = useAgentChatStore((state) => state.loading)
|
||||
const historyHydrated = useAgentChatStore((state) => state.hydrated)
|
||||
const activeUserId = useAgentChatStore((state) => state.activeUserId)
|
||||
const resetForUser = useAgentChatStore((state) => state.resetForUser)
|
||||
const setDraftText = useAgentChatStore((state) => state.setDraftText)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const chatInputRef = useRef<ChatInputHandle>(null)
|
||||
|
||||
@@ -465,10 +494,12 @@ export function AgentChatPage() {
|
||||
nextUserId,
|
||||
loadAgentMessages<Message>(window.localStorage, nextUserId).messages
|
||||
)
|
||||
setDraftText(loadAgentDraft(window.localStorage, nextUserId))
|
||||
}, [
|
||||
activeUserId,
|
||||
historyHydrated,
|
||||
resetForUser,
|
||||
setDraftText,
|
||||
storageKey,
|
||||
storageUserId,
|
||||
user?.id,
|
||||
@@ -490,6 +521,21 @@ export function AgentChatPage() {
|
||||
}
|
||||
}, [historyHydrated, messages, storageKey, storageUserId, user?.id])
|
||||
|
||||
// Persist the unsent draft so navigating away from the Agent page does not
|
||||
// wipe what the user was typing.
|
||||
useEffect(() => {
|
||||
if (!historyHydrated) return
|
||||
try {
|
||||
persistAgentDraft(
|
||||
window.localStorage,
|
||||
user?.id || storageUserId,
|
||||
draftText
|
||||
)
|
||||
} catch {
|
||||
// Ignore storage failures and keep typing responsive.
|
||||
}
|
||||
}, [draftText, historyHydrated, storageKey, storageUserId, user?.id])
|
||||
|
||||
// Responsive sidebar
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
@@ -526,6 +572,11 @@ export function AgentChatPage() {
|
||||
})
|
||||
}
|
||||
|
||||
const stopCurrentResponse = () => {
|
||||
stopActiveAgentStream(user?.id || storageUserId, language)
|
||||
chatInputRef.current?.focus()
|
||||
}
|
||||
|
||||
const quickActions =
|
||||
language === 'zh'
|
||||
? [
|
||||
@@ -682,7 +733,10 @@ export function AgentChatPage() {
|
||||
ref={chatInputRef}
|
||||
language={language}
|
||||
loading={loading}
|
||||
value={draftText}
|
||||
onChange={setDraftText}
|
||||
onSend={send}
|
||||
onStop={stopCurrentResponse}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,10 +4,12 @@ import type { AgentMessage } from '../types/agent'
|
||||
interface AgentChatStoreState {
|
||||
activeUserId?: string
|
||||
messages: AgentMessage[]
|
||||
draftText: string
|
||||
loading: boolean
|
||||
hydrated: boolean
|
||||
setActiveUserId: (userId?: string) => void
|
||||
setMessages: (messages: AgentMessage[]) => void
|
||||
setDraftText: (draftText: string) => void
|
||||
updateMessages: (
|
||||
updater: (messages: AgentMessage[]) => AgentMessage[]
|
||||
) => void
|
||||
@@ -19,10 +21,12 @@ interface AgentChatStoreState {
|
||||
export const useAgentChatStore = create<AgentChatStoreState>((set) => ({
|
||||
activeUserId: undefined,
|
||||
messages: [],
|
||||
draftText: '',
|
||||
loading: false,
|
||||
hydrated: false,
|
||||
setActiveUserId: (userId) => set({ activeUserId: userId }),
|
||||
setMessages: (messages) => set({ messages }),
|
||||
setDraftText: (draftText) => set({ draftText }),
|
||||
updateMessages: (updater) =>
|
||||
set((state) => ({ messages: updater(state.messages) })),
|
||||
setLoading: (loading) => set({ loading }),
|
||||
@@ -31,6 +35,7 @@ export const useAgentChatStore = create<AgentChatStoreState>((set) => ({
|
||||
set({
|
||||
activeUserId: userId,
|
||||
messages,
|
||||
draftText: '',
|
||||
loading: false,
|
||||
hydrated: true,
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user