Improve NOFXi agent product handling

This commit is contained in:
lky-spec
2026-05-02 22:55:10 +08:00
parent 25d0b30ea9
commit 159f27dfdd
19 changed files with 449 additions and 54 deletions

View File

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

View File

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

View File

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

View File

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