feat(ai): add chart renderer, floating ball hook, and provider updates
- Add ai-chart-renderer for rendering charts in AI responses - Add use-floating-ball hook for draggable AI assistant widget - Update ai-assistant-widget, ai-chat-panel, ai-markdown-renderer, ai-provider-selector - Update use-ai-chat-stream hook and prompt-templates service
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useCallback, useRef } from "react"
|
||||
import { useState, useCallback, useRef, useEffect } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import type { AiChatMessage } from "../types"
|
||||
import {
|
||||
@@ -11,11 +11,14 @@ import {
|
||||
appendTokenToLastAssistant,
|
||||
} from "./stream-utils"
|
||||
|
||||
const HISTORY_STORAGE_KEY = "ai-chat-history"
|
||||
const MAX_HISTORY = 20
|
||||
|
||||
/**
|
||||
* AI 流式聊天 Hook
|
||||
*
|
||||
* 通过 SSE 端点消费流式 AI 回复。
|
||||
* 支持:逐 token 渲染、停止生成(AbortController)、错误处理。
|
||||
* 支持:逐 token 渲染、停止生成(AbortController)、错误处理、历史持久化恢复。
|
||||
*/
|
||||
type UseAiChatStreamReturn = {
|
||||
messages: AiChatMessage[]
|
||||
@@ -26,13 +29,49 @@ type UseAiChatStreamReturn = {
|
||||
clear: () => void
|
||||
}
|
||||
|
||||
function loadHistory(): AiChatMessage[] {
|
||||
if (typeof window === "undefined") return []
|
||||
try {
|
||||
const raw = localStorage.getItem(HISTORY_STORAGE_KEY)
|
||||
if (!raw) return []
|
||||
const parsed = JSON.parse(raw) as AiChatMessage[]
|
||||
if (!Array.isArray(parsed)) return []
|
||||
// 过滤掉空的 assistant 消息
|
||||
return parsed.filter((m) => m && m.role && typeof m.content === "string")
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function useAiChatStream(): UseAiChatStreamReturn {
|
||||
const t = useTranslations("ai")
|
||||
const [messages, setMessages] = useState<AiChatMessage[]>([])
|
||||
// 懒初始化:从 localStorage 恢复历史
|
||||
const [messages, setMessages] = useState<AiChatMessage[]>(() => loadHistory())
|
||||
const [streaming, setStreaming] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
|
||||
// 持久化(防抖)
|
||||
const persistTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
useEffect(() => {
|
||||
if (streaming) return
|
||||
if (persistTimerRef.current) clearTimeout(persistTimerRef.current)
|
||||
persistTimerRef.current = setTimeout(() => {
|
||||
try {
|
||||
if (messages.length === 0) {
|
||||
localStorage.removeItem(HISTORY_STORAGE_KEY)
|
||||
} else {
|
||||
localStorage.setItem(HISTORY_STORAGE_KEY, JSON.stringify(messages.slice(-MAX_HISTORY)))
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, 500)
|
||||
return () => {
|
||||
if (persistTimerRef.current) clearTimeout(persistTimerRef.current)
|
||||
}
|
||||
}, [messages, streaming])
|
||||
|
||||
const send = useCallback(
|
||||
async (
|
||||
inputMessages: AiChatMessage[],
|
||||
@@ -105,6 +144,11 @@ export function useAiChatStream(): UseAiChatStreamReturn {
|
||||
const clear = useCallback((): void => {
|
||||
setMessages([])
|
||||
setError(null)
|
||||
try {
|
||||
localStorage.removeItem(HISTORY_STORAGE_KEY)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { messages, streaming, error, send, stop, clear }
|
||||
|
||||
Reference in New Issue
Block a user