常见的AI对话场景
大部分AI对话场景都与chatGPT很相似

session_id可以放在url中 并根据其变化情况 为会话组件设置不同的key 以规避"重置state"的情况
会话区域有三种状态:
1.无会话(无session_id)
2.刚创建的新会话(有session_id)
3.老会话(有session_id)
仅当从状态1->2时 虽然session_id变化 但是会话组件不能重载.此时session_id会从空变为另一个值
不过 从1->3时 用户在无会话时点击历史记录 session_id也会从无到有 因此需要为这种情况设置一个特殊入口
代码如下
tsx
import { FC } from 'react'
export const AIChat: FC = () => {
/** 从url中取到当前会话的id */
const { id } = useParams<{ id?: string }>()
const session_id = useMemo(() => {
const _id = parseInt(id!)
return Number.isInteger(_id) ? _id : null
}, [id])
/** 用key使会话组件重载 */
const keyRef = useRef(0)
/** 创建新会话时 不重置组件 */
const prevSessionId = useRef(session_id)
if (prevSessionId.current && prevSessionId.current !== session_id) {
++keyRef.current
}
/** 点击历史记录时 必定重置组件 */
const nextUnstableSessionId = useRef<number>()
if (nextUnstableSessionId.current === session_id) {
++keyRef.current
nextUnstableSessionId.current = undefined
}
prevSessionId.current = session_id
return (
<div className='flex '>
<ChatHistory
session_id={session_id}
setNextSessionId={(val) => (nextUnstableSessionId.current = val)}
/>
<Dialog key={keyRef.current} session_id={session_id} />
</div>
)
}
/** 历史记录 */
declare const ChatHistory: FC<{
session_id: null | number
setNextSessionId: (val: number) => void
}>
/** 会话区域 */
declare const Dialog: FC<{ session_id: null | number }>
nextUnstableSessionId是为空会话点击历史记录的情形提供的 只要session_id变为该id 就重置组件
tsx
// ChatHistory中
<Link to={`/qa/${session_id}`} onClick={() => setNextSessionId(session_id)}>
历史会话{session_id}
</Link>
在Dialog组件中 由于使用key进行了重置 因此仅需要根据第一次传入session_id的判断当前所处的状态
ts
// Dialog组件 控制对话的hook
export const useDialog = (session_id: number | null) => {
const [dialog, setDialog] = useImmer<(UserDialog | AIDialog)[]>([])
const [status, setStatus] = useState<'loading' | 'answering' | 'ready'>(
'loading',
)
const acceptAnswer = async (stream: ReadableStream, index: number) => {
const reader = stream.getReader()
const decoder = new TextDecoder()
while (true) {
const { done, value } = await reader.read()
const text = decoder.decode(value, { stream: true })
setDialog((draft) => {
draft[index].content += text
})
if (done) {
setStatus('ready')
break
}
}
}
useMount(async () => {
if (!session_id) {
setStatus('ready')
return
}
const historyDialog = await getSessionHistory({ session_id })
setDialog(historyDialog)
// 还可以继续上一次被中断的会话
setStatus('ready')
})
const navigate = useNavigate()
const startQA = useMemoizedFn(async (content: string) => {
setStatus('answering')
let answerIndex: number
setDialog((draft) => {
draft.push(
{
role: 'question',
content,
},
{
role: 'answer',
content: '',
},
)
answerIndex = draft.length - 1
})
let currentSessionId = session_id
if (!currentSessionId) {
// 无会话时 先创建会话
const { id } = await createSession()
currentSessionId = id
navigate(`/qa/${id}`)
// 可以向历史记录中添加当前的session
}
const { body } = await startSessionQA({
session_id: currentSessionId,
content,
})
acceptAnswer(body, answerIndex!)
})
return {
dialog,
status,
startQA,
// 也可以有stopQA等函数
}
}
如果不能重置Dialog组件
我所遇到的业务场景是:
1.用户可以创建会话组
2.如果当前的组无会话 则展示常规情况的大输入框
3.如果有会话,则展示这样的内容
在开发时 我将2、3种场景视作同一组件
tsx
const SessionGroup: FC = () => {
const { data, loading } = useSessionGroupInfo(group_id)
const { dialog } = useDialog()
if (loading) return <Skeleton active className='p-4' />
if (!data) return <EmptyGroup />
return (
<div className='flex'>
<Dialog />
<GroupChart />
</div>
)
}
但这就带来一个问题 如果使用key重置了SessionGroup组件 那么与会话无关的GroupChart一定也会重置 但这些统计内容是一定不能重置的 因为包含了echarts、入场动画、用户滚动等
因此我对useDialog进行了改造 使之具有了"重置state"的能力
ts
// useDialog 中
const resetKey = useRef(0)
function withResetKey<Args extends any[], R>(fn: (...args: Args) => R) {
const currentKey = resetKey.current
const fnWithResetKey = (...args: Args) => {
if (currentKey !== resetKey.current) {
throw new Error('resetKey落后')
}
return fn(...args)
}
return fnWithResetKey
}
const [dialog, _setDialog] = useImmer<(UserDialog | AIDialog)[]>([])
const setDialog = withResetKey(_setDialog)
const [status, setStatus] = useState<'loading' | 'answering' | 'ready'>(
'loading',
)
const setStatus = withResetKey(_setStatus)
const resetAllState = ()=> {
++ resetKey.current
// 重置dialog、status
}
对每个setState函数都使用withResetKey包裹 确保在state重置前的回调函数不会干扰到重置后的state.需要注意的是 withResetKey是基于闭包进行的 因此不能和useMemoizedFn配合使用
此外 由于历史记录和无会话展示在同一个区域 url中的session_id都为空 无法区分 因此必须手动维护一个sessionStatus 用于判断当前组件的状态.此外 session_id还需要和sessionStatus同步更新 session_id也需要手动维护
ts
const [sessionStatus, setSessionStatus] = useState<
'history' | 'new' | 'creating' | 'created'
>('new')
const { id } = useParams<{ id?: string }>()
const [session_id, _setSessionId] = useState(() => {
const _id = parseInt(id!)
return Number.isInteger(_id) ? _id : null
})
const navigate = useNavigate()
const setSessionId = useMemoizedFn((val?: number | null) => {
navigate(`/session-group/${group_id}` + (val ? `/${val}` : ''))
_setSessionId(val ?? null)
})
每当变更session_id时(包括使用<Link>组件跳转路由) 需要调用setSessionId函数 并用setSessionStatus更新session的状态
反思和优化
我在实际开发中 发现"重置state"和"手动维护session_id和sessionStatus"非常繁琐
后者是由于历史记录和会话在同一个位置 是不可避免的 但重置state完全可以通过合理的设计进行规避 优化方案如下:
- 用css控制GroupChart的位置 使之在空会话时隐藏
- 将GroupChart组件挂载在html的其他位置 通过绝对定位控制其位置(即KeepAlive)
- 从逻辑上 将无、有会话的组件拆分为两个组件.在其公共父组件维护一个state 包含创建会话的信息.无会话组件提问后 设置该state 并将其输入给有会话组件.有会话组件就可以用key重置组件内部的Dialog组件