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:
SpecialX
2026-06-24 12:02:29 +08:00
parent 61e76f0d67
commit a48e7d0e27
8 changed files with 988 additions and 99 deletions

View File

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