常见的AI对话场景和特殊情况

常见的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组件
相关推荐
前端之虎陈随易1 小时前
编程语言级别的Skill市场,AI Agent 的未来形态
前端·vue.js·人工智能·typescript·node.js
一路向北he2 小时前
字节钢铁军团--“提供情境,而非控制”
java·开发语言·前端
kyriewen2 小时前
豆包和千问同时关了智能体,我用它们搭的 3 个自动化全废了——迁移方案整理
前端·javascript·ai编程
前端一小卒2 小时前
我用 TypeScript 从零手写了一个 Claude Code,然后发现它的核心只有 30 行
前端·agent
大圣编程4 小时前
Python中continue语句的用法是什么?
开发语言·前端·python
yuhaiqiang4 小时前
随手 vibecoding 的浏览器插件已经 6000 多次下载,聊聊他的产品设计
前端·后端·面试
之歆4 小时前
Vue商品详情与放大镜组件
前端·javascript·vue.js
再吃一根胡萝卜5 小时前
如何把小米 MiMo 接入 CodeBuddy,打造私有 Agent
前端
负责的蛋挞6 小时前
异步HttpModule的实现方式
java·服务器·前端
YFF菲菲兔7 小时前
其他 Hooks 解析
react.js