refactor: ai module and implement rich text (#4282)

This commit is contained in:
Innei 2025-07-31 00:16:40 +08:00 committed by GitHub
parent 5d0ae8fd1c
commit 4f78a8aa48
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
56 changed files with 3523 additions and 1640 deletions

View File

@ -5,6 +5,7 @@
"main": "./dist/main/index.js",
"scripts": {
"build:web": "cd ../.. && pnpm build:web",
"db:generate": "pnpm --filter @follow/database run generate",
"dev": "cd ../.. && pnpm dev:web",
"dev:ssl": "cd ../.. && SSL=true pnpm dev:web",
"generate-pwa-assets": "pwa-assets-generator public/icon.svg",
@ -28,6 +29,8 @@
"@hcaptcha/react-hcaptcha": "1.12.0",
"@headlessui/react": "2.2.4",
"@hookform/resolvers": "5.1.1",
"@lexical/markdown": "0.33.1",
"@lexical/react": "0.33.1",
"@lottiefiles/dotlottie-react": "0.14.2",
"@openpanel/web": "1.0.1",
"@radix-ui/react-avatar": "1.1.10",
@ -73,6 +76,7 @@
"immer": "10.1.1",
"jotai": "2.12.5",
"lethargy": "1.0.9",
"lexical": "0.33.1",
"masonic": "4.1.0",
"mdast-util-gfm-table": "2.0.0",
"mdast-util-to-markdown": "2.1.2",

View File

@ -1,15 +1,8 @@
import type { UIMessage, UseChatHelpers } from "@ai-sdk/react"
import type { UIDataTypes } from "ai"
import { createContext, use } from "react"
import type { StoreApi } from "zustand"
import type { UseBoundStoreWithEqualityFn } from "zustand/traditional"
import type { AiChatContextStore } from "./store"
import type { BizUIMetadata, BizUITools } from "./types"
export const AIChatContext = createContext<
UseChatHelpers<UIMessage<BizUIMetadata, UIDataTypes, BizUITools>>
>(null!)
import type { AiChatStore } from "./store"
export type AIPanelRefs = {
panelRef: React.RefObject<HTMLDivElement>
@ -18,15 +11,14 @@ export type AIPanelRefs = {
export const AIPanelRefsContext = createContext<AIPanelRefs>(null!)
export const AIChatContextStoreContext = createContext<
UseBoundStoreWithEqualityFn<StoreApi<AiChatContextStore>>
>(null!)
export const AIChatStoreContext = createContext<UseBoundStoreWithEqualityFn<StoreApi<AiChatStore>>>(
null!,
)
// Hook to access AI chat context information
export const useAIChatStore = () => {
const store = use(AIChatContextStoreContext)
const store = use(AIChatStoreContext)
if (!store && import.meta.env.DEV) {
throw new Error("useAIChatStore must be used within a AIChatContextStoreContext")
throw new Error("useAIChatStore must be used within a AIChatStoreContext")
}
return store
}
@ -34,9 +26,8 @@ export const useAIChatStore = () => {
// Session methods context for managing chat session actions
export interface AIChatSessionMethods {
handleTitleGenerated: (title: string) => Promise<void>
handleFirstMessage: () => Promise<void>
handleNewChat: () => void
handleSwitchRoom: (roomId: string) => Promise<void>
}
export const AIChatSessionMethodsContext = createContext<AIChatSessionMethods>(null!)

View File

@ -0,0 +1,104 @@
import { useAIChatStore } from "./AIChatContext"
/**
* Hook to get the current room ID (chat ID) from the AI chat store
*/
export const useCurrentChatId = () => {
const store = useAIChatStore()
return store((state) => state.chatId)
}
/**
* Hook to get the current chat title from the AI chat store
*/
export const useCurrentTitle = () => {
const store = useAIChatStore()
return store((state) => state.currentTitle)
}
/**
* Hook to get the setter for current room ID
*/
export const useSetCurrentChatId = () => {
const store = useAIChatStore()
return (chatId: string | null) => {
const actions = store.getState().chatActions
if (chatId && chatId !== actions.getCurrentChatId()) {
// If we need to switch to a different room, create a new chat
actions.newChat()
}
}
}
/**
* Hook to get the setter for current title
*/
export const useSetCurrentTitle = () => {
const store = useAIChatStore()
return store.getState().chatActions.setCurrentTitle
}
/**
* Hook to get the chat actions
*/
export const useChatActions = () => {
const store = useAIChatStore()
return store((state) => state.chatActions)
}
/**
* Hook to get the block actions
*/
export const useBlockActions = () => {
const store = useAIChatStore()
return store((state) => state.blockActions)
}
/**
* Hook to get the chat instance
*/
export const useChatInstance = () => {
const store = useAIChatStore()
return store((state) => state.chatInstance)
}
/**
* Hook to get the current messages
*/
export const useMessages = () => {
const store = useAIChatStore()
return store((state) => state.messages)
}
/**
* Hook to check if the chat has messages
*/
export const useHasMessages = () => {
const store = useAIChatStore()
return store((state) => state.messages.length > 0)
}
export const useChatBlockActions = () => useAIChatStore()((state) => state.blockActions)
/**
* Hook to get the chat status
*/
export const useChatStatus = () => {
const store = useAIChatStore()
return store((state) => state.status)
}
/**
* Hook to get the chat error
*/
export const useChatError = () => {
const store = useAIChatStore()
return store((state) => state.error)
}
/**
* Hook to get the streaming status
*/
export const useIsStreaming = () => {
const store = useAIChatStore()
return store((state) => state.isStreaming)
}

View File

@ -0,0 +1,109 @@
import { produce } from "immer"
import { nanoid } from "nanoid"
import type { StateCreator } from "zustand"
import type { AIChatContextBlock } from "../types"
export interface BlockSlice {
blocks: AIChatContextBlock[]
blockActions: BlockSliceAction
}
export const createBlockSlice: (
initialBlocks?: AIChatContextBlock[],
) => StateCreator<BlockSlice, [], [], BlockSlice> =
(initialBlocks?: AIChatContextBlock[]) =>
(...params) => {
const defaultBlocks: AIChatContextBlock[] = initialBlocks || []
return {
blocks: defaultBlocks,
blockActions: new BlockSliceAction(params),
}
}
class BlockSliceAction {
constructor(private params: Parameters<StateCreator<BlockSlice, [], [], BlockSlice>>) {}
get set() {
return this.params[0]
}
get get() {
return this.params[1]
}
addBlock(block: Omit<AIChatContextBlock, "id">) {
const currentBlocks = this.get().blocks
// Only allow one mainEntry
if (block.type === "mainEntry" && currentBlocks.some((b) => b.type === "mainEntry")) {
return
}
// Only allow one selectedText
if (block.type === "selectedText" && currentBlocks.some((b) => b.type === "selectedText")) {
return
}
// Prevent duplicate referEntry or referFeed
if (
block.type === "referEntry" &&
block.value &&
currentBlocks.some((b) => b.type === "referEntry" && b.value === block.value)
) {
return
}
if (
block.type === "referFeed" &&
block.value &&
currentBlocks.some((b) => b.type === "referFeed" && b.value === block.value)
) {
return
}
this.set(
produce((state: BlockSlice) => {
state.blocks.push({ ...block, id: nanoid(8) })
}),
)
}
removeBlock(id: string) {
this.set(
produce((state: BlockSlice) => {
state.blocks = state.blocks.filter((block) => block.id !== id)
}),
)
}
updateBlock(id: string, updates: Partial<AIChatContextBlock>) {
this.set(
produce((state: BlockSlice) => {
state.blocks = state.blocks.map((block) =>
block.id === id ? { ...block, ...updates } : block,
)
}),
)
}
clearBlocks() {
this.set(
produce((state: BlockSlice) => {
state.blocks = []
}),
)
}
resetContext() {
this.set(
produce((state: BlockSlice) => {
state.blocks = []
}),
)
}
getBlocks() {
return this.get().blocks
}
}

View File

@ -0,0 +1,544 @@
import type { ChatInit, ChatState, ChatStatus } from "ai"
import { AbstractChat } from "ai"
import { throttle } from "es-toolkit/compat"
import { nanoid } from "nanoid"
import type { StateCreator } from "zustand"
import { AIPersistService } from "../../services"
import { generateChatTitle } from "../../utils/titleGeneration"
import { createChatTransport } from "../transport"
import type { BizUIMessage } from "../types"
// Event types and payloads
interface ChatStateEvents<UI_MESSAGE extends BizUIMessage> {
messages: { messages: UI_MESSAGE[] }
status: { status: ChatStatus }
error: { error: Error | undefined }
}
type ChatStateEventType = keyof ChatStateEvents<any>
// Event emitter for AI chat state changes with typed payloads
class ChatStateEventEmitter<UI_MESSAGE extends BizUIMessage> {
#listeners = new Map<ChatStateEventType, Set<(payload: any) => void>>()
on<T extends ChatStateEventType>(
event: T,
listener: (payload: ChatStateEvents<UI_MESSAGE>[T]) => void,
): () => void {
if (!this.#listeners.has(event)) {
this.#listeners.set(event, new Set())
}
this.#listeners.get(event)!.add(listener)
// Return unsubscribe function
return () => {
this.#listeners.get(event)?.delete(listener)
}
}
emit<T extends ChatStateEventType>(event: T, payload: ChatStateEvents<UI_MESSAGE>[T]): void {
this.#listeners.get(event)?.forEach((listener) => {
try {
listener(payload)
} catch (error) {
console.error(`Error in chat state listener for event ${event}:`, error)
}
})
}
clear(): void {
this.#listeners.clear()
}
}
// Zustand Chat State that implements AI SDK ChatState interface
class ZustandChatState<UI_MESSAGE extends BizUIMessage> implements ChatState<UI_MESSAGE> {
#messages: UI_MESSAGE[]
#status: ChatStatus = "ready"
#error: Error | undefined = undefined
#eventEmitter = new ChatStateEventEmitter<UI_MESSAGE>()
constructor(
initialMessages: UI_MESSAGE[] = [],
private updateZustandState: (updater: (state: ChatSlice) => ChatSlice) => void,
private chatId: string,
) {
this.#messages = initialMessages
this.#setupEventHandlers()
}
#setupEventHandlers(): void {
// Setup event handlers for automatic Zustand synchronization
this.#eventEmitter.on("messages", ({ messages }) => {
this.updateZustandState((state) => ({
...state,
messages: [...messages],
}))
})
this.#eventEmitter.on("status", ({ status }) => {
this.updateZustandState((state) => ({
...state,
status,
isStreaming: status === "streaming",
}))
})
this.#eventEmitter.on("error", ({ error }) => {
this.updateZustandState((state) => ({
...state,
error,
}))
})
}
get status(): ChatStatus {
return this.#status
}
set status(newStatus: ChatStatus) {
if (this.#status === newStatus) return
this.#status = newStatus
this.#eventEmitter.emit("status", { status: newStatus })
}
get error(): Error | undefined {
return this.#error
}
set error(newError: Error | undefined) {
if (this.#error === newError) return
this.#error = newError
this.#eventEmitter.emit("error", { error: newError })
}
get messages(): UI_MESSAGE[] {
return this.#messages
}
set messages(newMessages: UI_MESSAGE[]) {
this.#messages = [...newMessages]
this.#eventEmitter.emit("messages", { messages: this.#messages })
// Auto-persist messages when they change
this.#persistMessages()
}
pushMessage = (message: UI_MESSAGE) => {
this.messages = this.#messages.concat(message)
}
popMessage = () => {
if (this.#messages.length === 0) return
this.messages = this.#messages.slice(0, -1)
}
replaceMessage = (index: number, message: UI_MESSAGE) => {
if (index < 0 || index >= this.#messages.length) return
this.messages = [
...this.#messages.slice(0, index),
// Deep clone the message to ensure React detects changes
this.snapshot(message),
...this.#messages.slice(index + 1),
]
}
snapshot = <T>(value: T): T => structuredClone(value)
// Callback registration methods with proper AI SDK compatibility
registerMessagesCallback = (onChange: () => void, throttleWaitMs?: number): (() => void) => {
const callback = throttleWaitMs ? throttle(onChange, throttleWaitMs) : onChange
// Convert payload-based event to AI SDK expected callback format
return this.#eventEmitter.on("messages", () => callback())
}
registerStatusCallback = (onChange: () => void): (() => void) => {
// Convert payload-based event to AI SDK expected callback format
return this.#eventEmitter.on("status", () => onChange())
}
registerErrorCallback = (onChange: () => void): (() => void) => {
// Convert payload-based event to AI SDK expected callback format
return this.#eventEmitter.on("error", () => onChange())
}
// Internal event subscription with payload access
onMessagesChange = (listener: (messages: UI_MESSAGE[]) => void): (() => void) => {
return this.#eventEmitter.on("messages", ({ messages }) => listener(messages))
}
onStatusChange = (listener: (status: ChatStatus) => void): (() => void) => {
return this.#eventEmitter.on("status", ({ status }) => listener(status))
}
onErrorChange = (listener: (error: Error | undefined) => void): (() => void) => {
return this.#eventEmitter.on("error", ({ error }) => listener(error))
}
// Persistence methods
#persistMessages = throttle(
async () => {
// Skip if no messages
if (this.#messages.length === 0) return
try {
await AIPersistService.ensureSession(this.chatId, "New Chat")
// Save messages using incremental updates
await AIPersistService.upsertMessages(this.chatId, this.#messages)
} catch (error) {
console.error("Failed to persist messages:", error)
}
},
100,
{ leading: true, trailing: true },
)
// Cleanup method
destroy(): void {
this.#eventEmitter.clear()
}
}
// Custom Chat class that uses Zustand-integrated state
export class ZustandChat<UI_MESSAGE extends BizUIMessage> extends AbstractChat<UI_MESSAGE> {
override state: ZustandChatState<UI_MESSAGE>
#unsubscribeFns: (() => void)[] = []
constructor(
{ messages, ...init }: ChatInit<UI_MESSAGE>,
updateZustandState: (updater: (state: ChatSlice) => ChatSlice) => void,
) {
const state = new ZustandChatState(messages, updateZustandState, init.id || "")
super({ ...init, state })
this.state = state
}
// Public getter for state access
get chatState() {
return this.state
}
// Cleanup method
destroy(): void {
// Unsubscribe from AI SDK callbacks
this.#unsubscribeFns.forEach((unsubscribe) => unsubscribe())
this.#unsubscribeFns = []
this.state.destroy()
}
protected override setStatus({ status, error }: { status: ChatStatus; error?: Error }): void {
super.setStatus({ status, error })
this.state.status = status
this.state.error = error
}
}
// Zustand slice interface
export interface ChatSlice {
// Chat state (mirrored from ChatState)
chatId: string
messages: BizUIMessage[]
status: ChatStatus
error: Error | undefined
isStreaming: boolean
// UI state
currentTitle: string | undefined
// AI SDK Chat instance
chatInstance: ZustandChat<BizUIMessage>
// Actions
chatActions: ChatSliceActions
}
export const createChatSlice: StateCreator<ChatSlice, [], [], ChatSlice> = (...params) => {
const [set, get] = params
const chatId = nanoid()
// Create chat instance with Zustand integration
const chatInstance = new ZustandChat<BizUIMessage>(
{
id: chatId,
messages: [],
transport: createChatTransport(),
onFinish: async (options) => {
const { message } = options
// Only trigger title generation for assistant messages (AI responses)
if (message.role !== "assistant") return
// Get current messages to check if this is the first AI response
const allMessages = chatInstance.chatState.messages
// Check if we have exactly 2 messages (1 user + 1 assistant = first exchange)
// Or if we have 2+ messages and this is the first assistant message
const assistantMessages = allMessages.filter((m) => m.role === "assistant")
const isFirstAIResponse = assistantMessages.length === 1
if (isFirstAIResponse && allMessages.length >= 2) {
try {
// Generate title using the first user message and first AI response
const firstExchange = allMessages.slice(0, 2)
const title = await generateChatTitle(firstExchange)
if (title && chatId) {
try {
await AIPersistService.updateSessionTitle(chatId, title)
if (get().chatId === chatId) {
chatActions.setCurrentTitle(title)
}
} catch (error) {
console.error("Failed to update session title:", error)
}
}
} catch (error) {
console.error("Failed to generate chat title:", error)
}
}
},
},
set, // Pass set function for state updates
)
// Create actions
const chatActions = new ChatSliceActions(params, chatInstance)
return {
// Chat state
chatId,
messages: [],
status: "ready",
error: undefined,
isStreaming: false,
currentTitle: undefined,
chatInstance,
chatActions,
}
}
class ChatSliceActions {
constructor(
private params: Parameters<StateCreator<ChatSlice, [], [], ChatSlice>>,
private chatInstance: ZustandChat<BizUIMessage>,
) {}
get set() {
return this.params[0]
}
get get() {
return this.params[1]
}
// Direct message management methods (delegating to chat instance state)
setMessages = (
messagesParam: BizUIMessage[] | ((messages: BizUIMessage[]) => BizUIMessage[]),
) => {
if (typeof messagesParam === "function") {
this.chatInstance.chatState.messages = messagesParam(this.chatInstance.chatState.messages)
} else {
this.chatInstance.chatState.messages = messagesParam
}
}
pushMessage = (message: BizUIMessage) => {
this.chatInstance.chatState.pushMessage(message)
}
popMessage = () => {
this.chatInstance.chatState.popMessage()
}
replaceMessage = (index: number, message: BizUIMessage) => {
this.chatInstance.chatState.replaceMessage(index, message)
}
updateMessage = (id: string, updates: Partial<BizUIMessage>) => {
const messageIndex = this.chatInstance.chatState.messages.findIndex(
(msg: BizUIMessage) => msg.id === id,
)
if (messageIndex !== -1) {
const message = this.chatInstance.chatState.messages[messageIndex]
if (message) {
const updatedMessage = { ...message, ...updates }
this.replaceMessage(messageIndex, updatedMessage)
}
}
}
// Getter
getMessages = (): BizUIMessage[] => {
return this.chatInstance.chatState.messages
}
// Status management (delegating to chat instance state)
setStatus = (status: ChatStatus) => {
this.chatInstance.chatState.status = status
}
setError = (error: Error | undefined) => {
this.chatInstance.chatState.error = error
}
setStreaming = (streaming: boolean) => {
this.chatInstance.chatState.status = streaming ? "streaming" : "ready"
}
// Title management
setCurrentTitle = (title: string | undefined) => {
this.set((state) => ({ ...state, currentTitle: title }))
}
getCurrentTitle = (): string | undefined => {
return this.get().currentTitle
}
getCurrentChatId = (): string | null => {
return this.get().chatId
}
// Core chat actions using AI SDK AbstractChat methods
sendMessage = async (message: string | BizUIMessage) => {
try {
// Convert string to message object if needed
const messageObj =
typeof message === "string"
? ({ parts: [{ type: "text", text: message }] } as Parameters<
typeof this.chatInstance.sendMessage
>[0])
: (message as Parameters<typeof this.chatInstance.sendMessage>[0])
// Use the AI SDK's sendMessage method
const response = await this.chatInstance.sendMessage(messageObj)
return response
} catch (error) {
this.setError(error as Error)
throw error
}
}
regenerate = async ({ messageId }: { messageId: string }) => {
try {
// Use the AI SDK's regenerate method
const response = await this.chatInstance.regenerate({ messageId })
return response
} catch (error) {
this.setError(error as Error)
throw error
}
}
stop = () => {
// Use AI SDK's stop method
this.chatInstance.stop()
}
resumeStream = async () => {
try {
// Use AI SDK's resumeStream method
await this.chatInstance.resumeStream()
} catch (error) {
this.setError(error as Error)
throw error
}
}
resetChat = () => {
// Reset through the chat instance state
this.chatInstance.chatState.messages = []
this.chatInstance.chatState.error = undefined
this.chatInstance.chatState.status = "ready"
// Reset title
this.setCurrentTitle(undefined)
}
newChat = () => {
const newChatId = nanoid()
// Cleanup old chat instance
this.chatInstance.destroy()
// Create new chat instance
const newChatInstance = new ZustandChat<BizUIMessage>(
{
id: newChatId,
messages: [],
transport: createChatTransport(),
},
this.set,
)
// Update store state
this.set((state) => ({
...state,
chatId: newChatId,
messages: [],
status: "ready" as ChatStatus,
error: undefined,
isStreaming: false,
currentTitle: undefined,
chatInstance: newChatInstance,
}))
// Update the reference
this.chatInstance = newChatInstance
}
switchToChat = async (chatId: string) => {
try {
// Set loading state (using ready as there's no loading status in ChatStatus)
this.setStatus("ready")
this.setError(undefined)
// Load messages from persistence service
const messages = await AIPersistService.loadUIMessages(chatId)
// Load chat session details to get title (direct SQL query)
const chatSession = await AIPersistService.getChatSession(chatId)
// Cleanup old chat instance
this.chatInstance.destroy()
// Create new chat instance with loaded messages
const newChatInstance = new ZustandChat<BizUIMessage>(
{
id: chatId,
messages,
transport: createChatTransport(),
},
this.set,
)
// Update store state
this.set((state) => ({
...state,
chatId,
messages: [...messages],
status: "ready" as ChatStatus,
error: undefined,
isStreaming: false,
currentTitle: chatSession?.title || undefined,
chatInstance: newChatInstance,
}))
// Update the reference
this.chatInstance = newChatInstance
} catch (error) {
console.error("Failed to switch to chat:", error)
this.setError(error as Error)
this.setStatus("ready")
throw error
}
}
}
export { type ChatStatus } from "ai"

View File

@ -0,0 +1,5 @@
export {
type BlockSlice as ContextSlice,
createBlockSlice as createContextSlice,
} from "./block.slice"
export { type ChatSlice, type ChatStatus, createChatSlice } from "./chat.slice"

View File

@ -1,203 +1,29 @@
import { produce } from "immer"
import { nanoid } from "nanoid"
import { createWithEqualityFn } from "zustand/traditional"
import type { AIChatContextBlock, AIChatContextInfo } from "./types"
import { blocksToContextInfo, contextInfoToBlocks } from "./utils"
import type { BlockSlice } from "./slices/block.slice"
import { createBlockSlice } from "./slices/block.slice"
import type { ChatSlice } from "./slices/chat.slice"
import { createChatSlice } from "./slices/chat.slice"
import type { AIChatStoreInitial } from "./types"
export interface AiChatContextStore {
state: AIChatContextInfo
blocks: AIChatContextBlock[]
// Block management methods
addBlock: (block: Omit<AIChatContextBlock, "id">) => void
removeBlock: (id: string) => void
updateBlock: (id: string, updates: Partial<AIChatContextBlock>) => void
clearBlocks: () => void
// Context info management methods
setMainEntryId: (entryId?: string) => void
addReferEntryId: (entryId: string) => void
removeReferEntryId: (entryId: string) => void
addReferFeedId: (feedId: string) => void
removeReferFeedId: (feedId: string) => void
setSelectedText: (selectedText?: string) => void
// Legacy compatibility
setEntryId: (entryId?: string) => void
setFeedId: (feedId?: string) => void
reset: () => void
// Sync methods
syncBlocksToContext: () => void
syncContextToBlocks: () => void
}
export const createAIChatContextStore = (initialState?: Partial<AIChatContextInfo>) => {
const defaultState: AIChatContextInfo = {
mainEntryId: undefined,
referEntryIds: [],
referFeedIds: [],
selectedText: undefined,
export type AiChatStore = BlockSlice &
ChatSlice & {
reset: () => void
}
const defaultBlocks: AIChatContextBlock[] = []
export const createAIChatStore = (initialState?: Partial<AIChatStoreInitial>) => {
return createWithEqualityFn<AiChatStore>((...a) => {
const blockSlice = createBlockSlice(initialState?.blocks)(...a)
const chatSlice = createChatSlice(...a)
const state: AIChatContextInfo = {
mainEntryId: initialState?.mainEntryId,
referEntryIds: initialState?.referEntryIds || [],
referFeedIds: initialState?.referFeedIds || [],
selectedText: initialState?.selectedText,
}
return {
...blockSlice,
...chatSlice,
return createWithEqualityFn<AiChatContextStore>((set, get) => ({
state,
blocks: defaultBlocks,
// Block management methods
addBlock: (block: Omit<AIChatContextBlock, "id">) => {
// Check for uniqueness constraints
const currentBlocks = get().blocks
// Only allow one mainEntry
if (block.type === "mainEntry" && currentBlocks.some((b) => b.type === "mainEntry")) {
return
}
// Only allow one selectedText
if (block.type === "selectedText" && currentBlocks.some((b) => b.type === "selectedText")) {
return
}
// Prevent duplicate referEntry or referFeed
if (
block.type === "referEntry" &&
block.value &&
currentBlocks.some((b) => b.type === "referEntry" && b.value === block.value)
) {
return
}
if (
block.type === "referFeed" &&
block.value &&
currentBlocks.some((b) => b.type === "referFeed" && b.value === block.value)
) {
return
}
set(
produce((s) => {
s.blocks.push({ ...block, id: nanoid(8) })
}),
)
// Sync to context info
get().syncBlocksToContext()
},
removeBlock: (id: string) => {
set(
produce((s: AiChatContextStore) => {
s.blocks = s.blocks.filter((block) => block.id !== id)
}),
)
// Sync to context info
get().syncBlocksToContext()
},
updateBlock: (id: string, updates: Partial<AIChatContextBlock>) => {
set(
produce((s: AiChatContextStore) => {
s.blocks = s.blocks.map((block) => (block.id === id ? { ...block, ...updates } : block))
}),
)
// Sync to context info
get().syncBlocksToContext()
},
clearBlocks: () => {
set(
produce((s: AiChatContextStore) => {
s.blocks = defaultBlocks
}),
)
get().syncBlocksToContext()
},
// Context info management methods
setMainEntryId: (entryId?: string) => {
set((s) => ({ state: { ...s.state, mainEntryId: entryId } }))
get().syncContextToBlocks()
},
addReferEntryId: (entryId: string) => {
set((s) => ({
state: {
...s.state,
referEntryIds: [...(s.state.referEntryIds || []), entryId],
},
}))
get().syncContextToBlocks()
},
removeReferEntryId: (entryId: string) => {
set((s) => ({
state: {
...s.state,
referEntryIds: (s.state.referEntryIds || []).filter((id) => id !== entryId),
},
}))
get().syncContextToBlocks()
},
addReferFeedId: (feedId: string) => {
set((s) => ({
state: {
...s.state,
referFeedIds: [...(s.state.referFeedIds || []), feedId],
},
}))
get().syncContextToBlocks()
},
removeReferFeedId: (feedId: string) => {
set((s) => ({
state: {
...s.state,
referFeedIds: (s.state.referFeedIds || []).filter((id) => id !== feedId),
},
}))
get().syncContextToBlocks()
},
setSelectedText: (selectedText?: string) => {
set((s) => ({ state: { ...s.state, selectedText } }))
get().syncContextToBlocks()
},
// Legacy compatibility
setEntryId: (entryId?: string) => {
get().setMainEntryId(entryId)
},
setFeedId: (feedId?: string) => {
if (feedId) {
get().addReferFeedId(feedId)
}
},
reset: () => {
set(() => ({ state: defaultState, blocks: defaultBlocks }))
},
// Sync methods
syncBlocksToContext: () => {
const contextInfo = blocksToContextInfo(get().blocks)
set((s) => ({ state: { ...s.state, ...contextInfo } }))
},
syncContextToBlocks: () => {
const blocks = contextInfoToBlocks(get().state)
set(() => ({ blocks }))
},
}))
reset: () => {
blockSlice.blockActions.resetContext()
chatSlice.chatActions.resetChat()
},
}
})
}

View File

@ -0,0 +1,14 @@
import { env } from "@follow/shared/env.desktop"
import { DefaultChatTransport } from "ai"
/**
* Create a chat transport for AI SDK
* This is used by the AbstractChat instance to communicate with AI providers
*/
export function createChatTransport() {
return new DefaultChatTransport({
// Custom fetch configuration
api: `${env.VITE_API_URL}/ai/chat`,
credentials: "include",
})
}

View File

@ -6,11 +6,8 @@ export interface AIChatContextBlock {
value: string
}
export interface AIChatContextInfo {
mainEntryId?: string
referEntryIds?: string[]
referFeedIds?: string[]
selectedText?: string
export interface AIChatStoreInitial {
blocks: AIChatContextBlock[]
}
export interface AIChatContextBlocks {

View File

@ -1,72 +0,0 @@
import type { AIChatContextBlock, AIChatContextInfo } from "./types"
export const contextInfoToBlocks = (contextInfo: AIChatContextInfo): AIChatContextBlock[] => {
const blocks: AIChatContextBlock[] = []
// Main entry (single)
if (contextInfo.mainEntryId) {
blocks.push({
id: `main-entry-${contextInfo.mainEntryId}`,
type: "mainEntry",
value: contextInfo.mainEntryId,
})
}
// Reference entries (multiple)
contextInfo.referEntryIds?.forEach((entryId) => {
blocks.push({
id: `refer-entry-${entryId}`,
type: "referEntry",
value: entryId,
})
})
// Reference feeds (multiple)
contextInfo.referFeedIds?.forEach((feedId) => {
blocks.push({
id: `refer-feed-${feedId}`,
type: "referFeed",
value: feedId,
})
})
// Selected text (single, auto-detected)
if (contextInfo.selectedText) {
blocks.push({
id: "selected-text",
type: "selectedText",
value: contextInfo.selectedText,
})
}
return blocks
}
export const blocksToContextInfo = (blocks: AIChatContextBlock[]): AIChatContextInfo => {
const contextInfo: AIChatContextInfo = {}
blocks.forEach((block) => {
switch (block.type) {
case "mainEntry": {
contextInfo.mainEntryId = block.value
break
}
case "referEntry": {
if (!contextInfo.referEntryIds) contextInfo.referEntryIds = []
contextInfo.referEntryIds.push(block.value)
break
}
case "referFeed": {
if (!contextInfo.referFeedIds) contextInfo.referFeedIds = []
contextInfo.referFeedIds.push(block.value)
break
}
case "selectedText": {
contextInfo.selectedText = block.value
break
}
}
})
return contextInfo
}

View File

@ -1,49 +1,7 @@
import { atom } from "jotai"
import { useCallback } from "react"
import { createAtomHooks } from "~/lib/jotai"
// Session state atoms with hooks
export const [, , useCurrentRoomId, useSetCurrentRoomId, , setCurrentRoomId] = createAtomHooks(
atom<string | null>(null),
)
export const [, , useCurrentTitle, useSetCurrentTitle, , setCurrentTitle] =
createAtomHooks(atom<string | undefined>())
export const [, , useSessionPersisted, useSetSessionPersisted, , setSessionPersisted] =
createAtomHooks(atom<boolean>(false))
// Edit state management for messages
export const [, , useEditingMessageId, useSetEditingMessageId, , setEditingMessageId] =
createAtomHooks(atom<string | null>(null))
// Combined hook for all session state
export const useSessionState = () => {
return {
currentRoomId: useCurrentRoomId(),
currentTitle: useCurrentTitle(),
sessionPersisted: useSessionPersisted(),
editingMessageId: useEditingMessageId(),
}
}
// Hook for session state setters
export const useSessionSetters = () => {
const setCurrentRoomIdAtom = useSetCurrentRoomId()
const setCurrentTitleAtom = useSetCurrentTitle()
const setSessionPersistedAtom = useSetSessionPersisted()
return useCallback(
(updates: {
currentRoomId?: string | null
currentTitle?: string | undefined
sessionPersisted?: boolean
}) => {
if (updates.currentRoomId !== undefined) setCurrentRoomIdAtom(updates.currentRoomId)
if (updates.currentTitle !== undefined) setCurrentTitleAtom(updates.currentTitle)
if (updates.sessionPersisted !== undefined) setSessionPersistedAtom(updates.sessionPersisted)
},
[setCurrentRoomIdAtom, setCurrentTitleAtom, setSessionPersistedAtom],
)
}

View File

@ -25,10 +25,12 @@ import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
import { useAIChatStore } from "~/modules/ai/chat/__internal__/AIChatContext"
import type { AIChatContextBlock } from "~/modules/ai/chat/__internal__/types"
import { useChatBlockActions } from "../__internal__/hooks"
export const AIChatContextBar: Component<{ onSendShortcut?: (prompt: string) => void }> = memo(
({ className, onSendShortcut }) => {
const blocks = useAIChatStore()((s) => s.blocks)
const { addBlock } = useAIChatStore()()
const blockActions = useChatBlockActions()
const { shortcuts } = useAISettingValue()
// Filter enabled shortcuts
@ -47,7 +49,7 @@ export const AIChatContextBar: Component<{ onSendShortcut?: (prompt: string) =>
<DropdownMenuSubContent>
<CurrentFeedEntriesPickerList
onSelect={(entryId) =>
addBlock({
blockActions.addBlock({
type: "referEntry",
value: entryId,
})
@ -65,7 +67,7 @@ export const AIChatContextBar: Component<{ onSendShortcut?: (prompt: string) =>
<DropdownMenuSubContent>
<RecentEntriesPickerList
onSelect={(entryId) =>
addBlock({
blockActions.addBlock({
type: "referEntry",
value: entryId,
})
@ -81,7 +83,7 @@ export const AIChatContextBar: Component<{ onSendShortcut?: (prompt: string) =>
<DropdownMenuSubContent>
<FeedPickerList
onSelect={(feedId) =>
addBlock({
blockActions.addBlock({
type: "referFeed",
value: feedId,
})
@ -227,7 +229,7 @@ const PickerList = <T extends PickerItem>({
const CurrentFeedEntriesPickerList: FC<{ onSelect: (entryId: string) => void }> = ({
onSelect,
}) => {
const mainEntryId = useAIChatStore()((s) => s.state.mainEntryId)
const mainEntryId = useAIChatStore()((s) => s.blocks.find((b) => b.type === "mainEntry")?.value)
const feedId = useEntry(mainEntryId, (e) => e?.feedId)
const entryIds = useEntryIdsByFeedId(feedId!)
@ -329,7 +331,7 @@ const FeedPickerItem: FC<{
}
const ContextBlock: FC<{ block: AIChatContextBlock }> = ({ block }) => {
const { removeBlock } = useAIChatStore()()
const blockActions = useChatBlockActions()
const getBlockIcon = () => {
switch (block.type) {
@ -409,7 +411,7 @@ const ContextBlock: FC<{ block: AIChatContextBlock }> = ({ block }) => {
{canRemove && (
<button
type="button"
onClick={() => removeBlock(block.id)}
onClick={() => blockActions.removeBlock(block.id)}
className="text-text-tertiary hover:text-text-secondary flex-shrink-0 opacity-0 transition-all group-hover:opacity-100"
>
<i className="i-mgc-close-cute-re size-3" />

View File

@ -1,202 +1,56 @@
import { Chat, useChat } from "@ai-sdk/react"
import { env } from "@follow/shared/env.desktop"
import type { UIDataTypes, UIMessage } from "ai"
import { DefaultChatTransport } from "ai"
import type { FC, PropsWithChildren } from "react"
import { useCallback, useMemo, useRef } from "react"
import { toast } from "sonner"
import { useEventCallback } from "usehooks-ts"
import { Focusable } from "~/components/common/Focusable"
import { HotkeyScope } from "~/constants"
import type { AIChatSessionMethods, AIPanelRefs } from "../__internal__/AIChatContext"
import {
AIChatContext,
AIChatContextStoreContext,
AIChatSessionMethodsContext,
AIChatStoreContext,
AIPanelRefsContext,
} from "../__internal__/AIChatContext"
import { createAIChatContextStore } from "../__internal__/store"
import type { BizUIMetadata, BizUITools } from "../__internal__/types"
import {
useCurrentRoomId,
useSessionPersisted,
useSetCurrentRoomId,
useSetCurrentTitle,
useSetSessionPersisted,
} from "../atoms/session"
import { useChatHistory } from "../hooks/useChatHistory"
import { useChatActions, useCurrentChatId } from "../__internal__/hooks"
import { createAIChatStore } from "../__internal__/store"
import { AIPersistService } from "../services"
import { generateChatTitle } from "../utils/titleGeneration"
interface AIChatRootProps extends PropsWithChildren {
wrapFocusable?: boolean
roomId?: string
chatId?: string
}
export const AIChatRoot: FC<AIChatRootProps> = ({
children,
wrapFocusable = true,
roomId: externalRoomId,
}) => {
const currentRoomId = useCurrentRoomId()
const sessionPersisted = useSessionPersisted()
const setCurrentRoomId = useSetCurrentRoomId()
const setCurrentTitle = useSetCurrentTitle()
const setSessionPersisted = useSetSessionPersisted()
// Inner component that has access to the AI chat store context
const AIChatRootInner: FC<AIChatRootProps> = ({ children, chatId: externalChatId }) => {
// Use the new internal hooks
const currentChatId = useCurrentChatId()
const { createNewSession } = useChatHistory()
const useAiContextStore = useMemo(createAIChatContextStore, [])
const chatActions = useChatActions()
// Initialize room ID on mount
useMemo(() => {
if (!currentRoomId && !externalRoomId) {
const newRoomId = createNewSession(false)
setCurrentRoomId(newRoomId)
} else if (externalRoomId && externalRoomId !== currentRoomId) {
setCurrentRoomId(externalRoomId)
if (!currentChatId && !externalChatId) {
chatActions.newChat()
}
}, [currentRoomId, externalRoomId, createNewSession, setCurrentRoomId])
}, [currentChatId, externalChatId, chatActions])
const handleTitleGenerated = useCallback(
async (title: string) => {
if (currentRoomId) {
if (currentChatId) {
try {
await AIPersistService.updateSessionTitle(currentRoomId, title)
setCurrentTitle(title)
await AIPersistService.updateSessionTitle(currentChatId, title)
chatActions.setCurrentTitle(title)
} catch (error) {
console.error("Failed to update session title:", error)
}
}
},
[currentRoomId, setCurrentTitle],
[currentChatId, chatActions],
)
const handleFirstMessage = useCallback(async () => {
if (!sessionPersisted && currentRoomId) {
try {
await AIPersistService.createSession(currentRoomId, "New Chat")
setSessionPersisted(true)
} catch (error) {
console.error("Failed to persist session:", error)
}
}
}, [sessionPersisted, currentRoomId, setSessionPersisted])
// Handle AI response completion - this is where we generate title
const handleChatFinish = useEventCallback(
async (options: { message: UIMessage<BizUIMetadata, UIDataTypes, BizUITools> }) => {
const { message } = options
// Only trigger title generation for assistant messages (AI responses)
if (message.role !== "assistant") return
// Get current messages to check if this is the first AI response
const allMessages = chatInstance.messages
// Check if we have exactly 2 messages (1 user + 1 assistant = first exchange)
// Or if we have 2+ messages and this is the first assistant message
const assistantMessages = allMessages.filter((m) => m.role === "assistant")
const isFirstAIResponse = assistantMessages.length === 1
if (isFirstAIResponse && allMessages.length >= 2) {
try {
// Generate title using the first user message and first AI response
const firstExchange = allMessages.slice(0, 2)
const title = await generateChatTitle(firstExchange)
if (title) {
await handleTitleGenerated(title)
}
} catch (error) {
console.error("Failed to generate chat title:", error)
}
}
},
)
const chatInstance = useMemo(() => {
return new Chat<UIMessage<BizUIMetadata, UIDataTypes, BizUITools>>({
// FIXME: this id can't modify after init, so used a fixed id
id: "ai-room",
transport: new DefaultChatTransport({
api: `${env.VITE_API_URL}/ai/chat`,
credentials: "include",
fetch: (url: string | Request | URL, options?: RequestInit) => {
if (!options?.body) return fetch(url, options)
try {
const state = useAiContextStore.getState()
state.syncBlocksToContext()
options.body = JSON.stringify({
...JSON.parse(options.body as string),
context: state.state,
blocks: state.blocks,
})
} catch (error) {
console.error(error)
}
return fetch(url, options)
},
}),
onError: (error) => {
console.error(error)
},
onFinish: handleChatFinish,
})
}, [useAiContextStore, handleChatFinish])
const ctx = useChat<UIMessage<BizUIMetadata, UIDataTypes, BizUITools>>({
chat: chatInstance,
})
const handleNewChat = useCallback(() => {
// Create a new session without persistence initially
const newRoomId = createNewSession(false)
setCurrentRoomId(newRoomId)
setSessionPersisted(false)
setCurrentTitle(undefined)
// Clear messages
ctx.setMessages([])
}, [createNewSession, ctx, setCurrentRoomId, setSessionPersisted, setCurrentTitle])
const handleSwitchRoom = useCallback(
async (roomId: string) => {
try {
// First check if we need to save current messages
if (sessionPersisted && currentRoomId && ctx.messages.length > 0) {
// Messages are automatically saved by useSaveMessages hook
}
// Clear current messages before switching
ctx.setMessages([])
// Switch to new room
setCurrentRoomId(roomId)
// Load session info
const sessionData = await AIPersistService.getChatSessions()
const session = sessionData.find((s) => s.roomId === roomId)
if (session) {
setCurrentTitle(session.title || "New Chat")
setSessionPersisted(true)
} else {
setCurrentTitle(undefined)
setSessionPersisted(false)
}
// Messages will be loaded automatically by useLoadMessages in ChatInterface
} catch (error) {
console.error("Failed to switch room:", error)
toast.error("Failed to switch chat session")
}
},
[sessionPersisted, currentRoomId, ctx, setCurrentRoomId, setCurrentTitle, setSessionPersisted],
)
chatActions.newChat()
chatActions.setCurrentTitle(undefined)
}, [chatActions])
const panelRef = useRef<HTMLDivElement>(null!)
const inputRef = useRef<HTMLTextAreaElement>(null!)
@ -206,14 +60,12 @@ export const AIChatRoot: FC<AIChatRootProps> = ({
const sessionMethods = useMemo<AIChatSessionMethods>(
() => ({
handleTitleGenerated,
handleFirstMessage,
handleNewChat,
handleSwitchRoom,
}),
[handleTitleGenerated, handleFirstMessage, handleNewChat, handleSwitchRoom],
[handleTitleGenerated, handleNewChat],
)
if (!currentRoomId || !ctx) {
if (!currentChatId) {
return (
<div className="bg-background flex size-full items-center justify-center">
<div className="flex items-center gap-2">
@ -224,16 +76,24 @@ export const AIChatRoot: FC<AIChatRootProps> = ({
)
}
return (
<AIPanelRefsContext value={refsContext}>
<AIChatSessionMethodsContext value={sessionMethods}>{children}</AIChatSessionMethodsContext>
</AIPanelRefsContext>
)
}
export const AIChatRoot: FC<AIChatRootProps> = ({
children,
wrapFocusable = true,
chatId: externalChatId,
}) => {
const useAiContextStore = useMemo(createAIChatStore, [])
const Element = (
<AIChatContext value={ctx}>
<AIPanelRefsContext value={refsContext}>
<AIChatContextStoreContext value={useAiContextStore}>
<AIChatSessionMethodsContext value={sessionMethods}>
{children}
</AIChatSessionMethodsContext>
</AIChatContextStoreContext>
</AIPanelRefsContext>
</AIChatContext>
<AIChatStoreContext value={useAiContextStore}>
<AIChatRootInner chatId={externalChatId}>{children}</AIChatRootInner>
</AIChatStoreContext>
)
if (wrapFocusable) {

View File

@ -1,100 +0,0 @@
import { Spring } from "@follow/components/constants/spring.js"
import { cn } from "@follow/utils"
import { m } from "motion/react"
import * as React from "react"
interface CollapsibleErrorProps {
error: Error | string
title?: string
className?: string
collapsedHeight?: string
icon?: string
}
export const CollapsibleError: React.FC<CollapsibleErrorProps> = ({
error,
title = "Error occurred",
className,
collapsedHeight = "48px",
icon = "i-mgc-alert-cute-fi",
}) => {
const [isExpanded, setIsExpanded] = React.useState(false)
const errorMessage = typeof error === "string" ? error : error.message
return (
<div
className={cn(
"animate-in slide-in-from-bottom-2 fade-in-0 group mb-3 duration-300",
className,
)}
onMouseEnter={() => setIsExpanded(true)}
onMouseLeave={() => setIsExpanded(false)}
>
<m.div
initial={false}
animate={{
height: isExpanded ? "auto" : collapsedHeight,
}}
transition={Spring.presets.snappy}
className="overflow-hidden"
>
<div
className={cn(
"bg-red/5 border-red/20 shadow-red/5 dark:shadow-red/10 relative overflow-hidden rounded-xl backdrop-blur-2xl transition-all duration-200",
"group-hover:bg-red/10 group-hover:border-red/30",
)}
>
{/* Glass effect overlay */}
<div className="from-red/5 absolute inset-0 bg-gradient-to-r to-transparent" />
{/* Collapsed Content */}
<div className="relative z-10 flex items-center gap-3 p-3">
<m.div
animate={{
rotate: isExpanded ? 0 : -90,
scale: isExpanded ? 1 : 0.8,
}}
transition={{ duration: 0.2 }}
className="bg-red/20 flex size-6 flex-shrink-0 items-center justify-center rounded-full"
>
<i className={cn(icon, "text-red size-3")} />
</m.div>
<div className="min-w-0 flex-1">
<div className="text-red text-sm font-medium">{title}</div>
</div>
<m.span
animate={{
opacity: isExpanded ? 0 : 1,
scale: isExpanded ? 0.8 : 1,
}}
transition={{ duration: 0.15 }}
className="text-text-tertiary text-xs"
>
hover to expand
</m.span>
</div>
{/* Expanded Content */}
<m.div
animate={{
opacity: isExpanded ? 1 : 0,
height: isExpanded ? "auto" : 0,
}}
transition={{
duration: 0.2,
ease: "easeOut",
}}
className="overflow-hidden"
>
<div className="border-red/20 bg-red/5 border-t px-3 pb-3">
<div className="text-red/80 bg-red/10 mt-2 rounded-md p-3 text-xs leading-relaxed">
{errorMessage}
</div>
</div>
</m.div>
</div>
</m.div>
</div>
)
}

View File

@ -16,7 +16,7 @@ export const ToolInvocationComponent: React.FC<ToolInvocationComponentProps> = (
const toolName = getToolName(part)
return (
<div className="bg-material-medium border-border size-full min-w-0 max-w-prose rounded-lg border text-left">
<div className="w-[9999px] max-w-prose" />
<div className="w-[9999px] max-w-[calc(var(--ai-chat-layout-width,65ch)_-120px)]" />
<Accordion type="single" collapsible>
<AccordionItem value="tool-invocation">
<AccordionTrigger className="flex w-full cursor-pointer items-center gap-3 py-1 pl-4 pr-2 hover:no-underline">

View File

@ -1,28 +1,25 @@
import { ActionButton } from "@follow/components/ui/button/index.js"
import { use, useCallback } from "react"
import { useCallback } from "react"
import { useTranslation } from "react-i18next"
import { useDialog } from "~/components/ui/modal/stacked/hooks"
import {
AIChatContext,
useAIChatSessionMethods,
} from "~/modules/ai/chat/__internal__/AIChatContext"
import { useSessionState } from "~/modules/ai/chat/atoms/session"
import { useAIChatSessionMethods } from "~/modules/ai/chat/__internal__/AIChatContext"
import { useChatActions, useCurrentTitle } from "~/modules/ai/chat/__internal__/hooks"
import { useSettingModal } from "~/modules/settings/modal/use-setting-modal-hack"
import { ChatMoreDropdown } from "./ChatMoreDropdown"
export const ChatHeader = () => {
const { currentTitle } = useSessionState()
const currentTitle = useCurrentTitle()
const { handleNewChat } = useAIChatSessionMethods()
const settingModalPresent = useSettingModal()
const { messages } = use(AIChatContext)
const chatActions = useChatActions()
const { ask } = useDialog()
const { t } = useTranslation("ai")
const handleNewChatClick = useCallback(() => {
if (messages.length === 0) {
const messages = chatActions.getMessages()
if (messages.length === 0 && !currentTitle) {
return
}
@ -34,11 +31,11 @@ export const ChatHeader = () => {
handleNewChat()
},
})
}, [ask, messages.length, t, handleNewChat])
}, [chatActions, currentTitle, ask, t, handleNewChat])
const maskImage = `linear-gradient(to bottom, black 0%, black 75%, transparent 100%)`
return (
<div className="absolute inset-x-0 top-0 z-20 h-12">
<div className="absolute inset-x-0 top-0 z-[1] h-12">
<div
className="bg-background/70 backdrop-blur-background absolute inset-0"
style={{
@ -47,11 +44,11 @@ export const ChatHeader = () => {
}}
/>
<div className="relative z-10 flex h-full items-center justify-between px-6">
<div className="relative z-10 flex h-full items-center justify-between px-4">
{/* Left side - Title */}
<div className="mr-2 min-w-0 flex-1">
{currentTitle && (
<h1 key={currentTitle} className="text-text truncate font-medium">
<h1 key={currentTitle} className="text-text truncate font-bold">
<span className="animate-mask-left-to-right [--animation-duration:1s]">
{currentTitle}
</span>
@ -62,11 +59,11 @@ export const ChatHeader = () => {
{/* Right side - Actions */}
<div className="flex items-center gap-2">
<ActionButton tooltip="New Chat" onClick={handleNewChatClick}>
<i className="i-mgc-add-cute-re size-5 opacity-80" />
<i className="i-mgc-add-cute-re text-text-secondary size-5" />
</ActionButton>
<ActionButton tooltip="AI Settings" onClick={() => settingModalPresent("ai")}>
<i className="i-mgc-user-setting-cute-re size-5 opacity-80" />
<i className="i-mgc-user-setting-cute-re text-text-secondary size-5" />
</ActionButton>
<ChatMoreDropdown />

View File

@ -1,14 +1,18 @@
import { useInputComposition } from "@follow/hooks"
import { cn, stopPropagation } from "@follow/utils"
import type { VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
import { memo, use, useCallback, useState } from "react"
import type { EditorState, LexicalEditor } from "lexical"
import { $getRoot } from "lexical"
import { memo, useCallback, useRef, useState } from "react"
import { AIChatContext, AIPanelRefsContext } from "~/modules/ai/chat/__internal__/AIChatContext"
import { AIChatContextBar } from "~/modules/ai/chat/components/AIChatContextBar"
import { AIChatSendButton } from "~/modules/ai/chat/components/AIChatSendButton"
import { convertLexicalToMarkdown } from "~/modules/ai/chat/utils/lexical-markdown"
import { useChatActions, useChatError, useChatStatus } from "../../__internal__/hooks"
import { AIChatSendButton } from "./AIChatSendButton"
import { CollapsibleError } from "./CollapsibleError"
import type { LexicalRichEditorRef } from "./LexicalRichEditor"
import { LexicalRichEditor } from "./LexicalRichEditor"
const chatInputVariants = cva(
[
@ -33,39 +37,53 @@ interface ChatInputProps extends VariantProps<typeof chatInputVariants> {
}
export const ChatInput = memo(({ onSend, variant }: ChatInputProps) => {
const { inputRef } = use(AIPanelRefsContext)
const { status, stop, error } = use(AIChatContext)
const status = useChatStatus()
const chatActions = useChatActions()
const error = useChatError()
const stop = useCallback(() => {
chatActions.stop()
}, [chatActions])
const editorRef = useRef<LexicalRichEditorRef>(null)
const [isEmpty, setIsEmpty] = useState(true)
const [currentEditor, setCurrentEditor] = useState<LexicalEditor | null>(null)
const isProcessing = status === "submitted" || status === "streaming"
const handleSend = useCallback(() => {
if (inputRef.current && inputRef.current.value.trim()) {
const message = inputRef.current.value.trim()
onSend(message)
inputRef.current.value = ""
setIsEmpty(true)
if (currentEditor && editorRef.current && !editorRef.current.isEmpty()) {
const markdown = convertLexicalToMarkdown(currentEditor)
if (markdown.trim()) {
onSend(markdown.trim())
editorRef.current.clear()
}
}
}, [onSend, inputRef])
const handleKeyPress = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
}, [currentEditor, onSend])
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault()
if (isProcessing) {
stop?.()
} else {
handleSend()
}
return true
}
return false
},
[handleSend, isProcessing, stop],
)
const inputProps = useInputComposition<HTMLTextAreaElement>({
onKeyDown: handleKeyPress,
})
const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
setIsEmpty(e.target.value.trim() === "")
const handleEditorChange = useCallback((editorState: EditorState, editor: LexicalEditor) => {
setCurrentEditor(editor)
// Update isEmpty state based on editor content
editorState.read(() => {
const root = $getRoot()
const textContent = root.getTextContent().trim()
setIsEmpty(textContent === "")
})
}, [])
return (
@ -76,15 +94,13 @@ export const ChatInput = memo(({ onSend, variant }: ChatInputProps) => {
{/* Integrated Input Container with Context Bar */}
<div className={cn(chatInputVariants({ variant }))}>
{/* Input Area */}
<div className="relative z-10 flex items-end">
<textarea
onContextMenu={stopPropagation}
ref={inputRef}
onChange={handleChange}
{...inputProps}
<div className="relative z-10 flex items-end" onContextMenu={stopPropagation}>
<LexicalRichEditor
ref={editorRef}
placeholder="Message AI assistant..."
className="scrollbar-none text-text placeholder:text-text-secondary max-h-40 min-h-14 w-full resize-none bg-transparent px-5 py-3.5 pr-14 text-sm !outline-none transition-all duration-200"
rows={1}
className="w-full"
onChange={handleEditorChange}
onKeyDown={handleKeyDown}
autoFocus
/>
<div className="absolute right-3 top-1/2 -translate-y-1/2">

View File

@ -1,43 +1,48 @@
import { ScrollArea } from "@follow/components/ui/scroll-area/ScrollArea.js"
import { cn, nextFrame } from "@follow/utils"
import { springScrollTo } from "@follow/utils/scroller"
import { use, useCallback, useEffect, useRef, useState } from "react"
import { nanoid } from "nanoid"
import { useCallback, useEffect, useRef, useState } from "react"
import { useEventCallback } from "usehooks-ts"
import {
AIChatContext,
useAIChatSessionMethods,
} from "~/modules/ai/chat/__internal__/AIChatContext"
import { useCurrentRoomId } from "~/modules/ai/chat/atoms/session"
import { ChatInput } from "~/modules/ai/chat/components/ChatInput"
useBlockActions,
useChatActions,
useChatError,
useChatStatus,
useCurrentChatId,
useHasMessages,
useMessages,
} from "~/modules/ai/chat/__internal__/hooks"
import {
AIChatMessage,
AIChatTypingIndicator,
} from "~/modules/ai/chat/components/message/AIChatMessage"
import { WelcomeScreen } from "~/modules/ai/chat/components/WelcomeScreen"
import { useAutoScroll } from "~/modules/ai/chat/hooks/useAutoScroll"
import { useLoadMessages } from "~/modules/ai/chat/hooks/useLoadMessages"
import { useSaveMessages } from "~/modules/ai/chat/hooks/useSaveMessages"
import { ChatInput } from "./ChatInput"
import { WelcomeScreen } from "./WelcomeScreen"
const SCROLL_BOTTOM_THRESHOLD = 50
export const ChatInterface = () => {
const { messages, status, sendMessage, error } = use(AIChatContext)
const hasMessages = useHasMessages()
const status = useChatStatus()
const chatActions = useChatActions()
const error = useChatError()
const currentRoomId = useCurrentRoomId()
const currentChatId = useCurrentChatId()
const { handleFirstMessage } = useAIChatSessionMethods()
const scrollAreaRef = useRef<HTMLDivElement>(null)
const [hasHandledFirstMessage, setHasHandledFirstMessage] = useState(false)
const [isAtBottom, setIsAtBottom] = useState(true)
// Reset handlers when roomId changes
// Reset handlers when chatId changes
useEffect(() => {
setHasHandledFirstMessage(false)
setIsAtBottom(true)
}, [currentRoomId])
}, [currentChatId])
const { isLoading: isLoadingHistory } = useLoadMessages(currentRoomId || "", {
const { isLoading: isLoadingHistory } = useLoadMessages(currentChatId || "", {
onLoad: () => {
nextFrame(() => {
const $scrollArea = scrollAreaRef.current
@ -52,7 +57,6 @@ export const ChatInterface = () => {
})
},
})
useSaveMessages(currentRoomId || "", { enabled: !isLoadingHistory })
const { resetScrollState } = useAutoScroll(scrollAreaRef.current, status === "streaming")
@ -83,20 +87,23 @@ export const ChatInterface = () => {
springScrollTo(scrollElement.scrollHeight, scrollElement)
}, [])
const blockActions = useBlockActions()
const handleSendMessage = useEventCallback((message: string) => {
resetScrollState()
// Handle first message persistence
if (messages.length === 0 && !hasHandledFirstMessage) {
handleFirstMessage()
setHasHandledFirstMessage(true)
}
sendMessage({
text: message,
metadata: {
finishTime: new Date().toISOString(),
},
chatActions.sendMessage({
parts: [
{
type: "data-block",
data: blockActions.getBlocks().map((b) => ({
type: b.type,
value: b.value,
})),
},
{ type: "text", text: message },
],
role: "user",
id: nanoid(),
})
})
@ -106,8 +113,6 @@ export const ChatInterface = () => {
}
}, [status, resetScrollState])
const hasMessages = messages.length > 0
const shouldShowScrollToBottom = hasMessages && !isAtBottom && !isLoadingHistory
return (
@ -127,9 +132,7 @@ export const ChatInterface = () => {
</div>
) : (
<div className="mx-auto max-w-4xl px-6 py-8">
{messages.map((message) => (
<AIChatMessage key={message.id} message={message} />
))}
<Messages />
{status === "submitted" && <AIChatTypingIndicator />}
</div>
)}
@ -167,3 +170,15 @@ export const ChatInterface = () => {
</div>
)
}
const Messages = () => {
const messages = useMessages()
return (
<>
{messages.map((message) => (
<AIChatMessage key={message.id} message={message} />
))}
</>
)
}

View File

@ -1,5 +1,5 @@
import { ActionButton } from "@follow/components/ui/button/index.js"
import { use, useCallback, useState } from "react"
import { useCallback, useState } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
@ -15,23 +15,25 @@ import {
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu/dropdown-menu"
import { useDialog } from "~/components/ui/modal/stacked/hooks"
import { useAIChatSessionMethods } from "~/modules/ai/chat/__internal__/AIChatContext"
import {
AIChatContext,
useAIChatSessionMethods,
} from "~/modules/ai/chat/__internal__/AIChatContext"
import { useSessionState } from "~/modules/ai/chat/atoms/session"
useChatActions,
useCurrentChatId,
useCurrentTitle,
} from "~/modules/ai/chat/__internal__/hooks"
import { useChatHistory } from "~/modules/ai/chat/hooks/useChatHistory"
import { AIPersistService } from "~/modules/ai/chat/services"
import { downloadMarkdown, exportChatToMarkdown } from "~/modules/ai/chat/utils/export"
export const ChatMoreDropdown = () => {
const { currentTitle, currentRoomId } = useSessionState()
const { handleNewChat, handleSwitchRoom } = useAIChatSessionMethods()
const currentTitle = useCurrentTitle()
const currentChatId = useCurrentChatId()
const { handleNewChat } = useAIChatSessionMethods()
const chatActions = useChatActions()
const { t } = useTranslation("ai")
const { ask } = useDialog()
const [deletingRoomId, setDeletingRoomId] = useState<string | null>(null)
const [deletingChatId, setDeletingChatId] = useState<string | null>(null)
const { sessions, loading, loadHistory } = useChatHistory()
const { messages } = use(AIChatContext)
const handleDropdownOpen = (open: boolean) => {
if (open) {
@ -40,11 +42,11 @@ export const ChatMoreDropdown = () => {
}
const handleDeleteSession = useCallback(
async (roomId: string, e: React.MouseEvent) => {
async (chatId: string, e: React.MouseEvent) => {
e.stopPropagation()
e.preventDefault()
const session = sessions.find((s) => s.roomId === roomId)
const session = sessions.find((s) => s.chatId === chatId)
if (!session) return
const confirm = await ask({
@ -55,12 +57,12 @@ export const ChatMoreDropdown = () => {
if (!confirm) return
setDeletingRoomId(roomId)
setDeletingChatId(chatId)
try {
await AIPersistService.deleteSession(roomId)
await AIPersistService.deleteSession(chatId)
toast.success(t("delete_chat_success"))
if (roomId === currentRoomId) {
if (chatId === currentChatId) {
handleNewChat()
}
@ -69,13 +71,14 @@ export const ChatMoreDropdown = () => {
console.error("Failed to delete session:", error)
toast.error(t("delete_chat_error"))
} finally {
setDeletingRoomId(null)
setDeletingChatId(null)
}
},
[sessions, ask, t, currentRoomId, loadHistory, handleNewChat],
[sessions, ask, t, currentChatId, loadHistory, handleNewChat],
)
const handleExport = useCallback(() => {
const messages = chatActions.getMessages()
if (messages.length === 0) {
toast.error(t("export_empty_chat"))
return
@ -90,7 +93,7 @@ export const ChatMoreDropdown = () => {
toast.error(t("export_error"))
console.error("Export error:", error)
}
}, [messages, currentTitle, t])
}, [chatActions, currentTitle, t])
return (
<DropdownMenu onOpenChange={handleDropdownOpen}>
@ -118,8 +121,8 @@ export const ChatMoreDropdown = () => {
</div>
{sessions.map((session) => (
<DropdownMenuItem
key={session.roomId}
onClick={() => handleSwitchRoom(session.roomId)}
key={session.chatId}
onClick={() => chatActions.switchToChat(session.chatId)}
className="group flex h-12 cursor-pointer items-center justify-between rounded-md px-2 py-3"
>
<div className="min-w-0 flex-1">
@ -135,11 +138,11 @@ export const ChatMoreDropdown = () => {
</span>
<button
type="button"
onClick={(e) => handleDeleteSession(session.roomId, e)}
className="group-data-[highlighted]:text-red bg-accent absolute inset-y-0 right-0 flex items-center px-2 py-1 text-white opacity-0 shadow-lg backdrop-blur-sm group-data-[highlighted]:opacity-100"
disabled={deletingRoomId === session.roomId}
onClick={(e) => handleDeleteSession(session.chatId, e)}
className="bg-accent absolute inset-y-0 right-0 flex items-center px-2 py-1 text-white opacity-0 shadow-lg backdrop-blur-sm group-data-[highlighted]:text-white group-data-[highlighted]:opacity-100"
disabled={deletingChatId === session.chatId}
>
{deletingRoomId === session.roomId ? (
{deletingChatId === session.chatId ? (
<i className="i-mgc-loading-3-cute-re size-4 animate-spin" />
) : (
<i className="i-mgc-delete-2-cute-re size-4" />

View File

@ -0,0 +1,229 @@
import { Spring } from "@follow/components/constants/spring.js"
import { cn } from "@follow/utils"
import { ExceptionCodeMap } from "@follow-app/client-sdk"
import { m } from "motion/react"
import * as React from "react"
import { useI18n } from "~/hooks/common/useI18n"
interface CollapsibleErrorProps {
error: Error | string
title?: string
className?: string
collapsedHeight?: string
icon?: string
}
interface ErrorData {
code?: number
remainedTokens?: number
windowResetTime?: string
[key: string]: any
}
export const CollapsibleError: React.FC<CollapsibleErrorProps> = ({
error,
title,
className,
collapsedHeight = "48px",
icon = "i-mgc-alert-cute-fi",
}) => {
const [isExpanded, setIsExpanded] = React.useState(false)
const t = useI18n()
const { displayMessage, errorCode, errorData, isBusinessError } = React.useMemo(() => {
const rawMessage = typeof error === "string" ? error : error.message
try {
const parsed = JSON.parse(rawMessage)
const errorData: ErrorData = parsed || {}
const { code } = errorData
if (code && ExceptionCodeMap[code]) {
// This is a business exception code
const errorKey = `errors:${code}` as any
const translatedMessage = t(errorKey)
// If translation exists and is different from the key, use it; otherwise fallback to raw message
const userFriendlyMessage = translatedMessage !== errorKey ? translatedMessage : rawMessage
return {
displayMessage: userFriendlyMessage,
errorCode: code,
errorData,
isBusinessError: true,
}
}
// If no code in data, return the original message
return {
displayMessage: rawMessage,
errorCode: null,
errorData: null,
isBusinessError: false,
}
} catch {
// If parsing fails, return the original message
return {
displayMessage: rawMessage,
errorCode: null,
errorData: null,
isBusinessError: false,
}
}
}, [error, t])
const formatResetTime = (windowResetTime: string) => {
const resetDate = new Date(windowResetTime)
// Format date part as YYYY/MM/DD
const dateFormatter = new Intl.DateTimeFormat("en-CA", {
year: "numeric",
month: "2-digit",
day: "2-digit",
})
const datePart = dateFormatter.format(resetDate).replaceAll("-", "/")
// Format time part as HH:mm
const timeFormatter = new Intl.DateTimeFormat("en-GB", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
})
const timePart = timeFormatter.format(resetDate)
// Get timezone offset
const timezoneFormatter = new Intl.DateTimeFormat("en", {
timeZoneName: "short",
})
const timezone =
timezoneFormatter.formatToParts(resetDate).find((part) => part.type === "timeZoneName")
?.value || ""
return `${datePart} ${timePart} (${timezone})`
}
const getContextualInfo = () => {
if (!isBusinessError || !errorData) return null
switch (errorCode) {
case ExceptionCodeMap.AIRateLimitExceeded: {
// AI Rate Limit Exceeded
return (
<div className="space-y-2">
{errorData.remainedTokens !== undefined && (
<div className="flex justify-between text-xs">
<span className="text-text-secondary">Remaining tokens:</span>
<span className="font-medium">{errorData.remainedTokens}</span>
</div>
)}
{errorData.windowResetTime && (
<div className="flex justify-between text-xs">
<span className="text-text-secondary">Rate limit resets at:</span>
<span className="font-medium">{formatResetTime(errorData.windowResetTime)}</span>
</div>
)}
</div>
)
}
default: {
return null
}
}
}
const getErrorTitle = () => {
if (title) return title
if (isBusinessError) {
switch (errorCode) {
case ExceptionCodeMap.AIRateLimitExceeded: {
return "AI Rate Limit Exceeded"
}
default: {
return "Error occurred"
}
}
}
return "Error occurred"
}
const contextualInfo = getContextualInfo()
return (
<div
className={cn(
"animate-in slide-in-from-bottom-2 fade-in-0 group mb-3 duration-300",
className,
)}
onMouseEnter={() => setIsExpanded(true)}
onMouseLeave={() => setIsExpanded(false)}
>
<m.div
initial={false}
animate={{
height: isExpanded ? "auto" : collapsedHeight,
}}
transition={Spring.presets.snappy}
className="overflow-hidden"
>
<div
className={cn(
"bg-red/5 border-red/20 shadow-red/5 dark:shadow-red/10 relative overflow-hidden rounded-xl backdrop-blur-2xl transition-all duration-200",
"group-hover:bg-red/10 group-hover:border-red/30",
)}
>
{/* Glass effect overlay */}
<div className="from-red/5 absolute inset-0 bg-gradient-to-r to-transparent" />
{/* Collapsed Content */}
<div className="relative z-10 flex items-center gap-3 p-3">
<m.div
transition={{ duration: 0.2 }}
className="bg-red/20 flex size-6 flex-shrink-0 items-center justify-center rounded-full"
>
<i className={cn(icon, "text-red size-3")} />
</m.div>
<div className="min-w-0 flex-1">
<div className="text-red text-sm font-medium">{getErrorTitle()}</div>
</div>
<m.span
animate={{
opacity: isExpanded ? 0 : 1,
scale: isExpanded ? 0.8 : 1,
}}
transition={{ duration: 0.15 }}
className="text-text-tertiary text-xs"
>
hover to expand
</m.span>
</div>
{/* Expanded Content */}
<m.div
animate={{
opacity: isExpanded ? 1 : 0,
height: isExpanded ? "auto" : 0,
}}
transition={{
duration: 0.2,
ease: "easeOut",
}}
className="overflow-hidden"
>
<div className="border-red/20 bg-red/5 border-t px-3 pb-3">
<div className="text-red/80 bg-red/10 mt-2 rounded-md p-3 text-xs leading-relaxed">
{displayMessage as string}
</div>
{contextualInfo && (
<div className="bg-red/5 border-red/10 mt-3 rounded-md border p-3">
{contextualInfo}
</div>
)}
</div>
</m.div>
</div>
</m.div>
</div>
)
}

View File

@ -0,0 +1,227 @@
import { cn } from "@follow/utils"
import { CodeHighlightNode, CodeNode } from "@lexical/code"
import { LinkNode } from "@lexical/link"
import { ListItemNode, ListNode } from "@lexical/list"
import { MarkNode } from "@lexical/mark"
import { TRANSFORMERS } from "@lexical/markdown"
import { AutoFocusPlugin } from "@lexical/react/LexicalAutoFocusPlugin"
import { LexicalComposer } from "@lexical/react/LexicalComposer"
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
import { ContentEditable } from "@lexical/react/LexicalContentEditable"
import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"
import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin"
import { LinkPlugin } from "@lexical/react/LexicalLinkPlugin"
import { ListPlugin } from "@lexical/react/LexicalListPlugin"
import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPlugin"
import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin"
import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"
import { HeadingNode, QuoteNode } from "@lexical/rich-text"
import type { EditorState, LexicalEditor } from "lexical"
import { $getRoot, ParagraphNode, TextNode } from "lexical"
import { useEffect, useImperativeHandle, useRef, useState } from "react"
const theme = {
paragraph: "mb-1",
text: {
bold: "font-semibold",
italic: "italic",
strikethrough: "line-through",
underline: "underline",
code: "bg-fill px-1 py-0.5 rounded text-sm font-mono",
},
heading: {
h1: "text-2xl font-bold mb-2",
h2: "text-xl font-bold mb-2",
h3: "text-lg font-bold mb-1",
h4: "text-base font-bold mb-1",
h5: "text-sm font-bold mb-1",
h6: "text-xs font-bold mb-1",
},
list: {
nested: {
listitem: "list-none",
},
ol: "list-decimal list-inside mb-2",
ul: "list-disc list-inside mb-2",
listitem: "mb-1",
},
quote: "border-l-4 border-accent pl-4 italic mb-2",
code: "bg-fill px-3 py-2 rounded font-mono text-sm mb-2 block overflow-x-auto",
codeHighlight: {
atrule: "text-purple-400",
attr: "text-blue-400",
boolean: "text-orange-400",
builtin: "text-purple-400",
cdata: "text-gray-400",
char: "text-green-400",
class: "text-blue-400",
"class-name": "text-blue-400",
comment: "text-gray-400",
constant: "text-orange-400",
deleted: "text-red-400",
doctype: "text-gray-400",
entity: "text-orange-400",
function: "text-yellow-400",
important: "text-red-400",
inserted: "text-green-400",
keyword: "text-purple-400",
namespace: "text-blue-400",
number: "text-orange-400",
operator: "text-pink-400",
prolog: "text-gray-400",
property: "text-blue-400",
punctuation: "text-gray-300",
regex: "text-green-400",
selector: "text-green-400",
string: "text-green-400",
symbol: "text-orange-400",
tag: "text-red-400",
url: "text-blue-400",
variable: "text-orange-400",
},
link: "text-accent underline hover:text-accent/80",
mark: "bg-yellow-200 px-1 py-0.5 rounded",
}
function onError(error: Error) {
console.error("Lexical Editor Error:", error)
}
export interface LexicalRichEditorRef {
getEditor: () => LexicalEditor
focus: () => void
clear: () => void
isEmpty: () => boolean
}
interface LexicalRichEditorProps {
placeholder?: string
className?: string
onChange?: (editorState: EditorState, editor: LexicalEditor) => void
onKeyDown?: (event: KeyboardEvent) => boolean
autoFocus?: boolean
}
function KeyboardPlugin({ onKeyDown }: { onKeyDown?: (event: KeyboardEvent) => boolean }) {
const [editor] = useLexicalComposerContext()
useEffect(() => {
if (!onKeyDown) return
const handleKeyDown = (event: KeyboardEvent) => {
onKeyDown(event)
}
return editor.registerRootListener((rootElement, prevRootElement) => {
if (prevRootElement !== null) {
prevRootElement.removeEventListener("keydown", handleKeyDown)
}
if (rootElement !== null) {
rootElement.addEventListener("keydown", handleKeyDown)
}
})
}, [editor, onKeyDown])
return null
}
export const LexicalRichEditor = ({
ref,
placeholder = "Enter your message...",
className,
onChange,
onKeyDown,
autoFocus = false,
}: LexicalRichEditorProps & { ref?: React.RefObject<LexicalRichEditorRef | null> }) => {
const editorRef = useRef<LexicalEditor | null>(null)
const [isEmpty, setIsEmpty] = useState(true)
const initialConfig = {
namespace: "AIChatRichEditor",
theme,
onError,
nodes: [
// Core nodes
ParagraphNode,
TextNode,
// Rich text nodes
HeadingNode, // For HEADING transformer
QuoteNode, // For QUOTE transformer
// List nodes
ListNode, // For UNORDERED_LIST, ORDERED_LIST transformers
ListItemNode,
// Code nodes
CodeNode, // For CODE transformer (multiline)
CodeHighlightNode, // For code syntax highlighting
// Link nodes
LinkNode, // For LINK transformer
// Text format nodes
MarkNode, // For HIGHLIGHT transformer
],
}
useImperativeHandle(ref, () => ({
getEditor: () => editorRef.current!,
focus: () => {
editorRef.current?.focus()
},
clear: () => {
editorRef.current?.update(() => {
const root = $getRoot()
root.clear()
})
},
isEmpty: () => isEmpty,
}))
const handleChange = (editorState: EditorState, editor: LexicalEditor) => {
editorRef.current = editor
// Check if editor is empty
editorState.read(() => {
const root = $getRoot()
const textContent = root.getTextContent().trim()
setIsEmpty(textContent === "")
})
onChange?.(editorState, editor)
}
return (
<LexicalComposer initialConfig={initialConfig}>
<div className={cn("relative", className)}>
<RichTextPlugin
contentEditable={
<ContentEditable
className={cn(
"scrollbar-none text-text placeholder:text-text-secondary",
"max-h-40 min-h-14 w-full resize-none bg-transparent px-5 py-3.5 pr-14",
"text-sm !outline-none transition-all duration-200 focus:outline-none",
)}
aria-placeholder={placeholder}
placeholder={
<div className="text-text-secondary pointer-events-none absolute left-5 top-3.5 text-sm">
{placeholder}
</div>
}
/>
}
ErrorBoundary={LexicalErrorBoundary}
/>
<OnChangePlugin onChange={handleChange} />
<HistoryPlugin />
<MarkdownShortcutPlugin transformers={TRANSFORMERS} />
<ListPlugin />
<LinkPlugin />
<KeyboardPlugin onKeyDown={onKeyDown} />
{autoFocus && <AutoFocusPlugin />}
</div>
</LexicalComposer>
)
}

View File

@ -2,10 +2,10 @@ import { m } from "motion/react"
import { useTranslation } from "react-i18next"
import { useAISettingValue } from "~/atoms/settings/ai"
import { ChatInput } from "~/modules/ai/chat/components/ChatInput"
import { AISpline } from "~/modules/ai/AISpline"
import { useSettingModal } from "~/modules/settings/modal/use-setting-modal-hack"
import { AISpline } from "../../AISpline"
import { ChatInput } from "./ChatInput"
interface WelcomeScreenProps {
onSend: (message: string) => void
@ -27,14 +27,14 @@ export const WelcomeScreen = ({ onSend }: WelcomeScreenProps) => {
return (
<div className="flex flex-1 flex-col items-center justify-center px-6">
<div className="w-full max-w-2xl space-y-8 text-center">
<div className="space-y-6">
<div className="w-full max-w-2xl space-y-8">
<div className="space-y-6 text-center">
<div className="mx-auto size-16">
<AISpline />
</div>
<div className="flex flex-col gap-2">
<h1 className="text-text text-2xl font-semibold">{APP_NAME} AI</h1>
<p className="text-text-secondary text-sm">{t("welcome_description")}</p>
<p className="text-text-secondary text-balance text-sm">{t("welcome_description")}</p>
{hasCustomPrompt && (
<div className="bg-material-medium border-border mx-auto mt-2 w-full max-w-2xl rounded-lg border p-3 text-left">
<div className="flex items-center justify-between">

View File

@ -5,7 +5,7 @@ import * as React from "react"
import { toast } from "sonner"
import { copyToClipboard } from "~/lib/clipboard"
import { AIChatContext } from "~/modules/ai/chat/__internal__/AIChatContext"
import { useChatActions } from "~/modules/ai/chat/__internal__/hooks"
import type { BizUIMetadata, BizUITools } from "~/modules/ai/chat/__internal__/types"
import { useEditingMessageId, useSetEditingMessageId } from "~/modules/ai/chat/atoms/session"
@ -26,7 +26,8 @@ interface AIChatMessageProps {
}
export const AIChatMessage: React.FC<AIChatMessageProps> = React.memo(({ message }) => {
const { regenerate, sendMessage, setMessages, messages } = React.use(AIChatContext)
const chatActions = useChatActions()
const messageId = message.id
const [isHovered, setIsHovered] = React.useState(false)
const editingMessageId = useEditingMessageId()
@ -53,25 +54,21 @@ export const AIChatMessage: React.FC<AIChatMessageProps> = React.memo(({ message
const handleSaveEdit = React.useCallback(
(newContent: string) => {
const messages = chatActions.getMessages()
if (newContent.trim() !== messageContent.trim()) {
// Find the message index and remove all messages after it (including AI responses)
const messageIndex = messages.findIndex((msg) => msg.id === messageId)
if (messageIndex !== -1) {
const messagesToKeep = messages.slice(0, messageIndex)
setMessages(messagesToKeep)
chatActions.setMessages(messagesToKeep)
// Send the edited message
sendMessage({
text: newContent,
metadata: {
finishTime: new Date().toISOString(),
},
})
chatActions.sendMessage(newContent)
}
}
setEditingMessageId(null)
},
[messageContent, messageId, messages, setMessages, sendMessage, setEditingMessageId],
[messageContent, messageId, chatActions, setEditingMessageId],
)
const handleCancelEdit = React.useCallback(() => {
@ -88,8 +85,8 @@ export const AIChatMessage: React.FC<AIChatMessageProps> = React.memo(({ message
}, [messageContent])
const handleRetry = React.useCallback(() => {
regenerate({ messageId })
}, [regenerate, messageId])
chatActions.regenerate({ messageId })
}, [chatActions, messageId])
return (
<m.div
@ -113,7 +110,7 @@ export const AIChatMessage: React.FC<AIChatMessageProps> = React.memo(({ message
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className="flex max-w-[calc(100%-1rem)] flex-col gap-2">
<div className="relative flex max-w-[calc(100%-1rem)] flex-col gap-2">
{/* Show editable message if editing */}
{isEditing && isUserMessage ? (
<EditableMessage
@ -161,7 +158,7 @@ export const AIChatMessage: React.FC<AIChatMessageProps> = React.memo(({ message
{/* Action buttons */}
<m.div
className={`flex ${message.role === "user" ? "justify-end" : "justify-start"} gap-1`}
className={`absolute bottom-0 flex ${message.role === "user" ? "right-0" : "left-0"} gap-1`}
initial={{ opacity: 0, scale: 0.8 }}
animate={{
opacity: isHovered ? 1 : 0,
@ -202,6 +199,8 @@ export const AIChatMessage: React.FC<AIChatMessageProps> = React.memo(({ message
</button>
)}
</m.div>
<div className="h-6" />
</>
)}
</div>

View File

@ -0,0 +1,199 @@
import { useEntry } from "@follow/store/entry/hooks"
import { useFeedById } from "@follow/store/feed/hooks"
import { cn } from "@follow/utils/utils"
import { m } from "motion/react"
import * as React from "react"
import type { AIChatContextBlock } from "~/modules/ai/chat/__internal__/types"
interface AIDataBlockPartProps {
blocks: AIChatContextBlock[]
}
// Helper components for displaying titles
const EntryTitle: React.FC<{ entryId?: string; fallback: string }> = ({ entryId, fallback }) => {
const entryTitle = useEntry(entryId!, (e) => e?.title)
if (!entryId || !entryTitle) {
return <span className="text-text-tertiary">{fallback}</span>
}
return <span>{entryTitle}</span>
}
const FeedTitle: React.FC<{ feedId?: string; fallback: string }> = ({ feedId, fallback }) => {
const feed = useFeedById(feedId, (feed) => ({ title: feed?.title }))
if (!feedId || !feed) {
return <span className="text-text-tertiary">{fallback}</span>
}
return <span>{feed.title}</span>
}
// Data Block Component
export const AIDataBlockPart: React.FC<AIDataBlockPartProps> = React.memo(({ blocks }) => {
const getBlockIcon = (type: AIChatContextBlock["type"]) => {
switch (type) {
case "mainEntry": {
return "i-mgc-star-cute-fi"
}
case "referEntry": {
return "i-mgc-paper-cute-fi"
}
case "referFeed": {
return "i-mgc-rss-cute-fi"
}
case "selectedText": {
return "i-mgc-quill-pen-cute-re"
}
default: {
return "i-mgc-paper-cute-fi"
}
}
}
const getBlockLabel = (type: AIChatContextBlock["type"]) => {
switch (type) {
case "mainEntry": {
return "Current"
}
case "referEntry": {
return "Ref"
}
case "referFeed": {
return "Feed"
}
case "selectedText": {
return "Text"
}
default: {
return ""
}
}
}
const getBlockStyles = (type: AIChatContextBlock["type"]) => {
switch (type) {
case "mainEntry": {
return {
container: "from-orange/5 to-orange/10 border-orange/20 hover:border-orange/30",
icon: "bg-orange/10 text-orange",
label: "text-orange",
}
}
case "referEntry": {
return {
container: "from-blue/5 to-blue/10 border-blue/20 hover:border-blue/30",
icon: "bg-blue/10 text-blue",
label: "text-blue",
}
}
case "referFeed": {
return {
container: "from-green/5 to-green/10 border-green/20 hover:border-green/30",
icon: "bg-green/10 text-green",
label: "text-green",
}
}
case "selectedText": {
return {
container: "from-purple/5 to-purple/10 border-purple/20 hover:border-purple/30",
icon: "bg-purple/10 text-purple",
label: "text-purple",
}
}
default: {
return {
container: "from-gray/5 to-gray/10 border-gray/20 hover:border-gray/30",
icon: "bg-gray/10 text-gray",
label: "text-gray",
}
}
}
}
const getDisplayContent = (block: AIChatContextBlock) => {
switch (block.type) {
case "mainEntry":
case "referEntry": {
return <EntryTitle entryId={block.value} fallback={block.value} />
}
case "referFeed": {
return <FeedTitle feedId={block.value} fallback={block.value} />
}
case "selectedText": {
return `"${block.value}"`
}
default: {
return block.value
}
}
}
if (!blocks || blocks.length === 0) {
return null
}
return (
<div
className={cn(
"inline-flex flex-wrap items-center gap-2 rounded-lg p-1 pl-2",
"bg-material-ultra-thick",
"border-border border",
)}
>
{/* Context indicator */}
<div className="text-text-tertiary flex items-center gap-1.5">
<i className="i-mgc-link-cute-re size-3.5" />
<span className="text-xs font-medium">Context:</span>
</div>
{/* Blocks */}
{blocks.map((block, index) => {
const styles = getBlockStyles(block.type)
return (
<m.div
key={block.id}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{
duration: 0.3,
delay: index * 0.05,
ease: [0.25, 0.1, 0.25, 1],
}}
className={cn(
"inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5",
"border bg-gradient-to-r backdrop-blur-sm transition-all duration-200",
"hover:scale-105 hover:shadow-md",
styles.container,
)}
>
{/* Icon */}
<div
className={cn(
"flex size-5 flex-shrink-0 items-center justify-center rounded-md",
styles.icon,
)}
>
<i className={cn("size-3", getBlockIcon(block.type))} />
</div>
{/* Label and content */}
<div className="flex min-w-0 items-center gap-1">
<span className={cn("text-xs font-medium", styles.label)}>
{getBlockLabel(block.type)}
</span>
<span className="text-text-secondary text-xs">·</span>
<span className="text-text max-w-32 truncate text-xs font-medium">
{getDisplayContent(block)}
</span>
</div>
</m.div>
)
})}
</div>
)
})
AIDataBlockPart.displayName = "AIDataBlockPart"

View File

@ -3,6 +3,7 @@ import { m } from "motion/react"
import * as React from "react"
import type {
AIChatContextBlock,
AIDisplayAnalyticsTool,
AIDisplayEntriesTool,
AIDisplayFeedsTool,
@ -17,6 +18,7 @@ import {
AIDisplaySubscriptionsPart,
} from "../displays"
import { ToolInvocationComponent } from "../ToolInvocationComponent"
import { AIDataBlockPart } from "./AIDataBlockPart"
import { AIMarkdownMessage } from "./AIMarkdownMessage"
interface MessagePartsProps {
@ -61,6 +63,7 @@ const ThinkingIndicator: React.FC = () => {
</div>
)
}
export const AIMessageParts: React.FC<MessagePartsProps> = React.memo(({ message }) => {
if (!message.parts || message.parts.length === 0) {
// In AI SDK v5, messages should always have parts
@ -88,6 +91,10 @@ export const AIMessageParts: React.FC<MessagePartsProps> = React.memo(({ message
)
}
case "data-block": {
return <AIDataBlockPart key={partKey} blocks={part.data as AIChatContextBlock[]} />
}
case "tool-displayAnalytics": {
return <AIDisplayAnalyticsPart key={partKey} part={part as AIDisplayAnalyticsTool} />
}
@ -103,20 +110,6 @@ export const AIMessageParts: React.FC<MessagePartsProps> = React.memo(({ message
return <AIDisplayFeedsPart key={partKey} part={part as AIDisplayFeedsTool} />
}
// case "reasoning": {
// return (
// <details key={partKey} className="my-2">
// <summary className="text-text-tertiary hover:text-text cursor-pointer text-sm font-medium">
// <i className="i-mgc-brain-cute-re mr-2 size-3" />
// Show reasoning
// </summary>
// <div className="bg-fill-secondary border-purple/50 text-text-secondary mt-2 rounded border-l-4 p-3 text-sm">
// {parseMarkdown(part.reasoning).content}
// </div>
// </details>
// )
// }
default: {
if (part.type.startsWith("tool-")) {
return <ToolInvocationComponent key={partKey} part={part as ToolUIPart} />

View File

@ -1,8 +1,8 @@
import { useInputComposition } from "@follow/hooks"
import { cn } from "@follow/utils"
import { use, useCallback, useEffect, useRef, useState } from "react"
import { useCallback, useEffect, useRef, useState } from "react"
import { AIChatContext } from "~/modules/ai/chat/__internal__/AIChatContext"
import { useChatStatus } from "~/modules/ai/chat/__internal__/hooks"
import { useEditingMessageId, useSetEditingMessageId } from "~/modules/ai/chat/atoms/session"
interface EditableMessageProps {
@ -20,7 +20,7 @@ export const EditableMessage = ({
onCancel,
className,
}: EditableMessageProps) => {
const { status } = use(AIChatContext)
const status = useChatStatus()
const editingMessageId = useEditingMessageId()
const setEditingMessageId = useSetEditingMessageId()
const [content, setContent] = useState(initialContent)

View File

@ -1,4 +1,3 @@
import { nanoid } from "nanoid"
import { useCallback, useState } from "react"
import { AIPersistService } from "~/modules/ai/chat/services/index"
@ -14,7 +13,7 @@ export const useChatHistory = () => {
try {
const result = await AIPersistService.getChatSessions()
const sessions: ChatSession[] = result.map((row) => ({
roomId: row.roomId,
chatId: row.chatId,
title: row.title || "New Chat",
createdAt: new Date(row.createdAt),
messageCount: row.messageCount,
@ -28,20 +27,9 @@ export const useChatHistory = () => {
}
}, [])
const createNewSession = (shouldPersist = false) => {
const roomId = nanoid()
if (shouldPersist) {
AIPersistService.createSession(roomId, "New Chat").catch((error) => {
console.error("Failed to create new session:", error)
})
}
return roomId
}
return {
sessions,
loading,
loadHistory,
createNewSession,
}
}

View File

@ -1,18 +1,15 @@
import { use, useEffect, useState } from "react"
import { useEffect, useState } from "react"
import { useEventCallback } from "usehooks-ts"
import { AIChatContext } from "../__internal__/AIChatContext"
import type { BizUIMessage } from "../__internal__/types"
import { useChatActions } from "../__internal__/hooks"
import type { BizUIMessage, BizUIMetadata } from "../__internal__/types"
import { AIPersistService } from "../services"
export const useLoadMessages = (
roomId: string,
chatId: string,
options?: { onLoad?: (messages: BizUIMessage[]) => void },
) => {
const { setMessages } = use(AIChatContext)
const setMessageEventCallback = useEventCallback((messages: BizUIMessage[]) => {
setMessages(messages)
})
const chatActions = useChatActions()
const [isLoading, setIsLoading] = useState(true)
@ -23,11 +20,16 @@ export const useLoadMessages = (
useEffect(() => {
let mounted = true
setIsLoading(true)
AIPersistService.loadMessages(roomId)
AIPersistService.loadMessages(chatId)
.then((messages) => {
if (mounted) {
const messagesToSet = messages.map((message) => message.message) as BizUIMessage[]
setMessageEventCallback(messagesToSet)
const messagesToSet: BizUIMessage[] = messages.map((message) => ({
id: message.id,
parts: message.messageParts as any[],
role: message.role,
metadata: message.metadata as BizUIMetadata,
}))
chatActions.setMessages(messagesToSet)
onLoadEventCallback(messagesToSet)
}
})
@ -42,6 +44,6 @@ export const useLoadMessages = (
return () => {
mounted = false
}
}, [setMessageEventCallback, roomId, onLoadEventCallback])
}, [chatId, onLoadEventCallback, chatActions])
return { isLoading }
}

View File

@ -1,15 +1,16 @@
import { use, useEffect } from "react"
import { useEffect } from "react"
import { AIChatContext } from "../__internal__/AIChatContext"
import { useChatStatus, useMessages } from "../__internal__/hooks"
import { AIPersistService } from "../services"
export const useSaveMessages = (
roomId: string,
chatId: string,
options: {
enabled: boolean
},
) => {
const { messages, status } = use(AIChatContext)
const messages = useMessages()
const status = useChatStatus()
const isStreaming = status === "streaming"
@ -18,7 +19,7 @@ export const useSaveMessages = (
return
}
if (!roomId) {
if (!chatId) {
return
}
@ -27,6 +28,6 @@ export const useSaveMessages = (
return
}
AIPersistService.replaceAllMessages(roomId, messages)
}, [roomId, messages, options.enabled, isStreaming])
AIPersistService.replaceAllMessages(chatId, messages)
}, [chatId, messages, options.enabled, isStreaming])
}

View File

@ -1,49 +1,299 @@
import type { AsyncDb } from "@follow/database/db"
import { db } from "@follow/database/db"
import { aiChatMessagesTable, aiChatTable } from "@follow/database/schemas/index"
import { asc, eq, inArray, sql } from "drizzle-orm"
import { asc, count, eq, inArray, sql } from "drizzle-orm"
import type { SerializedEditorState } from "lexical"
import type { BizUIMessage } from "../__internal__/types"
import type { MessageContent } from "../utils/lexical-markdown"
class AIPersistServiceStatic {
async loadMessages(roomId: string) {
async loadMessages(chatId: string) {
return db.query.aiChatMessagesTable.findMany({
where: eq(aiChatMessagesTable.roomId, roomId),
where: eq(aiChatMessagesTable.chatId, chatId),
orderBy: [asc(aiChatMessagesTable.createdAt)],
})
}
async insertMessages(roomId: string, messages: BizUIMessage[]) {
/**
* Convert enhanced database message to BizUIMessage format for compatibility
*/
private convertToUIMessage(dbMessage: any): BizUIMessage {
// Reconstruct UIMessage from database fields
const uiMessage: BizUIMessage = {
id: dbMessage.id,
role: dbMessage.role,
parts: [], // AI SDK v5 uses parts array
}
// Add parts based on content format and data
if (dbMessage.messageParts && dbMessage.messageParts.length > 0) {
// For assistant messages with complex parts (tools, reasoning, etc)
uiMessage.parts = dbMessage.messageParts
} else {
// For simple text messages, create a text part
uiMessage.parts = [
{
type: "text",
text: dbMessage.content,
},
]
}
return uiMessage
}
/**
* Enhanced message loading that converts to UIMessage format
*/
async loadUIMessages(chatId: string): Promise<BizUIMessage[]> {
const dbMessages = await this.loadMessages(chatId)
return dbMessages.map((msg) => this.convertToUIMessage(msg))
}
/**
* Store a rich text message from user input
*/
async insertRichTextMessage(chatId: string, messageId: string, content: MessageContent) {
let richTextSchema: SerializedEditorState | undefined
if (content.format === "richtext") {
richTextSchema = content.content as SerializedEditorState
}
await db.insert(aiChatMessagesTable).values({
id: messageId,
chatId,
role: "user",
richTextSchema,
createdAt: new Date(),
status: "completed",
})
}
async insertMessages(chatId: string, messages: BizUIMessage[]) {
if (messages.length === 0) {
return
}
await db.insert(aiChatMessagesTable).values(
messages.map((message) => ({
roomId,
id: message.id,
message,
createdAt: message.metadata?.finishTime ? new Date(message.metadata.finishTime) : undefined,
})),
)
await db
.insert(aiChatMessagesTable)
.values(
messages.map((message) => {
// Store parts as-is since they're stored as JSON and the UI can handle them
const convertedParts = message.parts as any[]
return {
id: message.id,
chatId,
role: message.role,
contentFormat: "plaintext" as const,
richTextSchema: undefined,
createdAt: new Date(),
status: "completed" as const,
finishedAt: message.metadata?.finishTime
? new Date(message.metadata.finishTime)
: undefined,
messageParts: convertedParts,
metadata: message.metadata,
} as typeof aiChatMessagesTable.$inferInsert
}),
)
.onConflictDoUpdate({
target: [aiChatMessagesTable.id],
set: {
messageParts: sql`excluded.message_parts`,
metadata: sql`excluded.metadata`,
finishedAt: sql`excluded.finished_at`,
createdAt: sql`excluded.created_at`,
status: sql`excluded.status`,
richTextSchema: sql`excluded.rich_text_schema`,
},
})
}
async replaceAllMessages(roomId: string, messages: BizUIMessage[]) {
await db.delete(aiChatMessagesTable).where(eq(aiChatMessagesTable.roomId, roomId))
await this.insertMessages(roomId, messages)
async replaceAllMessages(chatId: string, messages: BizUIMessage[]) {
await db.delete(aiChatMessagesTable).where(eq(aiChatMessagesTable.chatId, chatId))
await this.insertMessages(chatId, messages)
}
async createSession(roomId: string, title?: string) {
await db.insert(aiChatTable).values({
roomId,
title,
createdAt: new Date(),
/**
* Upsert specific messages (insert new, update existing)
* Ensures the chat session exists before inserting messages
*/
async upsertMessages(chatId: string, messages: BizUIMessage[]) {
if (messages.length === 0) {
return
}
// Ensure the chat session exists first to avoid foreign key constraint failure
await this.ensureSession(chatId)
await db
.insert(aiChatMessagesTable)
.values(
messages.map((message) => {
const convertedParts = message.parts as any[]
return {
id: message.id,
chatId,
role: message.role,
contentFormat: "plaintext" as const,
richTextSchema: undefined,
createdAt: new Date(),
status: "completed" as const,
finishedAt: message.metadata?.finishTime
? new Date(message.metadata.finishTime)
: undefined,
messageParts: convertedParts,
metadata: message.metadata,
} as typeof aiChatMessagesTable.$inferInsert
}),
)
.onConflictDoUpdate({
target: [aiChatMessagesTable.id],
set: {
messageParts: sql`excluded.message_parts`,
metadata: sql`excluded.metadata`,
finishedAt: sql`excluded.finished_at`,
status: sql`excluded.status`,
richTextSchema: sql`excluded.rich_text_schema`,
},
})
}
/**
* Delete specific messages by ID
*/
async deleteMessages(chatId: string, messageIds: string[]) {
if (messageIds.length === 0) {
return
}
await db
.delete(aiChatMessagesTable)
.where(eq(aiChatMessagesTable.chatId, chatId) && inArray(aiChatMessagesTable.id, messageIds))
}
/**
* Ensure session exists (idempotent operation)
*/
async ensureSession(chatId: string, title?: string) {
const existing = await this.getChatSession(chatId)
if (!existing) {
await this.createSession(chatId, title)
return { created: true, session: { chatId, title, createdAt: new Date() } }
}
return { created: false, session: existing }
}
/**
* Batch multiple persistence operations atomically
*/
async batchUpdate(
operations: Array<{
type: "session_create" | "session_update" | "messages_upsert" | "messages_delete"
payload: any
}>,
) {
return db.transaction(async (tx) => {
for (const operation of operations) {
switch (operation.type) {
case "session_create": {
await tx
.insert(aiChatTable)
.values({
chatId: operation.payload.chatId,
title: operation.payload.title,
createdAt: new Date(),
updatedAt: new Date(),
})
.onConflictDoNothing()
break
}
case "session_update": {
await tx
.update(aiChatTable)
.set({
title: operation.payload.title,
updatedAt: new Date(),
})
.where(eq(aiChatTable.chatId, operation.payload.chatId))
break
}
case "messages_upsert": {
if (operation.payload.messages.length > 0) {
await tx
.insert(aiChatMessagesTable)
.values(operation.payload.messages)
.onConflictDoUpdate({
target: [aiChatMessagesTable.id],
set: {
messageParts: sql`excluded.message_parts`,
metadata: sql`excluded.metadata`,
finishedAt: sql`excluded.finished_at`,
status: sql`excluded.status`,
richTextSchema: sql`excluded.rich_text_schema`,
},
})
}
break
}
case "messages_delete": {
if (operation.payload.messageIds.length > 0) {
await tx
.delete(aiChatMessagesTable)
.where(
eq(aiChatMessagesTable.chatId, operation.payload.chatId) &&
inArray(aiChatMessagesTable.id, operation.payload.messageIds),
)
}
break
}
}
}
})
}
async createSession(chatId: string, title?: string) {
const now = new Date()
await db.insert(aiChatTable).values({
chatId,
title,
createdAt: now,
updatedAt: now,
})
}
async getChatSession(chatId: string) {
const result = await db.query.aiChatTable.findFirst({
where: eq(aiChatTable.chatId, chatId),
columns: {
chatId: true,
title: true,
createdAt: true,
},
})
// Explicitly check if the result is valid
if (!result || !result.chatId) {
return null
}
return result
}
async getChatSessions(limit = 20) {
const chats = await db.query.aiChatTable.findMany({
columns: {
roomId: true,
chatId: true,
title: true,
createdAt: true,
},
@ -51,53 +301,62 @@ class AIPersistServiceStatic {
limit,
})
const result = await Promise.all(
chats.map(async (chat) => {
// Use raw SQL count query
const messageCountResult = await db.values<[number]>(
sql`SELECT COUNT(*) FROM ${aiChatMessagesTable} WHERE ${aiChatMessagesTable.roomId} = ${chat.roomId}`,
)
if (chats.length === 0) {
return []
}
const messageCount = messageCountResult[0]?.[0] || 0
const chatIds = chats.map((chat) => chat.chatId)
const messageCounts = await (db as AsyncDb)
.select({
chatId: aiChatMessagesTable.chatId,
messageCount: count(aiChatMessagesTable.id),
})
.from(aiChatMessagesTable)
.where(inArray(aiChatMessagesTable.chatId, chatIds))
.groupBy(aiChatMessagesTable.chatId)
return {
roomId: chat.roomId,
title: chat.title,
createdAt: chat.createdAt,
messageCount,
}
}),
)
const messageCountMap = new Map(messageCounts.map((item) => [item.chatId, item.messageCount]))
// Filter out chats with 0 messages
return result.filter((chat) => chat.messageCount > 0)
return chats
.map((chat) => ({
chatId: chat.chatId,
title: chat.title,
createdAt: chat.createdAt,
messageCount: messageCountMap.get(chat.chatId) || 0,
}))
.filter((chat) => chat.messageCount > 0)
}
async deleteSession(roomId: string) {
await db.delete(aiChatMessagesTable).where(eq(aiChatMessagesTable.roomId, roomId))
await db.delete(aiChatTable).where(eq(aiChatTable.roomId, roomId))
async deleteSession(chatId: string) {
await db.delete(aiChatMessagesTable).where(eq(aiChatMessagesTable.chatId, chatId))
await db.delete(aiChatTable).where(eq(aiChatTable.chatId, chatId))
}
async updateSessionTitle(roomId: string, title: string) {
await db.update(aiChatTable).set({ title }).where(eq(aiChatTable.roomId, roomId))
async updateSessionTitle(chatId: string, title: string) {
await db
.update(aiChatTable)
.set({
title,
updatedAt: new Date(Date.now()),
})
.where(eq(aiChatTable.chatId, chatId))
}
async cleanupEmptySessions() {
// Use raw SQL to find empty sessions
const emptySessions = await db.values<[string]>(
sql`
SELECT ${aiChatTable.roomId}
SELECT ${aiChatTable.chatId}
FROM ${aiChatTable}
LEFT JOIN ${aiChatMessagesTable} ON ${aiChatTable.roomId} = ${aiChatMessagesTable.roomId}
GROUP BY ${aiChatTable.roomId}
LEFT JOIN ${aiChatMessagesTable} ON ${aiChatTable.chatId} = ${aiChatMessagesTable.chatId}
GROUP BY ${aiChatTable.chatId}
HAVING COUNT(${aiChatMessagesTable.id}) = 0
`,
)
// Delete empty sessions
if (emptySessions.length > 0) {
const roomIdsToDelete = emptySessions.map((row) => row[0])
await db.delete(aiChatTable).where(inArray(aiChatTable.roomId, roomIdsToDelete))
const chatIdsToDelete = emptySessions.map((row) => row[0])
await db.delete(aiChatTable).where(inArray(aiChatTable.chatId, chatIdsToDelete))
}
}
}

View File

@ -1,5 +1,5 @@
export interface ChatSession {
roomId: string
chatId: string
title?: string
createdAt: Date
messageCount: number

View File

@ -0,0 +1,86 @@
import { $convertToMarkdownString, TRANSFORMERS } from "@lexical/markdown"
import type { LexicalEditor, SerializedEditorState } from "lexical"
/**
* Message format types
*/
export type MessageFormat = "plaintext" | "richtext"
/**
* Message content with format information
*/
export interface MessageContent {
/** The content format */
format: MessageFormat
/** Raw content - markdown string for plaintext, Lexical schema for richtext */
content: string | SerializedEditorState
}
/**
* Convert Lexical editor state to markdown string for AI communication
*/
export function convertLexicalToMarkdown(editor: LexicalEditor): string {
let markdown = ""
editor.getEditorState().read(() => {
markdown = $convertToMarkdownString(TRANSFORMERS)
})
return markdown
}
/**
* Create MessageContent for user's rich text input
*/
export function createRichTextMessage(editor: LexicalEditor): MessageContent {
const schema = editor.getEditorState().toJSON()
return {
format: "richtext",
content: schema,
}
}
/**
* Create MessageContent for plaintext (AI responses, existing messages)
*/
export function createPlaintextMessage(text: string): MessageContent {
return {
format: "plaintext",
content: text,
}
}
/**
* Get markdown string for AI communication from any MessageContent
*/
export function getMarkdownForAI(message: MessageContent, editor?: LexicalEditor): string {
if (message.format === "plaintext") {
return message.content as string
}
if (message.format === "richtext" && editor) {
// Set editor state from schema and convert to markdown
const schema = message.content as SerializedEditorState
const editorState = editor.parseEditorState(schema)
editor.setEditorState(editorState)
return convertLexicalToMarkdown(editor)
}
// Fallback
return ""
}
/**
* Check if message is rich text format
*/
export function isRichTextMessage(message: MessageContent): boolean {
return message.format === "richtext"
}
/**
* Check if message is plaintext format
*/
export function isPlaintextMessage(message: MessageContent): boolean {
return message.format === "plaintext"
}

View File

@ -1,17 +1,20 @@
import type { FC } from "react"
import { Focusable } from "~/components/common/Focusable"
import { HotkeyScope } from "~/constants"
import { AIChatRoot } from "~/modules/ai/chat/components/AIChatRoot"
import { ChatHeader } from "~/modules/ai/chat/components/layouts/ChatHeader"
import { ChatInterface } from "~/modules/ai/chat/components/layouts/ChatInterface"
import { ChatHeader } from "./components/ChatHeader"
import { ChatInterface } from "./components/ChatInterface"
export const AIChatLayout = ({ style }: { style?: React.CSSProperties }) => {
export const AIChatLayout: FC<
React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>
> = ({ ...props }) => {
return (
<AIChatRoot wrapFocusable={false}>
<Focusable
scope={HotkeyScope.AIChat}
className="bg-background relative flex h-full flex-col overflow-hidden"
style={style}
{...props}
>
<ChatHeader />
<ChatInterface />

View File

@ -3,11 +3,11 @@ import { views } from "@follow/constants"
import { clsx, cn } from "@follow/utils/utils"
import { easeOut } from "motion/react"
import type { FC, PropsWithChildren } from "react"
import { useMemo, useRef } from "react"
import { useMemo } from "react"
import { useResizable } from "react-resizable-layout"
import { useParams } from "react-router"
import { setAIChatPinned, useAIChatPinned } from "~/atoms/settings/ai"
import { useAIChatPinned } from "~/atoms/settings/ai"
import { useRealInWideMode } from "~/atoms/settings/ui"
import { useTimelineColumnShow, useTimelineColumnTempShow } from "~/atoms/sidebar"
import { m } from "~/components/common/Motion"
@ -18,7 +18,6 @@ import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
import { useRouteParams } from "~/hooks/biz/useRouteParams"
import { AIChatRoot } from "~/modules/ai/chat/components/AIChatRoot"
import { EntryContent } from "~/modules/entry-content/components/entry-content"
import { AIChatPanelContainer } from "~/modules/entry-content/components/entry-content/ai"
import { AppLayoutGridContainerProvider } from "~/providers/app-grid-layout-container-provider"
import { AIChatLayout } from "../ai/AIChatLayout"
@ -119,7 +118,6 @@ const Grid = ({ entryId }) => {
initial: 400,
reverse: true,
})
const handleAIChatPanelClose = useRef(() => setAIChatPinned(false)).current
return (
<AIChatRoot wrapFocusable={false}>
@ -154,11 +152,6 @@ const Grid = ({ entryId }) => {
cursor={separatorCursor}
{...separatorProps}
/>
<AIChatPanelContainer
className="absolute inset-0"
entryId={entryId}
onClose={handleAIChatPanelClose}
/>
</div>
)}
</div>

View File

@ -112,7 +112,7 @@ export const EntryColumnLayout = () => {
const { position, separatorProps, isDragging, separatorCursor, setPosition } = useResizable({
axis: "x",
min: 300,
max: 600,
max: 1200,
initial: aiColWidth,
reverse: true,
onResizeStart({ position }) {
@ -187,7 +187,11 @@ export const EntryColumnLayout = () => {
setPosition(defaultUISettings.aiColWidth)
}}
/>
<AIChatLayout style={{ width: position }} />
<AIChatLayout
style={
{ width: position, "--ai-chat-layout-width": `${position}px` } as React.CSSProperties
}
/>
</div>
)
}

View File

@ -158,17 +158,23 @@ const SocialMediaDateItem = ({
isSticky?: boolean
}) => {
const { startOfDay, endOfDay, dateObj } = useParseDate(date)
const aiEnabled = useFeature("ai")
return (
<DateItemInner
// @ts-expect-error
Wrapper={useCallback(
({ children }) => (
<div className="m-auto flex w-[645px] max-w-full select-none gap-3 pl-5 text-base lg:text-lg">
<div
className={cn(
"m-auto flex w-[645px] max-w-full select-none gap-3 pl-5 text-base lg:text-lg",
aiEnabled && "pl-2",
)}
>
{children}
</div>
),
[],
[aiEnabled],
)}
className={className}
date={dateObj}

View File

@ -217,7 +217,7 @@ export const EntryItemWrapper: FC<
to={navigationPath}
className={cn(
"hover:bg-theme-item-hover cursor-button relative block overflow-visible duration-200",
isWide ? "rounded-md" : "",
isWide ? "@[650px]:rounded-md rounded-none" : "",
(isActive || isContextMenuOpen) && "!bg-theme-item-active",
itemClassName,
)}

View File

@ -99,7 +99,7 @@ const EntryContentImpl: Component<EntryContentProps> = ({
if (!entry) return null
return (
<div className={cn(className, "flex flex-col")}>
<div className={cn(className, "@container flex flex-col")}>
<EntryCommandShortcutRegister entryId={entryId} view={view} />
{!isInPeekModal && (
<EntryHeader

View File

@ -236,8 +236,6 @@ const EntryContentImpl: Component<EntryContentProps> = ({
</EntryScrollArea>
<SourceContentPanel src={safeUrl ?? "#"} />
</Focusable>
{/* <React.Suspense>{!isInPeekModal && <AISmartSidebar entryId={entryId} />}</React.Suspense> */}
</>
)
}

View File

@ -1,31 +0,0 @@
import { use } from "react"
import { AIChatContext } from "~/modules/ai/chat/__internal__/AIChatContext"
import { AIChatContextBar } from "~/modules/ai/chat/components/AIChatContextBar"
export const AIChatBottom: React.FC<{ children: React.ReactNode }> = (props) => {
return (
<div className="bg-background relative overflow-hidden">
<AIChatErrorIndicator />
{/* Context Bar */}
<div className="border-border border-b">
<AIChatContextBar />
</div>
{props.children}
</div>
)
}
const AIChatErrorIndicator = () => {
const { error } = use(AIChatContext)
if (!error) return null
return (
<div className="bg-red/50 max-h-12 w-full px-2 py-1 text-[12px]">
<div className="line-clamp-2 text-white">{error.message}</div>
</div>
)
}

View File

@ -1,198 +0,0 @@
import { ScrollArea } from "@follow/components/ui/scroll-area/ScrollArea.js"
import { cn, nextFrame } from "@follow/utils"
import * as React from "react"
import { useDialog } from "~/components/ui/modal/stacked/hooks"
import { AISpline } from "~/modules/ai/AISpline"
import { AIChatContext, AIPanelRefsContext } from "~/modules/ai/chat/__internal__/AIChatContext"
import {
AIChatMessage,
AIChatTypingIndicator,
} from "~/modules/ai/chat/components/message/AIChatMessage"
import { useAutoScroll } from "~/modules/ai/chat/hooks/useAutoScroll"
import { useLoadMessages } from "~/modules/ai/chat/hooks/useLoadMessages"
import { useSaveMessages } from "~/modules/ai/chat/hooks/useSaveMessages"
import { AIChatBottom } from "./AIChatBottom"
import { AIChatInput } from "./AIChatInput"
declare const APP_NAME: string
interface AIChatContainerProps {
onSendMessage?: (message: string) => void
}
const Welcome: React.FC = () => {
const { inputRef } = React.use(AIPanelRefsContext)
const { ask } = useDialog()
const guardChangeValue = (toValue: string) => {
const { value } = inputRef.current
const fn = () => {
inputRef.current.value = toValue
nextFrame(() => {
inputRef.current.focus()
inputRef.current.selectionStart = inputRef.current.selectionEnd = toValue.length
})
}
if (value) {
ask({
title: "Confirm Change",
message:
"Are you sure you want to change the value to recommend? This will overwrite the current value.",
confirmText: "Change",
cancelText: "Cancel",
variant: "warning",
onConfirm: () => {
fn()
},
})
} else {
fn()
}
}
const items = [
{
tag: "Content analysis",
onClick: () => {
guardChangeValue("Analyze the content of this entry")
},
},
{
tag: "Summaries",
onClick: () => {
guardChangeValue("Summarize the content of this entry")
},
},
{
tag: "Insights",
onClick: () => {
guardChangeValue("Provide insights about the content of this entry")
},
},
]
return (
<div className="flex flex-1 items-center justify-center p-6">
<div className="max-w-md space-y-6 text-center">
<AISpline />
<div>
<h2 className="text-text mb-2 text-xl font-semibold">Welcome to {APP_NAME} AI</h2>
<p className="text-text-secondary text-sm">
I can help you analyze content, answer questions, and provide insights about your feeds.
</p>
</div>
<div className="space-y-2">
<p className="text-text-tertiary text-xs">Ask me anything about:</p>
<div className="flex flex-wrap justify-center gap-2">
{items.map((item) => (
<button
key={item.tag}
className="bg-fill-tertiary text-text-secondary hover:bg-fill-secondary rounded-full px-3 py-1 text-xs"
onClick={item.onClick}
type="button"
>
{item.tag}
</button>
))}
</div>
</div>
</div>
</div>
)
}
export const AIChatContainer: React.FC<AIChatContainerProps> = React.memo(({ onSendMessage }) => {
const { inputRef } = React.use(AIPanelRefsContext)
const scrollAreaRef = React.useRef<HTMLDivElement>(null)
const { messages, status, id: roomId } = React.use(AIChatContext)
const scrollToBottom = React.useCallback(() => {
if (scrollAreaRef.current) {
scrollAreaRef.current.scrollTop = scrollAreaRef.current.scrollHeight
}
}, [])
// Auto-scroll logic
const { resetScrollState, scrollToBottom: scrollToBottomAnimated } = useAutoScroll(
scrollAreaRef.current,
status === "streaming",
)
const { isLoading: isLoadingHistory } = useLoadMessages(roomId, {
onLoad: scrollToBottom,
})
useSaveMessages(roomId, { enabled: !isLoadingHistory })
// Auto-scroll to bottom when initial messages are loaded
React.useEffect(() => {
nextFrame(() => {
scrollToBottom()
})
}, [scrollToBottom])
const handleSendMessage = (message: string) => {
if (inputRef.current) {
inputRef.current.value = ""
}
// Call the actual sending logic
if (onSendMessage) {
onSendMessage(message)
} else {
// Demo fallback for no onSendMessage
console.info("Sending message:", message)
}
// Reset scroll state when sending a new message
resetScrollState()
// Scroll to bottom after new message
requestAnimationFrame(() => {
scrollToBottomAnimated()
})
}
// Show welcome screen if no messages
const showWelcome = messages.length === 0 && !isLoadingHistory
return (
<>
<div className="flex min-h-0 grow flex-col">
{showWelcome && <Welcome />}
{isLoadingHistory && (
<div className="center absolute inset-0 flex">
<i className="i-mgc-loading-3-cute-re text-text size-6 animate-spin" />
</div>
)}
<ScrollArea
ref={scrollAreaRef}
flex
viewportClassName="p-6"
rootClassName={cn("min-h-[500px] flex-1", showWelcome && "hidden")}
>
<div className="flex flex-col gap-2">
{messages.map((message) => (
<AIChatMessage key={message.id} message={message} />
))}
{status === "submitted" && <AIChatTypingIndicator />}
</div>
</ScrollArea>
{/* Input area */}
<div className="border-border pb-safe shrink-0 border-t">
<AIChatBottom>
<AIChatInput
onSend={handleSendMessage}
placeholder={showWelcome ? "What are your thoughts?" : "Ask me anything..."}
/>
</AIChatBottom>
</div>
</div>
</>
)
})

View File

@ -1,128 +0,0 @@
import { cn } from "@follow/utils"
import * as React from "react"
import { AIChatContext, AIPanelRefsContext } from "~/modules/ai/chat/__internal__/AIChatContext"
import { AIChatSendButton } from "~/modules/ai/chat/components/AIChatSendButton"
interface AIChatInputProps {
value?: string
onChange?: (value: string) => void
onSend: (message: string) => void
placeholder?: string
disabled?: boolean
}
const minHeight = 120
const maxHeight = 200
export const AIChatInput = ({
value,
onChange,
onSend,
placeholder = "Ask me anything about your feeds, or describe a task...",
disabled = false,
}: AIChatInputProps) => {
const { inputRef: textareaRef } = React.use(AIPanelRefsContext)
const [height, setHeight] = React.useState(minHeight)
const [isEmpty, setIsEmpty] = React.useState(true)
const { status, stop } = React.use(AIChatContext)
// Determine if we should show stop button
const isProcessing = status === "submitted" || status === "streaming"
const handleSend = () => {
if (textareaRef.current && textareaRef.current.value.trim()) {
const message = textareaRef.current.value.trim()
onSend(message)
setIsEmpty(true)
requestAnimationFrame(() => {
textareaRef.current?.focus()
})
}
}
const handleButtonClick = () => {
if (isProcessing) {
// Stop the AI processing
stop?.()
} else {
// Send the message
handleSend()
}
}
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleButtonClick()
}
}
// Auto-resize function
const autoResize = React.useCallback(() => {
if (textareaRef.current) {
textareaRef.current.style.height = "auto"
const { scrollHeight } = textareaRef.current
const newHeight = Math.min(Math.max(scrollHeight, minHeight), maxHeight)
setHeight(newHeight)
textareaRef.current.style.height = `${newHeight}px`
}
}, [textareaRef])
// Auto-resize when value changes
React.useEffect(() => {
autoResize()
}, [value, autoResize])
React.useEffect(() => {
if (textareaRef.current) {
autoResize()
textareaRef.current.focus()
}
}, [])
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
onChange?.(e.target.value)
setTimeout(autoResize, 0)
setIsEmpty(e.target.value.trim() === "")
}
return (
<div className="relative">
<textarea
ref={textareaRef}
value={value}
onChange={handleChange}
onKeyPress={handleKeyPress}
onFocus={() => {
setIsEmpty(!textareaRef.current?.value)
}}
placeholder={placeholder}
disabled={disabled}
className={cn(
"scrollbar-none placeholder:text-text-tertiary w-full resize-none border-0 bg-transparent text-sm outline-none transition-all disabled:opacity-50",
"px-4 py-3 pr-12",
)}
style={{
height: `${height}px`,
minHeight: `${minHeight}px`,
maxHeight: `${maxHeight}px`,
}}
/>
{/* Action buttons inside input */}
<div className="absolute right-3 top-3 flex items-center gap-2">
{/* Send/Stop button */}
<AIChatSendButton
onClick={handleButtonClick}
disabled={disabled || (!isProcessing && isEmpty)}
isProcessing={isProcessing}
size="sm"
/>
</div>
</div>
)
}

View File

@ -1,61 +0,0 @@
import { ActionButton } from "@follow/components/ui/button/action-button.js"
import * as React from "react"
import { useTranslation } from "react-i18next"
import { setAIChatPinned, useAIChatPinned } from "~/atoms/settings/ai"
import { useDialog } from "~/components/ui/modal/stacked/hooks"
import { AIChatContext } from "~/modules/ai/chat/__internal__/AIChatContext"
export const AIPanelHeader: React.FC<{ onClose: () => void }> = ({ onClose }) => {
const { setMessages, messages } = React.use(AIChatContext)
const isAiChatPinned = useAIChatPinned()
const { ask } = useDialog()
const { t } = useTranslation("ai")
return (
<div className="border-border flex h-[55px] shrink-0 items-center justify-between border-b px-3">
<div className="flex items-center gap-3">
<div className="from-folo flex size-8 items-center justify-center rounded-full bg-gradient-to-br to-red-500">
<i className="i-mgc-ai-cute-fi size-4 text-white" />
</div>
<div>
<h3 className="text-sm font-semibold">{APP_NAME} AI</h3>
</div>
</div>
<div className="flex items-center gap-2">
<ActionButton
icon={<i className="i-mgc-add-cute-re size-4" />}
onClick={() => {
if (messages.length === 0) {
return
}
ask({
title: t("clear_chat"),
message: t("clear_chat_message"),
variant: "danger",
onConfirm: () => {
setMessages([])
},
})
}}
/>
{!isAiChatPinned && (
<ActionButton
icon={<i className="i-mingcute-pin-line size-4" />}
onClick={() => {
setAIChatPinned(true)
onClose()
}}
/>
)}
<button
type="button"
className="bg-fill-tertiary hover:bg-fill-secondary flex size-8 items-center justify-center rounded-full transition-colors"
onClick={onClose}
>
<i className="i-mgc-close-cute-re size-4" />
</button>
</div>
</div>
)
}

View File

@ -1,13 +0,0 @@
import { createContext, use } from "react"
import type { StoreApi, UseBoundStore } from "zustand"
export interface IEntryAIContext {
entryId?: Nullable<string>
selectedText?: Nullable<string>
}
export const EntryAIContext = createContext<UseBoundStore<StoreApi<IEntryAIContext>>>(null!)
export const useEntryAIContextStore = () => {
return use(EntryAIContext)
}

View File

@ -1,326 +0,0 @@
import { useGlobalFocusableScopeSelector } from "@follow/components/common/Focusable/hooks.js"
import { Spring } from "@follow/components/constants/spring.js"
import { useMousePosition } from "@follow/components/hooks/useMouse.js"
import { cn, combineCleanupFunctions } from "@follow/utils"
import { EventBus } from "@follow/utils/event-bus"
import { AnimatePresence, m } from "motion/react"
import type { FC } from "react"
import * as React from "react"
import { useEffect, useState } from "react"
import { create } from "zustand"
import { getAIChatPinned, setAIChatPinned, useAIChatPinned } from "~/atoms/settings/ai"
import { Focusable, FocusablePresets } from "~/components/common/Focusable"
import { HotkeyScope } from "~/constants"
import { AIChatContext, useAIChatStore } from "~/modules/ai/chat/__internal__/AIChatContext"
import { COMMAND_ID } from "~/modules/command/commands/id"
import { useCommandBinding } from "~/modules/command/hooks/use-command-binding"
import { AIChatContainer } from "./AIChatContainer"
import { AIPanelHeader } from "./AIPanelHeader"
import type { IEntryAIContext } from "./context"
import { EntryAIContext, useEntryAIContextStore } from "./context"
const AIChat: React.FC = () => {
const { sendMessage } = React.use(AIChatContext)
const handleSendMessage = React.useCallback(
(message: string) => {
sendMessage({
text: message,
metadata: {
finishTime: new Date().toISOString(),
},
})
},
[sendMessage],
)
const store = useEntryAIContextStore()()
// const setContextInfo = useAIChatStore((s) => s.setContextInfo)
const useAiContextStore = useAIChatStore()
useEffect(() => {
const aiContextStore = useAiContextStore.getState()
aiContextStore.setEntryId(store.entryId ?? undefined)
aiContextStore.setSelectedText(store.selectedText ?? undefined)
}, [store.entryId, store.selectedText, useAiContextStore])
return <AIChatContainer onSendMessage={handleSendMessage} />
}
const AIAmbientSidebar: React.FC<{ onExpand: () => void }> = ({ onExpand }) => {
const [intensity, setIntensity] = useState(0)
const [showPrompt, setShowPrompt] = useState(false)
const isShowPromptRef = React.useRef(false)
const mousePosition = useMousePosition()
// Calculate the distance between the mouse and the right edge of the screen
useEffect(() => {
const rightEdgeDistance = window.innerWidth - mousePosition.x
const maxDistance = 500 // Maximum sensing distance
const threshold = 80 // Threshold distance for showing prompt
const showedThreshold = 300
const topBoundary = 100 // Top boundary to avoid toolbar area
// Don't trigger if mouse is in the toolbar area (top 100px)
if (mousePosition.y <= topBoundary) {
setIntensity(0)
setShowPrompt(false)
isShowPromptRef.current = false
return
}
if (isShowPromptRef.current && rightEdgeDistance <= showedThreshold) {
return
}
if (rightEdgeDistance <= maxDistance) {
const newIntensity = Math.max(0, (maxDistance - rightEdgeDistance) / maxDistance)
setIntensity(newIntensity)
const showPrompt = rightEdgeDistance <= threshold
setShowPrompt(showPrompt)
isShowPromptRef.current = showPrompt
} else {
setIntensity(0)
setShowPrompt(false)
isShowPromptRef.current = false
}
}, [mousePosition])
const selectedText = useEntryAIContextStore()((s) => s.selectedText)
return (
<>
{/* Blurred gradient bar - Adheres to the right edge */}
<m.div
className="pointer-events-none fixed right-0 top-0 z-40 h-full w-2"
animate={{
opacity: intensity * 0.8,
width: intensity > 0 ? 8 + intensity * 40 : 2,
// Breathing effect - Starts breathing when intensity is greater than 0.3
scale: intensity > 0.3 ? [1, 1.1, 1] : 1,
}}
transition={{
duration: 0.3,
ease: "easeOut",
// Breathing animation configuration
scale: {
repeat: intensity > 0.3 ? Infinity : 0,
duration: 2,
ease: "easeInOut",
},
}}
style={{
background: `linear-gradient(to left,
rgba(255, 92, 0, ${intensity * 0.4}) 0%,
rgba(255, 140, 0, ${intensity * 0.3}) 50%,
transparent 100%)`,
transformOrigin: "right center",
}}
/>
<AnimatePresence>
{showPrompt && (
<>
<m.div
className="pointer-events-none fixed bottom-12 right-0 z-40"
initial={{ opacity: 0, scale: 0.5, x: 0 }}
animate={{
opacity: intensity * 0.6,
scale: 0.5 + intensity * 1.5,
x: intensity > 0 ? -10 - intensity * 30 : 0,
}}
transition={Spring.presets.smooth}
exit={{
opacity: 0,
scale: 0.5,
x: 0,
}}
style={{
width: 100,
height: 100,
background: `radial-gradient(circle,
rgba(255, 92, 0, ${intensity * 0.3}) 0%,
rgba(255, 140, 0, ${intensity * 0.2}) 40%,
transparent 70%)`,
}}
/>
<m.div
className="fixed bottom-12 right-6 z-50 flex flex-col items-end gap-3"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
transition={Spring.presets.smooth}
>
{/* Text prompt */}
<m.div className="bg-background/90 border-folo/30 rounded-2xl border px-4 py-3 backdrop-blur-xl">
<div className="text-right">
<p className="text-text text-sm font-medium">
{selectedText ? "Ask AI about selection" : "Ask AI anything"}
</p>
<p className="text-text-secondary mt-1 text-xs">
{selectedText
? `"${selectedText.slice(0, 30)}..."`
: "Get insights about this article"}
</p>
</div>
</m.div>
{/* Clickable area */}
<m.button
className="border-folo/40 from-folo/20 hover:from-folo/30 rounded-full border bg-gradient-to-r to-red-500/20 px-6 py-2 backdrop-blur-xl transition-all duration-300 hover:to-red-500/30"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={onExpand}
>
<div className="flex items-center gap-2">
<m.div
className="from-folo size-2 rounded-full bg-gradient-to-r to-red-500"
animate={{
scale: [1, 1.2, 1],
opacity: [0.7, 1, 0.7],
}}
transition={{
repeat: Infinity,
duration: 2,
ease: "easeInOut",
}}
/>
<span className="text-text text-sm font-medium">Open AI Chat</span>
</div>
</m.button>
</m.div>
</>
)}
</AnimatePresence>
</>
)
}
// AI Chat Panel Component - Expanded chat panel
const AIChatSidePanel: React.FC<{ onClose: () => void }> = ({ onClose }) => (
<m.div
className="bg-background/95 border-border fixed inset-y-0 right-0 z-50 flex w-96 flex-col border-l shadow-2xl backdrop-blur-xl"
initial={{ x: "100%" }}
animate={{ x: 0 }}
exit={{ x: "100%" }}
transition={{ type: "spring", damping: 25, stiffness: 200 }}
>
{/* Panel header */}
<AIPanelHeader onClose={onClose} />
{/* AI Chat content */}
<div className="flex min-h-[500px] grow flex-col">
<Focusable scope={HotkeyScope.AIChat} asChild>
<AIChat />
</Focusable>
</div>
</m.div>
)
const useCreateEntryAIContext = (entryId: string) => {
const ctxStore = React.useMemo(() => {
return create<IEntryAIContext>(() => ({
entryId,
selectedText: "",
}))
}, [entryId])
// Listen for text selection
useEffect(() => {
const handleSelection = () => {
const selection = window.getSelection()
if (
selection?.anchorNode?.firstChild?.nodeName === "INPUT" ||
selection?.anchorNode?.firstChild?.nodeName === "TEXTAREA"
) {
return
}
const text = selection?.toString().trim()
if (text)
ctxStore.setState({
selectedText: text || "",
})
}
document.addEventListener("selectionchange", handleSelection)
return () => document.removeEventListener("selectionchange", handleSelection)
}, [ctxStore])
return ctxStore
}
// eslint-disable-next-line unicorn/no-thenable
const infiniteThenable = { then() {} }
export const AISmartSidebar = ({ entryId }: { entryId: string }) => {
const [isExpanded, setIsExpanded] = useState(false)
const hasAi = React.use(AIChatContext)
if (!hasAi) {
throw infiniteThenable
}
const ctxStore = useCreateEntryAIContext(entryId)
const when = useGlobalFocusableScopeSelector(FocusablePresets.isNotFloatingLayerScope)
useCommandBinding({
commandId: COMMAND_ID.global.toggleAIChat,
when,
})
useCommandBinding({
commandId: COMMAND_ID.global.toggleAIChatPinned,
when,
})
useEffect(() => {
return combineCleanupFunctions(
EventBus.subscribe(COMMAND_ID.global.toggleAIChat, () => {
setIsExpanded((state) => !state)
}),
EventBus.subscribe(COMMAND_ID.global.toggleAIChatPinned, () => {
const current = getAIChatPinned()
setIsExpanded(false)
setAIChatPinned(!current)
}),
)
}, [])
if (useAIChatPinned()) return null
return (
<EntryAIContext value={ctxStore}>
{!isExpanded && <AIAmbientSidebar onExpand={() => setIsExpanded(true)} />}
<AnimatePresence>
{isExpanded && <AIChatSidePanel onClose={() => setIsExpanded(false)} />}
</AnimatePresence>
</EntryAIContext>
)
}
export const AIChatPanelContainer: FC<{
className?: string
entryId: string
onClose: () => void
}> = React.memo(({ className, entryId, onClose }) => {
const ctxStore = useCreateEntryAIContext(entryId)
return (
<EntryAIContext value={ctxStore}>
<Focusable
scope={HotkeyScope.AIChat}
className={cn("bg-background relative flex grow flex-col overflow-hidden", className)}
>
{/* Panel header */}
<AIPanelHeader onClose={onClose} />
{/* AI Chat content */}
<div className="relative flex grow flex-col overflow-hidden">
<AIChat />
</div>
</Focusable>
</EntryAIContext>
)
})

View File

@ -7,7 +7,6 @@ import { cn } from "@follow/utils"
import { ErrorBoundary } from "@sentry/react"
import { useMemo, useRef } from "react"
import { useEntryIsInReadability } from "~/atoms/readability"
import { useUISettingKey } from "~/atoms/settings/ui"
import { ShadowDOM } from "~/components/common/ShadowDOM"
import type { TocRef } from "~/components/ui/markdown/components/Toc"
@ -49,10 +48,9 @@ export const ArticleLayout: React.FC<ArticleLayoutProps> = ({
}))
const feed = useFeedById(entry?.feedId)
const isInbox = useIsInbox(entry?.inboxId)
const _isInReadabilityMode = useEntryIsInReadability(entryId)
const { content } = useEntryContent(entryId)
const customCSS = useUISettingKey("customCSS")
const _isInPeekModal = useInPeekModal()
if (!entry) return null

View File

@ -44,6 +44,7 @@ export default defineConfig(
"@eslint-react/hooks-extra/no-unnecessary-use-callback": "warn",
// NOTE: Disable this temporarily
"react-compiler/react-compiler": 0,
"unicorn/no-array-callback-reference": 0,
"no-restricted-syntax": 0,
"no-restricted-globals": [
"error",

View File

@ -1,3 +1,6 @@
import type { BaseSQLiteDatabase } from "drizzle-orm/sqlite-core"
import type * as schema from "./schemas"
import type { DB } from "./types"
export declare const sqlite: unknown
@ -9,3 +12,5 @@ export declare function exportDB(): Promise<Blob>
* Deletes the database file, normally you should reload the app after calling this function.
*/
export declare function deleteDB(): Promise<void>
export type AsyncDb = BaseSQLiteDatabase<"async", any, typeof schema>

View File

@ -0,0 +1,25 @@
ALTER TABLE `ai_chat` RENAME TO `ai_chat_sessions`;--> statement-breakpoint
ALTER TABLE `ai_chat_sessions` RENAME COLUMN "room_id" TO "id";--> statement-breakpoint
ALTER TABLE `ai_chat_sessions` ADD `updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL;--> statement-breakpoint
CREATE INDEX `idx_ai_chat_sessions_updated_at` ON `ai_chat_sessions` (`updated_at`);--> statement-breakpoint
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_ai_chat_messages` (
`id` text PRIMARY KEY NOT NULL,
`chat_id` text NOT NULL,
`role` text NOT NULL,
`rich_text_schema` text,
`created_at` integer,
`metadata` text,
`status` text DEFAULT 'completed',
`finished_at` integer,
`message_parts` text,
FOREIGN KEY (`chat_id`) REFERENCES `ai_chat_sessions`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
INSERT INTO `__new_ai_chat_messages`("id", "chat_id", "role", "rich_text_schema", "created_at", "metadata", "status", "finished_at", "message_parts") SELECT "id", "chat_id", "role", "rich_text_schema", "created_at", "metadata", "status", "finished_at", "message_parts" FROM `ai_chat_messages`;--> statement-breakpoint
DROP TABLE `ai_chat_messages`;--> statement-breakpoint
ALTER TABLE `__new_ai_chat_messages` RENAME TO `ai_chat_messages`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
CREATE INDEX `idx_ai_chat_messages_chat_id_created_at` ON `ai_chat_messages` (`chat_id`,`created_at`);--> statement-breakpoint
CREATE INDEX `idx_ai_chat_messages_status` ON `ai_chat_messages` (`status`);--> statement-breakpoint
CREATE INDEX `idx_ai_chat_messages_chat_id_role` ON `ai_chat_messages` (`chat_id`,`role`);

View File

@ -0,0 +1,950 @@
{
"version": "6",
"dialect": "sqlite",
"id": "339611d0-cf1e-4ec9-9bf6-275608b815f5",
"prevId": "611bbe39-b8f0-4c81-a067-315ac9b0f8a3",
"tables": {
"ai_chat_messages": {
"name": "ai_chat_messages",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"chat_id": {
"name": "chat_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"rich_text_schema": {
"name": "rich_text_schema",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"metadata": {
"name": "metadata",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'completed'"
},
"finished_at": {
"name": "finished_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"message_parts": {
"name": "message_parts",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"idx_ai_chat_messages_chat_id_created_at": {
"name": "idx_ai_chat_messages_chat_id_created_at",
"columns": ["chat_id", "created_at"],
"isUnique": false
},
"idx_ai_chat_messages_status": {
"name": "idx_ai_chat_messages_status",
"columns": ["status"],
"isUnique": false
},
"idx_ai_chat_messages_chat_id_role": {
"name": "idx_ai_chat_messages_chat_id_role",
"columns": ["chat_id", "role"],
"isUnique": false
}
},
"foreignKeys": {
"ai_chat_messages_chat_id_ai_chat_sessions_id_fk": {
"name": "ai_chat_messages_chat_id_ai_chat_sessions_id_fk",
"tableFrom": "ai_chat_messages",
"tableTo": "ai_chat_sessions",
"columnsFrom": ["chat_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"ai_chat_sessions": {
"name": "ai_chat_sessions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
}
},
"indexes": {
"idx_ai_chat_sessions_updated_at": {
"name": "idx_ai_chat_sessions_updated_at",
"columns": ["updated_at"],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"collections": {
"name": "collections",
"columns": {
"feed_id": {
"name": "feed_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"entry_id": {
"name": "entry_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"view": {
"name": "view",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"entries": {
"name": "entries",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source_content": {
"name": "source_content",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"readability_updated_at": {
"name": "readability_updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"guid": {
"name": "guid",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"author": {
"name": "author",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"author_url": {
"name": "author_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"author_avatar": {
"name": "author_avatar",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"inserted_at": {
"name": "inserted_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"published_at": {
"name": "published_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"media": {
"name": "media",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"categories": {
"name": "categories",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"attachments": {
"name": "attachments",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"extra": {
"name": "extra",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"language": {
"name": "language",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"feed_id": {
"name": "feed_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"inbox_handle": {
"name": "inbox_handle",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"read": {
"name": "read",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"sources": {
"name": "sources",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"settings": {
"name": "settings",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"feeds": {
"name": "feeds",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"error_at": {
"name": "error_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"site_url": {
"name": "site_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"owner_user_id": {
"name": "owner_user_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"error_message": {
"name": "error_message",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"subscription_count": {
"name": "subscription_count",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updates_per_week": {
"name": "updates_per_week",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"latest_entry_published_at": {
"name": "latest_entry_published_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"tip_users": {
"name": "tip_users",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"published_at": {
"name": "published_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"images": {
"name": "images",
"columns": {
"url": {
"name": "url",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"colors": {
"name": "colors",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch() * 1000)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"inboxes": {
"name": "inboxes",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"secret": {
"name": "secret",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"lists": {
"name": "lists",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"feed_ids": {
"name": "feed_ids",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"view": {
"name": "view",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"fee": {
"name": "fee",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"owner_user_id": {
"name": "owner_user_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"subscription_count": {
"name": "subscription_count",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"purchase_amount": {
"name": "purchase_amount",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"subscriptions": {
"name": "subscriptions",
"columns": {
"feed_id": {
"name": "feed_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"list_id": {
"name": "list_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"inbox_id": {
"name": "inbox_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"view": {
"name": "view",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_private": {
"name": "is_private",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"hide_from_timeline": {
"name": "hide_from_timeline",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"summaries": {
"name": "summaries",
"columns": {
"entry_id": {
"name": "entry_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"summary": {
"name": "summary",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"readability_summary": {
"name": "readability_summary",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"language": {
"name": "language",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"unq": {
"name": "unq",
"columns": ["entry_id", "language"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"translations": {
"name": "translations",
"columns": {
"entry_id": {
"name": "entry_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"language": {
"name": "language",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"readability_content": {
"name": "readability_content",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"translation-unique-index": {
"name": "translation-unique-index",
"columns": ["entry_id", "language"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"unread": {
"name": "unread",
"columns": {
"subscription_id": {
"name": "subscription_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"count": {
"name": "count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"handle": {
"name": "handle",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_me": {
"name": "is_me",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email_verified": {
"name": "email_verified",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"bio": {
"name": "bio",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"website": {
"name": "website",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"social_links": {
"name": "social_links",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {
"\"ai_chat\"": "\"ai_chat_sessions\""
},
"columns": {
"\"ai_chat_messages\".\"room_id\"": "\"ai_chat_messages\".\"chat_id\"",
"\"ai_chat_sessions\".\"room_id\"": "\"ai_chat_sessions\".\"id\""
}
},
"internal": {
"indexes": {}
}
}

View File

@ -232,6 +232,13 @@
"when": 1753240775348,
"tag": "0032_orange_prima",
"breakpoints": true
},
{
"idx": 33,
"version": "6",
"when": 1753890574250,
"tag": "0033_shiny_sebastian_shaw",
"breakpoints": true
}
]
}

View File

@ -33,6 +33,7 @@ import m0029 from "./0029_flaky_gorgon.sql"
import m0030 from "./0030_common_gabe_jones.sql"
import m0031 from "./0031_kind_ikaris.sql"
import m0032 from "./0032_orange_prima.sql"
import m0033 from "./0033_shiny_sebastian_shaw.sql"
import journal from "./meta/_journal.json"
export default {
@ -71,5 +72,6 @@ export default {
m0030,
m0031,
m0032,
m0033,
},
}

View File

@ -1,9 +1,8 @@
import type { FeedViewType } from "@follow/constants"
import type { SupportedActionLanguage } from "@follow/shared/language"
import type { EntrySettings } from "@follow-app/client-sdk"
import type { UIMessage } from "ai"
import { sql } from "drizzle-orm"
import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"
import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"
import type { AttachmentsModel, ExtraModel, ImageColorsResult, MediaModel } from "./types"
@ -156,28 +155,106 @@ export const imagesTable = sqliteTable("images", (t) => ({
.default(sql`(unixepoch() * 1000)`),
}))
export const aiChatTable = sqliteTable("ai_chat", (t) => ({
roomId: t.text("room_id").notNull().primaryKey(),
title: t.text("title"),
createdAt: t
.integer("created_at", { mode: "timestamp_ms" })
.notNull()
.default(sql`(unixepoch() * 1000)`),
}))
export const aiChatMessagesTable = sqliteTable(
"ai_chat_messages",
// AI Chat Sessions Table
export const aiChatTable = sqliteTable(
"ai_chat_sessions",
(t) => ({
roomId: t
.text("room_id")
.notNull()
.references(() => aiChatTable.roomId),
id: t.text("id").notNull().primaryKey(),
chatId: t.text("id").notNull().primaryKey(),
title: t.text("title"),
createdAt: t
.integer("created_at", { mode: "timestamp_ms" })
.notNull()
.default(sql`(unixepoch() * 1000)`),
message: t.text("message", { mode: "json" }).$type<UIMessage<any, any, any>>().notNull(),
updatedAt: t
.integer("updated_at", { mode: "timestamp_ms" })
.notNull()
.default(sql`(unixepoch() * 1000)`),
}),
(table) => ({
updatedAtIdx: index("idx_ai_chat_sessions_updated_at").on(table.updatedAt),
}),
)
// Message Part types based on Vercel AI SDK UIMessage parts
interface TextUIPart {
type: "text"
text: string
}
interface ReasoningUIPart {
type: "reasoning"
reasoning: string
}
interface ToolInvocationUIPart {
type: "tool-invocation"
toolInvocation: {
state: "partial-call" | "call" | "result"
toolCallId: string
toolName: string
args: any
result?: any
}
}
interface SourceUIPart {
type: "source"
source: {
sourceType: "url"
id: string
url: string
title?: string
}
}
interface StepStartUIPart {
type: "step-start"
}
type UIMessagePart =
| TextUIPart
| ReasoningUIPart
| ToolInvocationUIPart
| SourceUIPart
| StepStartUIPart
// AI Chat Messages Table - Rich text support
export const aiChatMessagesTable = sqliteTable(
"ai_chat_messages",
(t) => ({
id: t.text("id").notNull().primaryKey(),
chatId: t
.text("chat_id")
.notNull()
.references(() => aiChatTable.chatId, { onDelete: "cascade" }),
// Core message properties matching Vercel AI SDK UIMessage
role: t.text("role").notNull().$type<"user" | "assistant" | "system">(),
richTextSchema: t
.text("rich_text_schema", { mode: "json" })
.$type<import("lexical").SerializedEditorState>(), // Lexical schema for user rich text
// Vercel AI SDK UIMessage properties
createdAt: t.integer("created_at", { mode: "timestamp_ms" }),
metadata: t.text("metadata", { mode: "json" }).$type<any>(),
// Message processing status
status: t
.text("status")
.$type<"pending" | "streaming" | "completed" | "error">()
.default("completed"),
finishedAt: t.integer("finished_at", { mode: "timestamp_ms" }),
// Store UIMessage parts for complex assistant responses (tools, reasoning, etc)
messageParts: t.text("message_parts", { mode: "json" }).$type<UIMessagePart[]>(),
}),
(table) => ({
chatIdCreatedAtIdx: index("idx_ai_chat_messages_chat_id_created_at").on(
table.chatId,
table.createdAt,
),
statusIdx: index("idx_ai_chat_messages_status").on(table.status),
chatIdRoleIdx: index("idx_ai_chat_messages_chat_id_role").on(table.chatId, table.role),
}),
(t) => [uniqueIndex("ai_chat_messages_unq").on(t.roomId, t.id)],
)

View File

@ -107,7 +107,6 @@ export class OpenPanel {
return
}
// eslint-disable-next-line unicorn/no-array-callback-reference
if (this.options.filter && !this.options.filter(payload)) {
return
}

View File

@ -495,6 +495,12 @@ importers:
'@hookform/resolvers':
specifier: 5.1.1
version: 5.1.1(react-hook-form@7.60.0(react@19.0.0))
'@lexical/markdown':
specifier: 0.33.1
version: 0.33.1
'@lexical/react':
specifier: 0.33.1
version: 0.33.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(yjs@13.6.27)
'@lottiefiles/dotlottie-react':
specifier: 0.14.2
version: 0.14.2(react@19.0.0)
@ -630,6 +636,9 @@ importers:
lethargy:
specifier: 1.0.9
version: 1.0.9
lexical:
specifier: 0.33.1
version: 0.33.1
masonic:
specifier: 4.1.0
version: 4.1.0(react@19.0.0)
@ -4024,21 +4033,42 @@ packages:
'@floating-ui/core@1.7.0':
resolution: {integrity: sha512-FRdBLykrPPA6P76GGGqlex/e7fbe0F1ykgxHYNXQsH/iTEtjMj/f9bpY5oQqbjt5VgZvgz/uKXbGuROijh3VLA==}
'@floating-ui/core@1.7.2':
resolution: {integrity: sha512-wNB5ooIKHQc+Kui96jE/n69rHFWAVoxn5CAzL1Xdd8FG03cgY3MLO+GF9U3W737fYDSgPWA6MReKhBQBop6Pcw==}
'@floating-ui/dom@1.7.0':
resolution: {integrity: sha512-lGTor4VlXcesUMh1cupTUTDoCxMb0V6bm3CnxHzQcw8Eaf1jQbgQX4i02fYgT0vJ82tb5MZ4CZk1LRGkktJCzg==}
'@floating-ui/dom@1.7.2':
resolution: {integrity: sha512-7cfaOQuCS27HD7DX+6ib2OrnW+b4ZBwDNnCcT0uTyidcmyWb03FnQqJybDBoCnpdxwBSfA94UAYlRCt7mV+TbA==}
'@floating-ui/react-dom@2.1.2':
resolution: {integrity: sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==}
peerDependencies:
react: 19.0.0
react-dom: 19.0.0
'@floating-ui/react-dom@2.1.4':
resolution: {integrity: sha512-JbbpPhp38UmXDDAu60RJmbeme37Jbgsm7NrHGgzYYFKmblzRUh6Pa641dII6LsjwF4XlScDrde2UAzDo/b9KPw==}
peerDependencies:
react: 19.0.0
react-dom: 19.0.0
'@floating-ui/react@0.26.28':
resolution: {integrity: sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==}
peerDependencies:
react: 19.0.0
react-dom: 19.0.0
'@floating-ui/react@0.27.14':
resolution: {integrity: sha512-aSf9JXfyXpRQWMbtuW+CJQrnhzHu4Hg1Th9AkvR1o+wSW/vCUVMrtgXaRY5ToV5Fh5w3I7lXJdvlKVvYrQrppw==}
peerDependencies:
react: 19.0.0
react-dom: 19.0.0
'@floating-ui/utils@0.2.10':
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
'@floating-ui/utils@0.2.9':
resolution: {integrity: sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==}
@ -4420,6 +4450,77 @@ packages:
'@levischuck/tiny-cbor@0.2.11':
resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==}
'@lexical/clipboard@0.33.1':
resolution: {integrity: sha512-Qd3/Cm3TW2DFQv58kMtLi86u5YOgpBdf+o7ySbXz55C613SLACsYQBB3X5Vu5hTx/t/ugYOpII4HkiatW6d9zA==}
'@lexical/code@0.33.1':
resolution: {integrity: sha512-E0Y/+1znkqVpP52Y6blXGAduoZek9SSehJN+vbH+4iQKyFwTA7JB+jd5C5/K0ik55du9X7SN/oTynByg7lbcAA==}
'@lexical/devtools-core@0.33.1':
resolution: {integrity: sha512-3yHu5diNtjwhoe2q/x9as6n6rIfA+QO2CfaVjFRkam8rkAW6zUzQT1D0fQdE8nOfWvXBgY1mH/ZLP4dDXBdG5Q==}
peerDependencies:
react: 19.0.0
react-dom: 19.0.0
'@lexical/dragon@0.33.1':
resolution: {integrity: sha512-UQ6DLkcDAr83wA1vz3sUgtcpYcMifC4sF0MieZAoMzFrna6Ekqj7OJ7g8Lo7m7AeuT4NETRVDsjIEDdrQMKLLA==}
'@lexical/hashtag@0.33.1':
resolution: {integrity: sha512-M3IsDe4cifggMBZgYAVT7hCLWcwQ3dIcUPdr9Xc6wDQQQdEqOQYB0PO//9bSYUVq+BNiiTgysc+TtlM7PiJfiw==}
'@lexical/history@0.33.1':
resolution: {integrity: sha512-Bk0h3D6cFkJ7w3HKvqQua7n6Xfz7nR7L3gLDBH9L0nsS4MM9+LteSEZPUe0kj4VuEjnxufYstTc9HA2aNLKxnQ==}
'@lexical/html@0.33.1':
resolution: {integrity: sha512-t14vu4eKa6BWz1N7/rwXgXif1k4dj73dRvllWJgfXum+a36vn1aySNYOlOfqWXF7k1b3uJmoqsWK7n/1ASnimw==}
'@lexical/link@0.33.1':
resolution: {integrity: sha512-JCTu7Fft2J2kgfqJiWnGei+UMIXVKiZKaXzuHCuGQTFu92DeCyd02azBaFazZHEkSqCIFZ0DqVV2SpIJmd0Ygw==}
'@lexical/list@0.33.1':
resolution: {integrity: sha512-PXp56dWADSThc9WhwWV4vXhUc3sdtCqsfPD3UQNGUZ9rsAY1479rqYLtfYgEmYPc8JWXikQCAKEejahCJIm8OQ==}
'@lexical/mark@0.33.1':
resolution: {integrity: sha512-tGdOf1e694lnm/HyWUKEkEWjDyfhCBFG7u8iRKNpsYTpB3M1FsJUXbphE2bb8MyWfhHbaNxnklupSSaSPzO88A==}
'@lexical/markdown@0.33.1':
resolution: {integrity: sha512-p5zwWNF70pELRx60wxE8YOFVNiNDkw7gjKoYqkED23q5hj4mcqco9fQf6qeeZChjxLKjfyT6F1PpWgxmlBlxBw==}
'@lexical/offset@0.33.1':
resolution: {integrity: sha512-3YIlUs43QdKSBLEfOkuciE2tn9loxVmkSs/HgaIiLYl0Edf1W00FP4ItSmYU4De5GopXsHq6+Y3ry4pU/ciUiQ==}
'@lexical/overflow@0.33.1':
resolution: {integrity: sha512-3BDq1lOw567FeCk4rN2ellKwoXTM9zGkGuKnSGlXS1JmtGGGSvT+uTANX3KOOfqTNSrOkrwoM+3hlFv7p6VpiQ==}
'@lexical/plain-text@0.33.1':
resolution: {integrity: sha512-2HxdhAx6bwF8y5A9P0q3YHsYbhUo4XXm+GyKJO87an8JClL2W+GYLTSDbfNWTh4TtH95eG+UYLOjNEgyU6tsWA==}
'@lexical/react@0.33.1':
resolution: {integrity: sha512-ylnUmom5h8PY+Z14uDmKLQEoikTPN77GRM0NRCIdtbWmOQqOq/5BhuCzMZE1WvpL5C6n3GtK6IFnsMcsKmVOcw==}
peerDependencies:
react: 19.0.0
react-dom: 19.0.0
'@lexical/rich-text@0.33.1':
resolution: {integrity: sha512-ZBIsj4LwmamRBCGjJiPSLj7N/XkUDv/pnYn5Rp0BL42WpOiQLvOoGLrZxgUJZEmRPQnx42ZgLKVgrWHsyjuoAA==}
'@lexical/selection@0.33.1':
resolution: {integrity: sha512-KXPkdCDdVfIUXmkwePu9DAd3kLjL0aAqL5G9CMCFsj7RG9lLvvKk7kpivrAIbRbcsDzO44QwsFPisZHbX4ioXA==}
'@lexical/table@0.33.1':
resolution: {integrity: sha512-pzB11i1Y6fzmy0IPUKJyCdhVBgXaNOxJUxrQJWdKNYCh1eMwwMEQvj+8inItd/11aUkjcdHjwDTht8gL2UHKiQ==}
'@lexical/text@0.33.1':
resolution: {integrity: sha512-CnyU3q3RytXXWVSvC5StOKISzFAPGK9MuesNDDGyZk7yDK+J98gV6df4RBKfqwcokFMThpkUlvMeKe1+S2y25A==}
'@lexical/utils@0.33.1':
resolution: {integrity: sha512-eKysPjzEE9zD+2af3WRX5U3XbeNk0z4uv1nXGH3RG15uJ4Huzjht82hzsQpCFUobKmzYlQaQs5y2IYKE2puipQ==}
'@lexical/yjs@0.33.1':
resolution: {integrity: sha512-Zx1rabMm/Zjk7n7YQMIQLUN+tqzcg1xqcgNpEHSfK1GA8QMPXCPvXWFT3ZDC4tfZOSy/YIqpVUyWZAomFqRa+g==}
peerDependencies:
yjs: '>=13.5.22'
'@lottiefiles/dotlottie-react@0.14.2':
resolution: {integrity: sha512-RR4r0HrKQbOAw6iS6C3mRARS2iu+yI+G1vICoUsRMHzlUUk1/26l3WyAjhcG+KoaGoKmORx8FgHjTNr4Sr/2Ug==}
peerDependencies:
@ -11027,6 +11128,9 @@ packages:
peerDependencies:
ws: '*'
isomorphic.js@0.2.5:
resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==}
istanbul-lib-coverage@3.2.2:
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
engines: {node: '>=8'}
@ -11308,6 +11412,14 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
lexical@0.33.1:
resolution: {integrity: sha512-+kiCS/GshQmCs/meMb8MQT4AMvw3S3Ef0lSCv2Xi6Itvs59OD+NjQWNfYkDteIbKtVE/w0Yiqh56VyGwIb8UcA==}
lib0@0.2.114:
resolution: {integrity: sha512-gcxmNFzA4hv8UYi8j43uPlQ7CGcyMJ2KQb5kZASw6SnAKAf10hK12i2fjrS3Cl/ugZa5Ui6WwIu1/6MIXiHttQ==}
engines: {node: '>=16'}
hasBin: true
lie@3.3.0:
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
@ -13214,6 +13326,10 @@ packages:
resolution: {integrity: sha512-973driJZvxiGOQ5ONsFhOF/DtzPMOMtgC11kCpUrPGMTgqp2q/1gwzCquocrN33is0VZ5GFHXZYMM9l6h67v2Q==}
engines: {node: '>=10'}
prismjs@1.30.0:
resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==}
engines: {node: '>=6'}
proc-log@2.0.1:
resolution: {integrity: sha512-Kcmo2FhfDTXdcbfDH76N7uBYHINxc/8GW7UAVuVP9I+Va3uHSerrnKV6dLooga/gh7GlgzuCCr/eoldnL1muGw==}
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
@ -13404,6 +13520,12 @@ packages:
peerDependencies:
react: 19.0.0
react-error-boundary@3.1.4:
resolution: {integrity: sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==}
engines: {node: '>=10', npm: '>=6'}
peerDependencies:
react: 19.0.0
react-error-boundary@6.0.0:
resolution: {integrity: sha512-gdlJjD7NWr0IfkPlaREN2d9uUZUlksrfOx7SX62VRerwXbMY6ftGCIZua1VG1aXFNOimhISsTq+Owp725b9SiA==}
peerDependencies:
@ -15964,6 +16086,10 @@ packages:
yauzl@2.10.0:
resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==}
yjs@13.6.27:
resolution: {integrity: sha512-OIDwaflOaq4wC6YlPBy2L6ceKeKuF7DeTxx+jPzv1FHn9tCZ0ZwSRnUBxD05E3yed46fv/FWJbvR+Ud7x0L7zw==}
engines: {node: '>=16.0.0', npm: '>=8.0.0'}
yn@3.1.1:
resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==}
engines: {node: '>=6'}
@ -19160,12 +19286,21 @@ snapshots:
'@floating-ui/core@1.7.0':
dependencies:
'@floating-ui/utils': 0.2.9
'@floating-ui/utils': 0.2.10
'@floating-ui/core@1.7.2':
dependencies:
'@floating-ui/utils': 0.2.10
'@floating-ui/dom@1.7.0':
dependencies:
'@floating-ui/core': 1.7.0
'@floating-ui/utils': 0.2.9
'@floating-ui/utils': 0.2.10
'@floating-ui/dom@1.7.2':
dependencies:
'@floating-ui/core': 1.7.2
'@floating-ui/utils': 0.2.10
'@floating-ui/react-dom@2.1.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
@ -19173,6 +19308,12 @@ snapshots:
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
'@floating-ui/react-dom@2.1.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@floating-ui/dom': 1.7.2
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
'@floating-ui/react@0.26.28(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@floating-ui/react-dom': 2.1.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
@ -19181,6 +19322,16 @@ snapshots:
react-dom: 19.0.0(react@19.0.0)
tabbable: 6.2.0
'@floating-ui/react@0.27.14(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@floating-ui/react-dom': 2.1.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@floating-ui/utils': 0.2.10
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
tabbable: 6.2.0
'@floating-ui/utils@0.2.10': {}
'@floating-ui/utils@0.2.9': {}
'@follow-app/client-sdk@0.3.25(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)(pg@8.16.3)':
@ -19799,6 +19950,152 @@ snapshots:
'@levischuck/tiny-cbor@0.2.11': {}
'@lexical/clipboard@0.33.1':
dependencies:
'@lexical/html': 0.33.1
'@lexical/list': 0.33.1
'@lexical/selection': 0.33.1
'@lexical/utils': 0.33.1
lexical: 0.33.1
'@lexical/code@0.33.1':
dependencies:
'@lexical/utils': 0.33.1
lexical: 0.33.1
prismjs: 1.30.0
'@lexical/devtools-core@0.33.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@lexical/html': 0.33.1
'@lexical/link': 0.33.1
'@lexical/mark': 0.33.1
'@lexical/table': 0.33.1
'@lexical/utils': 0.33.1
lexical: 0.33.1
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
'@lexical/dragon@0.33.1':
dependencies:
lexical: 0.33.1
'@lexical/hashtag@0.33.1':
dependencies:
'@lexical/utils': 0.33.1
lexical: 0.33.1
'@lexical/history@0.33.1':
dependencies:
'@lexical/utils': 0.33.1
lexical: 0.33.1
'@lexical/html@0.33.1':
dependencies:
'@lexical/selection': 0.33.1
'@lexical/utils': 0.33.1
lexical: 0.33.1
'@lexical/link@0.33.1':
dependencies:
'@lexical/utils': 0.33.1
lexical: 0.33.1
'@lexical/list@0.33.1':
dependencies:
'@lexical/selection': 0.33.1
'@lexical/utils': 0.33.1
lexical: 0.33.1
'@lexical/mark@0.33.1':
dependencies:
'@lexical/utils': 0.33.1
lexical: 0.33.1
'@lexical/markdown@0.33.1':
dependencies:
'@lexical/code': 0.33.1
'@lexical/link': 0.33.1
'@lexical/list': 0.33.1
'@lexical/rich-text': 0.33.1
'@lexical/text': 0.33.1
'@lexical/utils': 0.33.1
lexical: 0.33.1
'@lexical/offset@0.33.1':
dependencies:
lexical: 0.33.1
'@lexical/overflow@0.33.1':
dependencies:
lexical: 0.33.1
'@lexical/plain-text@0.33.1':
dependencies:
'@lexical/clipboard': 0.33.1
'@lexical/selection': 0.33.1
'@lexical/utils': 0.33.1
lexical: 0.33.1
'@lexical/react@0.33.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(yjs@13.6.27)':
dependencies:
'@floating-ui/react': 0.27.14(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@lexical/devtools-core': 0.33.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@lexical/dragon': 0.33.1
'@lexical/hashtag': 0.33.1
'@lexical/history': 0.33.1
'@lexical/link': 0.33.1
'@lexical/list': 0.33.1
'@lexical/mark': 0.33.1
'@lexical/markdown': 0.33.1
'@lexical/overflow': 0.33.1
'@lexical/plain-text': 0.33.1
'@lexical/rich-text': 0.33.1
'@lexical/table': 0.33.1
'@lexical/text': 0.33.1
'@lexical/utils': 0.33.1
'@lexical/yjs': 0.33.1(yjs@13.6.27)
lexical: 0.33.1
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
react-error-boundary: 3.1.4(react@19.0.0)
transitivePeerDependencies:
- yjs
'@lexical/rich-text@0.33.1':
dependencies:
'@lexical/clipboard': 0.33.1
'@lexical/selection': 0.33.1
'@lexical/utils': 0.33.1
lexical: 0.33.1
'@lexical/selection@0.33.1':
dependencies:
lexical: 0.33.1
'@lexical/table@0.33.1':
dependencies:
'@lexical/clipboard': 0.33.1
'@lexical/utils': 0.33.1
lexical: 0.33.1
'@lexical/text@0.33.1':
dependencies:
lexical: 0.33.1
'@lexical/utils@0.33.1':
dependencies:
'@lexical/list': 0.33.1
'@lexical/selection': 0.33.1
'@lexical/table': 0.33.1
lexical: 0.33.1
'@lexical/yjs@0.33.1(yjs@13.6.27)':
dependencies:
'@lexical/offset': 0.33.1
'@lexical/selection': 0.33.1
lexical: 0.33.1
yjs: 13.6.27
'@lottiefiles/dotlottie-react@0.14.2(react@19.0.0)':
dependencies:
'@lottiefiles/dotlottie-web': 0.47.0
@ -27776,6 +28073,8 @@ snapshots:
dependencies:
ws: 8.18.2(bufferutil@4.0.9)(utf-8-validate@6.0.5)
isomorphic.js@0.2.5: {}
istanbul-lib-coverage@3.2.2: {}
istanbul-lib-instrument@5.2.1:
@ -28095,6 +28394,12 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
lexical@0.33.1: {}
lib0@0.2.114:
dependencies:
isomorphic.js: 0.2.5
lie@3.3.0:
dependencies:
immediate: 3.0.6
@ -30235,6 +30540,8 @@ snapshots:
dependencies:
parse-ms: 2.1.0
prismjs@1.30.0: {}
proc-log@2.0.1: {}
proc-log@4.2.0: {}
@ -30440,6 +30747,11 @@ snapshots:
react: 19.0.0
scheduler: 0.25.0
react-error-boundary@3.1.4(react@19.0.0):
dependencies:
'@babel/runtime': 7.27.6
react: 19.0.0
react-error-boundary@6.0.0(react@19.0.0):
dependencies:
'@babel/runtime': 7.27.6
@ -33381,6 +33693,10 @@ snapshots:
buffer-crc32: 0.2.13
fd-slicer: 1.1.0
yjs@13.6.27:
dependencies:
lib0: 0.2.114
yn@3.1.1: {}
yocto-queue@0.1.0: {}