From b1517c8239d186730b92fdc5065fec42516c34f6 Mon Sep 17 00:00:00 2001 From: Marc McIntosh Date: Tue, 18 Feb 2025 17:34:14 +0100 Subject: [PATCH 01/21] wip(chat db): get thread from the lsp. --- refact-agent/gui/src/app/store.ts | 2 + .../gui/src/components/Sidebar/Sidebar.tsx | 2 + .../gui/src/features/ChatDB/chatDbSlice.ts | 53 +++++ .../gui/src/services/refact/chatdb.ts | 216 ++++++++++++++++++ .../gui/src/services/refact/consts.ts | 2 + refact-agent/gui/src/services/refact/index.ts | 1 + refact-agent/gui/src/services/refact/types.ts | 154 +++++++++++++ 7 files changed, 430 insertions(+) create mode 100644 refact-agent/gui/src/features/ChatDB/chatDbSlice.ts create mode 100644 refact-agent/gui/src/services/refact/chatdb.ts diff --git a/refact-agent/gui/src/app/store.ts b/refact-agent/gui/src/app/store.ts index 49b48ee80..8890c9a3b 100644 --- a/refact-agent/gui/src/app/store.ts +++ b/refact-agent/gui/src/app/store.ts @@ -52,6 +52,7 @@ import { knowledgeSlice } from "../features/Knowledge/knowledgeSlice"; import { checkpointsSlice } from "../features/Checkpoints/checkpointsSlice"; import { checkpointsApi } from "../services/refact/checkpoints"; import { patchesAndDiffsTrackerSlice } from "../features/PatchesAndDiffsTracker/patchesAndDiffsTrackerSlice"; +import { chatDbSlice } from "../features/ChatDB/chatDbSlice"; const tipOfTheDayPersistConfig = { key: "totd", @@ -115,6 +116,7 @@ const rootReducer = combineSlices( knowledgeSlice, checkpointsSlice, patchesAndDiffsTrackerSlice, + chatDbSlice, ); const rootPersistConfig = { diff --git a/refact-agent/gui/src/components/Sidebar/Sidebar.tsx b/refact-agent/gui/src/components/Sidebar/Sidebar.tsx index 6e871a9ee..ac5f000c5 100644 --- a/refact-agent/gui/src/components/Sidebar/Sidebar.tsx +++ b/refact-agent/gui/src/components/Sidebar/Sidebar.tsx @@ -10,6 +10,7 @@ import { import { push } from "../../features/Pages/pagesSlice"; import { restoreChat } from "../../features/Chat/Thread"; import { FeatureMenu } from "../../features/Config/FeatureMenu"; +import { subscribeToThreadsThunk } from "../../services/refact/chatdb"; export type SidebarProps = { takingNotes: boolean; @@ -27,6 +28,7 @@ export type SidebarProps = { export const Sidebar: React.FC = ({ takingNotes, style }) => { // TODO: these can be lowered. const dispatch = useAppDispatch(); + void dispatch(subscribeToThreadsThunk()); const history = useAppSelector((app) => app.history, { // TODO: selector issue here devModeChecks: { stabilityCheck: "never" }, diff --git a/refact-agent/gui/src/features/ChatDB/chatDbSlice.ts b/refact-agent/gui/src/features/ChatDB/chatDbSlice.ts new file mode 100644 index 000000000..a4e4501ad --- /dev/null +++ b/refact-agent/gui/src/features/ChatDB/chatDbSlice.ts @@ -0,0 +1,53 @@ +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; +import { CThread } from "../../services/refact"; +import { reset } from "../FIM"; +import { setError } from "../Errors/errorsSlice"; +import { getChatById } from "../History/historySlice"; + +export type ChatDbState = { + loading: boolean; + error: string | null; + chats: Record; +}; + +const initialState: ChatDbState = { + loading: false, + error: null, + chats: {}, +}; + +export const chatDbSlice = createSlice({ + name: "chatDb", + initialState, + reducers: { + reset: () => initialState, + setLoading: (state, action: PayloadAction) => { + state.loading = action.payload; + }, + setError: (state, action: PayloadAction) => { + state.error = action.payload; + }, + startLoading: (state) => { + state.loading = true; + state.error = null; + state.chats = {}; + }, + updateCThread: (state, action: PayloadAction) => { + state.chats[action.payload.cthread_id] = action.payload; + }, + deleteCThread: (state, action: PayloadAction) => { + if (action.payload in state.chats) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete state.chats[action.payload]; + } + }, + }, + selectors: { + getChats: (state) => state.chats, + getLoading: (state) => state.loading, + getError: (state) => state.error, + }, +}); + +export const chatDbActions = chatDbSlice.actions; +export const chatDbSelectors = chatDbSlice.selectors; diff --git a/refact-agent/gui/src/services/refact/chatdb.ts b/refact-agent/gui/src/services/refact/chatdb.ts new file mode 100644 index 000000000..c1b35115d --- /dev/null +++ b/refact-agent/gui/src/services/refact/chatdb.ts @@ -0,0 +1,216 @@ +import { createAsyncThunk } from "@reduxjs/toolkit/react"; +import { AppDispatch, RootState } from "../../app/store"; +import { CHAT_DB_THREADS_SUB } from "./consts"; +import { consumeStream } from "../../features/Chat/Thread/utils"; +import { + isCThreadSubResponseUpdate, + isCThreadSubResponseDelete, +} from "./types"; +import { chatDbActions } from "../../features/ChatDB/chatDbSlice"; + +const createAppAsyncThunk = createAsyncThunk.withTypes<{ + state: RootState; + dispatch: AppDispatch; +}>(); + +export type SubscribeToThreadArgs = + | { + quick_search?: string; + limit?: number; + } + | undefined; +function subscribeToThreads( + args: SubscribeToThreadArgs = {}, + port = 8001, + apiKey?: string | null, + abortSignal?: AbortSignal, +): Promise { + const url = `http://127.0.0.1:${port}${CHAT_DB_THREADS_SUB}`; + const headers = new Headers(); + headers.append("Content-Type", "application/json"); + if (apiKey) { + headers.append("Authorization", `Bearer ${apiKey}`); + } + + return fetch(url, { + method: "POST", + headers, + redirect: "follow", + cache: "no-cache", + body: JSON.stringify(args), + signal: abortSignal, + }); +} + +// type CThreadSubResponse = CThreadSubResponseUpdate | CThreadSubResponseDelete; +// function isCThreadSubResponseChunk(value: unknown): value is CThreadSubResponse { +// if (isCThreadSubResponseUpdate(value)) return true; +// if (isCThreadSubResponseDelete(value)) return true; +// return false; +// } + +export const subscribeToThreadsThunk = createAppAsyncThunk< + unknown, + SubscribeToThreadArgs +>("chatdbApi/subscribeToThreads", (args, thunkApi) => { + const state = thunkApi.getState() as unknown as RootState; + const port = state.config.lspPort; + const apiKey = state.config.apiKey; + return subscribeToThreads(args, port, apiKey, thunkApi.signal) + .then((response) => { + if (!response.ok) { + throw new Error(response.statusText); + } + const reader = response.body?.getReader(); + if (!reader) return; + + const onAbort = () => { + // console.log("knowledge stream aborted"); + }; + + const onChunk = (chunk: unknown) => { + if (isCThreadSubResponseUpdate(chunk)) { + const action = chatDbActions.updateCThread(chunk.cthread_rec); + thunkApi.dispatch(action); + // dispatch update + } else if (isCThreadSubResponseDelete(chunk)) { + const action = chatDbActions.deleteCThread(chunk.cthread_id); + thunkApi.dispatch(action); + // dispatch delete + } else { + console.log("unknown thread chunk", chunk); + } + }; + + return consumeStream(reader, thunkApi.signal, onAbort, onChunk); + }) + .catch((err) => { + // eslint-disable-next-line no-console + console.error("Error in chat thread subscription", err); + }); +}); + +// Types for the API + +// export interface CMessage { +// cmessage_belongs_to_cthread_id: string; +// cmessage_alt: number; +// cmessage_num: number; +// cmessage_prev_alt: number; +// cmessage_usage_model: string; +// cmessage_usage_prompt: number; +// cmessage_usage_completion: number; +// cmessage_json: string; +// } + +// export interface Chore { +// chore_id: string; +// chore_title: string; +// chore_spontaneous_work_enable: boolean; +// chore_created_ts: number; +// chore_archived_ts: number; +// } + +// export interface ChoreEvent { +// chore_event_id: string; +// chore_event_belongs_to_chore_id: string; +// chore_event_summary: string; +// chore_event_ts: number; +// chore_event_link: string; +// chore_event_cthread_id: string | null; +// } + +// // Request types +// export interface CThreadSubscription { +// quicksearch?: string; +// limit?: number; +// } + +// export interface CMessagesSubscription { +// cmessage_belongs_to_cthread_id: string; +// } + +// API definition +// export const chatDbApi = createApi({ +// reducerPath: "chatdbApi", +// baseQuery: fetchBaseQuery({ +// prepareHeaders: (headers, { getState }) => { +// const token = (getState() as RootState).config.apiKey; +// if (token) { +// headers.set("Authorization", `Bearer ${token}`); +// } +// return headers; +// }, +// }), +// endpoints: (builder) => ({ +// // Threads +// subscribeCThreads: builder.mutation({ +// query: (subscription) => ({ +// url: "/cthreads-sub", +// method: "POST", +// body: subscription, +// }), +// }), +// updateCThread: builder.mutation< +// { status: string; cthread: CThread }, +// Partial +// >({ +// query: (thread) => ({ +// url: "/cthread-update", +// method: "POST", +// body: thread, +// }), +// }), + +// // Messages +// subscribeCMessages: builder.mutation({ +// query: (subscription) => ({ +// url: "/cmessages-sub", +// method: "POST", +// body: subscription, +// }), +// }), +// updateCMessages: builder.mutation<{ status: string }, CMessage[]>({ +// query: (messages) => ({ +// url: "/cmessages-update", +// method: "POST", +// body: messages, +// }), +// }), + +// // Chores +// subscribeChores: builder.mutation({ +// query: () => ({ +// url: "/chores-sub", +// method: "POST", +// }), +// }), +// updateChore: builder.mutation<{ status: string }, Partial>({ +// query: (chore) => ({ +// url: "/chore-update", +// method: "POST", +// body: chore, +// }), +// }), +// updateChoreEvent: builder.mutation<{ status: string }, Partial>( +// { +// query: (event) => ({ +// url: "/chore-event-update", +// method: "POST", +// body: event, +// }), +// }, +// ), +// }), +// }); + +// // Export hooks for usage in components +// export const { +// useSubscribeCThreadsMutation, +// useUpdateCThreadMutation, +// useSubscribeCMessagesMutation, +// useUpdateCMessagesMutation, +// useSubscribeChoresMutation, +// useUpdateChoreMutation, +// useUpdateChoreEventMutation, +// } = chatDbApi; diff --git a/refact-agent/gui/src/services/refact/consts.ts b/refact-agent/gui/src/services/refact/consts.ts index dedc0afa8..8048358a2 100644 --- a/refact-agent/gui/src/services/refact/consts.ts +++ b/refact-agent/gui/src/services/refact/consts.ts @@ -40,3 +40,5 @@ export const KNOWLEDGE_REMOVE_URL = "/v1/mem-erase"; export const KNOWLEDGE_UPDATE_USED_URL = "/v1/mem-update-used"; export const KNOWLEDGE_UPDATE_URL = "/v1/mem-upd"; export const KNOWLEDGE_CREATE_URL = "/v1/trajectory-save"; +// Chatdblinks +export const CHAT_DB_THREADS_SUB = "/db_v1/cthreads-sub"; diff --git a/refact-agent/gui/src/services/refact/index.ts b/refact-agent/gui/src/services/refact/index.ts index 6153995d8..0f573cedc 100644 --- a/refact-agent/gui/src/services/refact/index.ts +++ b/refact-agent/gui/src/services/refact/index.ts @@ -13,3 +13,4 @@ export * from "./integrations"; export * from "./docker"; export * from "./telemetry"; export * from "./knowledge"; +export * from "./chatdb"; diff --git a/refact-agent/gui/src/services/refact/types.ts b/refact-agent/gui/src/services/refact/types.ts index f53cbf0b3..0b1c8809b 100644 --- a/refact-agent/gui/src/services/refact/types.ts +++ b/refact-agent/gui/src/services/refact/types.ts @@ -671,3 +671,157 @@ export function isMCPEnvironmentsDict(json: unknown): json is MCPEnvs { return Object.values(json).every((value) => typeof value === "string"); } + +// ChatDB + +export type CThread = { + cthread_id: string; + cthread_belongs_to_chore_event_id: string | null; + cthread_title: string; + cthread_toolset: string; + cthread_model: string; + cthread_temperature: number; + cthread_n_ctx: number; + cthread_max_new_tokens: number; + cthread_n: number; + cthread_error: string; + cthread_anything_new: boolean; + cthread_created_ts: number; + cthread_updated_ts: number; + cthread_archived_ts: number; + cthread_locked_by: string; + cthread_locked_ts: number; +}; +export function isCThread(value: unknown): value is CThread { + if (!value || typeof value !== "object") { + return false; + } + + if (!("cthread_id" in value) || typeof value.cthread_id !== "string") { + return false; + } + + if ( + !("cthread_belongs_to_chore_event_id" in value) || + (value.cthread_belongs_to_chore_event_id !== null && + typeof value.cthread_belongs_to_chore_event_id !== "string") + ) { + return false; + } + + if (!("cthread_title" in value) || typeof value.cthread_title !== "string") { + return false; + } + + if ( + !("cthread_toolset" in value) || + typeof value.cthread_toolset !== "string" + ) { + return false; + } + + if (!("cthread_model" in value) || typeof value.cthread_model !== "string") { + return false; + } + + if ( + !("cthread_temperature" in value) || + typeof value.cthread_temperature !== "number" + ) { + return false; + } + + if (!("cthread_n_ctx" in value) || typeof value.cthread_n_ctx !== "number") { + return false; + } + + if ( + !("cthread_max_new_tokens" in value) || + typeof value.cthread_max_new_tokens !== "number" + ) { + return false; + } + + if (!("cthread_n" in value) || typeof value.cthread_n !== "number") { + return false; + } + + if (!("cthread_error" in value) || typeof value.cthread_error !== "string") { + return false; + } + + if ( + !("cthread_anything_new" in value) || + typeof value.cthread_anything_new !== "boolean" + ) { + return false; + } + + if ( + !("cthread_created_ts" in value) || + typeof value.cthread_created_ts !== "number" + ) { + return false; + } + + if ( + !("cthread_updated_ts" in value) || + typeof value.cthread_updated_ts !== "number" + ) { + return false; + } + + if ( + !("cthread_archived_ts" in value) || + typeof value.cthread_archived_ts !== "number" + ) { + return false; + } + + if ( + !("cthread_locked_by" in value) || + typeof value.cthread_locked_by !== "string" + ) { + return false; + } + + if ( + !("cthread_locked_ts" in value) || + typeof value.cthread_locked_ts !== "number" + ) { + return false; + } + + return true; +} + +type CThreadSubResponseUpdate = { + sub_event: "cthread_update"; + cthread_rec: CThread; +}; + +export function isCThreadSubResponseUpdate( + value: unknown, +): value is CThreadSubResponseUpdate { + if (!value || typeof value !== "object") return false; + if (!("sub_event" in value)) return false; + if (typeof value.sub_event !== "string") return false; + if (!("cthread_rec" in value)) return false; + return isCThread(value.cthread_rec); +} + +type CThreadSubResponseDelete = { + sub_event: "cthread_delete"; + cthread_id: string; +}; + +export function isCThreadSubResponseDelete( + value: unknown, +): value is CThreadSubResponseDelete { + if (!value || typeof value !== "object") return false; + if (!("sub_event" in value)) return false; + if (typeof value.sub_event !== "string") return false; + if (!("cthread_id" in value)) return false; + if (typeof value.cthread_id !== "string") return false; + return true; +} From 4ea5f07ee0fe0bf1eaa047ee145a1e8c6c78571e Mon Sep 17 00:00:00 2001 From: Marc McIntosh Date: Wed, 19 Feb 2025 10:29:17 +0100 Subject: [PATCH 02/21] wip(chat-db): render threads in history sidebar. interactive rebase in progress; onto f1158365 Last commands done (2 commands done): pick ff6b362c wip(chat db): get thread from the lsp. pick 71457ebd wip(chat-db): render threads in history sidebar. Next commands to do (8 remaining commands): pick 45b14b11 wip(chatdb): fetch messages for thread. pick 9ee4451d wip(messsage trie): build a trie of mesages. You are currently rebasing branch 'ui-chat-db' on 'f1158365'. Changes to be committed: modified: refact-agent/gui/src/components/ChatHistory/ChatHistory.tsx modified: refact-agent/gui/src/components/ChatHistory/HistoryItem.tsx modified: refact-agent/gui/src/components/Sidebar/Sidebar.tsx modified: refact-agent/gui/src/features/ChatDB/chatDbSlice.ts --- .../components/ChatHistory/ChatHistory.tsx | 121 ++++++++++-------- .../components/ChatHistory/HistoryItem.tsx | 57 +++++---- .../gui/src/components/Sidebar/Sidebar.tsx | 50 +++----- .../gui/src/features/ChatDB/chatDbSlice.ts | 5 +- 4 files changed, 118 insertions(+), 115 deletions(-) diff --git a/refact-agent/gui/src/components/ChatHistory/ChatHistory.tsx b/refact-agent/gui/src/components/ChatHistory/ChatHistory.tsx index dda2600d0..c65586a37 100644 --- a/refact-agent/gui/src/components/ChatHistory/ChatHistory.tsx +++ b/refact-agent/gui/src/components/ChatHistory/ChatHistory.tsx @@ -1,61 +1,76 @@ -import { memo } from "react"; +import React, { useCallback } from "react"; import { Flex, Box } from "@radix-ui/themes"; import { ScrollArea } from "../ScrollArea"; import { HistoryItem } from "./HistoryItem"; +// import { +// getHistory, +// type HistoryState, +// } from "../../features/History/historySlice"; +// import type { ChatThread } from "../../features/Chat/Thread/types"; +import { useAppDispatch, useAppSelector } from "../../hooks"; import { - ChatHistoryItem, - getHistory, - type HistoryState, -} from "../../features/History/historySlice"; + chatDbSelectors, + chatDbActions, +} from "../../features/ChatDB/chatDbSlice"; +import { subscribeToThreadsThunk } from "../../services/refact/chatdb"; +import { restoreChat } from "../../features/Chat/Thread/actions"; +import { push } from "../../features/Pages/pagesSlice"; +import { CThread } from "../../services/refact/types"; -export type ChatHistoryProps = { - history: HistoryState; - onHistoryItemClick: (id: ChatHistoryItem) => void; - onDeleteHistoryItem: (id: string) => void; - onOpenChatInTab?: (id: string) => void; - currentChatId?: string; -}; +// export type ChatHistoryProps = { +// history: HistoryState; +// onHistoryItemClick: (id: ChatThread) => void; +// onDeleteHistoryItem: (id: string) => void; +// onOpenChatInTab?: (id: string) => void; +// currentChatId?: string; +// }; + +export const ChatHistory: React.FC = () => { + // const sortedHistory = getHistory({ history }); + const dispatch = useAppDispatch(); + void dispatch(subscribeToThreadsThunk()); + const history = useAppSelector(chatDbSelectors.getChats); + // TODO: should be a request to the lsp, if supported + const onDeleteHistoryItem = useCallback( + (id: string) => { + dispatch(chatDbActions.deleteCThread(id)); + }, + [dispatch], + ); -export const ChatHistory = memo( - ({ - history, - onHistoryItemClick, - onDeleteHistoryItem, - onOpenChatInTab, - currentChatId, - }: ChatHistoryProps) => { - const sortedHistory = getHistory({ history }); - return ( - - - - {sortedHistory.map((item) => ( - onHistoryItemClick(item)} - onOpenInTab={onOpenChatInTab} - onDelete={onDeleteHistoryItem} - key={item.id} - historyItem={item} - disabled={item.id === currentChatId} - /> - ))} - - - - ); - }, -); + const onHistoryItemClick = useCallback( + (thread: CThread) => { + dispatch(restoreChat(thread)); + dispatch(push({ name: "chat" })); + }, + [dispatch], + ); + + return ( + + + + {history.map((item) => ( + onHistoryItemClick(item)} + onClick={onHistoryItemClick} + // onOpenInTab={onOpenChatInTab} + onDelete={onDeleteHistoryItem} + key={item.cthread_id} + historyItem={item} + // disabled={item.cthread_id === currentChatId} + /> + ))} + + + + ); +}; ChatHistory.displayName = "ChatHistory"; diff --git a/refact-agent/gui/src/components/ChatHistory/HistoryItem.tsx b/refact-agent/gui/src/components/ChatHistory/HistoryItem.tsx index f3c079575..71151bc66 100644 --- a/refact-agent/gui/src/components/ChatHistory/HistoryItem.tsx +++ b/refact-agent/gui/src/components/ChatHistory/HistoryItem.tsx @@ -1,33 +1,34 @@ import React from "react"; -import { Card, Flex, Text, Box, Spinner } from "@radix-ui/themes"; -// import type { ChatHistoryItem } from "../../hooks/useChatHistory"; +import { Card, Flex, Text, Box } from "@radix-ui/themes"; import { ChatBubbleIcon, DotFilledIcon } from "@radix-ui/react-icons"; import { CloseButton } from "../Buttons/Buttons"; -import { IconButton } from "@radix-ui/themes"; -import { OpenInNewWindowIcon } from "@radix-ui/react-icons"; -import type { ChatHistoryItem } from "../../features/History/historySlice"; -import { isUserMessage } from "../../services/refact"; -import { useAppSelector } from "../../hooks"; +import { CThread } from "../../services/refact"; export const HistoryItem: React.FC<{ - historyItem: ChatHistoryItem; - onClick: () => void; + historyItem: CThread; + onClick: (thread: CThread) => void; onDelete: (id: string) => void; - onOpenInTab?: (id: string) => void; - disabled: boolean; -}> = ({ historyItem, onClick, onDelete, onOpenInTab, disabled }) => { - const dateCreated = new Date(historyItem.createdAt); + // onOpenInTab?: (id: string) => void; + // disabled: boolean; +}> = ({ + historyItem, + onClick, + onDelete, + // onOpenInTab, + // disabled +}) => { + const dateCreated = new Date(historyItem.cthread_created_ts); const dateTimeString = dateCreated.toLocaleString(); - const cache = useAppSelector((app) => app.chat.cache); - - const isStreaming = historyItem.id in cache; + // maybe remove this? + // const cache = useAppSelector((app) => app.chat.cache); + // const isStreaming = historyItem.cthread_id in cache; return (