refactor: ai module and implement rich text (#4282)
This commit is contained in:
parent
5d0ae8fd1c
commit
4f78a8aa48
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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!)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
export {
|
||||
type BlockSlice as ContextSlice,
|
||||
createBlockSlice as createContextSlice,
|
||||
} from "./block.slice"
|
||||
export { type ChatSlice, type ChatStatus, createChatSlice } from "./chat.slice"
|
||||
|
|
@ -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()
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
})
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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],
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
@ -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">
|
||||
|
|
@ -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} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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" />
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
export interface ChatSession {
|
||||
roomId: string
|
||||
chatId: string
|
||||
title?: string
|
||||
createdAt: Date
|
||||
messageCount: number
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -236,8 +236,6 @@ const EntryContentImpl: Component<EntryContentProps> = ({
|
|||
</EntryScrollArea>
|
||||
<SourceContentPanel src={safeUrl ?? "#"} />
|
||||
</Focusable>
|
||||
|
||||
{/* <React.Suspense>{!isInPeekModal && <AISmartSidebar entryId={entryId} />}</React.Suspense> */}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
})
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
|
|
@ -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": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
320
pnpm-lock.yaml
320
pnpm-lock.yaml
|
|
@ -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: {}
|
||||
|
|
|
|||
Loading…
Reference in New Issue