diff --git a/client/src/Tab.tsx b/client/src/Tab.tsx index a8ebf4c785..3f53f3b615 100644 --- a/client/src/Tab.tsx +++ b/client/src/Tab.tsx @@ -12,6 +12,7 @@ import FileModalContainer from './pages/ResultModal/FileModalContainer'; import { FileModalContextProvider } from './context/providers/FileModalContextProvider'; import PromptGuidePopup from './components/PromptGuidePopup'; import Onboarding from './pages/Onboarding'; +import { FileHighlightsContextProvider } from './context/providers/FileHighlightsContextProvider'; type Props = { isActive: boolean; @@ -31,12 +32,14 @@ class Tab extends PureComponent { - - - - - - + + + + + + + + diff --git a/client/src/components/Chat/AllCoversations/index.tsx b/client/src/components/Chat/AllCoversations/index.tsx index a325b2b376..290189b9c6 100644 --- a/client/src/components/Chat/AllCoversations/index.tsx +++ b/client/src/components/Chat/AllCoversations/index.tsx @@ -86,6 +86,7 @@ const AllConversations = ({ isFromHistory: true, queryId: m.id, responseTimestamp: m.response_timestamp, + explainedFile: m.focused_chunk?.file_path, }); }); setTitle(conv[0].text || ''); diff --git a/client/src/components/Chat/Conversation.tsx b/client/src/components/Chat/Conversation.tsx index 1234a58743..86eeaac4bb 100644 --- a/client/src/components/Chat/Conversation.tsx +++ b/client/src/components/Chat/Conversation.tsx @@ -84,6 +84,9 @@ const Conversation = ({ responseTimestamp={ m.author === ChatMessageAuthor.Server ? m.responseTimestamp : null } + explainedFile={ + m.author === ChatMessageAuthor.Server ? m.explainedFile : undefined + } /> ))} diff --git a/client/src/components/Chat/ConversationMessage/FileChip.tsx b/client/src/components/Chat/ConversationMessage/FileChip.tsx index 318e94fdd2..af5baff178 100644 --- a/client/src/components/Chat/ConversationMessage/FileChip.tsx +++ b/client/src/components/Chat/ConversationMessage/FileChip.tsx @@ -1,21 +1,100 @@ -import React from 'react'; +import React, { + Dispatch, + MutableRefObject, + SetStateAction, + useEffect, + useRef, +} from 'react'; import FileIcon from '../../FileIcon'; import { ArrowOut } from '../../../icons'; +import { highlightColors } from '../../../consts/code'; +import { FileHighlightsType } from '../../../types/general'; type Props = { onClick: () => void; fileName: string; + filePath: string; skipIcon?: boolean; + lines?: [number, number]; + fileChips?: MutableRefObject; + setFileHighlights?: Dispatch>; }; -const FileChip = ({ onClick, fileName, skipIcon }: Props) => { +const FileChip = ({ + onClick, + fileName, + filePath, + skipIcon, + lines, + fileChips, + setFileHighlights, +}: Props) => { + const ref = useRef(null); + + useEffect(() => { + let chip = ref.current; + if (chip && fileChips) { + fileChips.current.push(chip); + } + + return () => { + if (chip && fileChips) { + const index = fileChips.current.indexOf(chip); + if (index !== -1) { + fileChips.current.splice(index, 1); + } + } + }; + }, []); + + const index = + ref.current && fileChips ? fileChips.current.indexOf(ref.current) : -1; + + useEffect(() => { + if (lines && index > -1 && setFileHighlights) { + setFileHighlights((prev) => { + const newHighlights = { ...prev }; + if (!newHighlights[filePath]) { + newHighlights[filePath] = []; + } + newHighlights[filePath][index] = { + lines, + color: `rgb(${highlightColors[index % highlightColors.length].join( + ', ', + )})`, + index, + }; + // newHighlights[filePath] = newHighlights[filePath].filter((h) => !!h); + if (JSON.stringify(prev) === JSON.stringify(newHighlights)) { + return prev; + } + return newHighlights; + }); + } + }, [lines, filePath, index]); + return ( diff --git a/client/src/pages/Repository/RepositoryOverview.tsx b/client/src/pages/Repository/RepositoryOverview.tsx index 2552e75473..cac57da2a9 100644 --- a/client/src/pages/Repository/RepositoryOverview.tsx +++ b/client/src/pages/Repository/RepositoryOverview.tsx @@ -71,7 +71,7 @@ const RepositoryOverview = ({ syncState, repository }: Props) => { const fileClick = useCallback((path: string, type: FileTreeFileType) => { if (type === FileTreeFileType.FILE) { - navigateFullResult(repository.name, path); + navigateFullResult(path); } else if (type === FileTreeFileType.DIR) { navigateRepoPath(repository.name, path === '/' ? '' : path); } diff --git a/client/src/pages/ResultFull/FileExplanation.tsx b/client/src/pages/ResultFull/FileExplanation.tsx new file mode 100644 index 0000000000..da768cd9cd --- /dev/null +++ b/client/src/pages/ResultFull/FileExplanation.tsx @@ -0,0 +1,51 @@ +import React, { useContext } from 'react'; +import MarkdownWithCode from '../../components/MarkdownWithCode'; +import { CopyMD } from '../../icons'; +import { FileModalContext } from '../../context/fileModalContext'; +import { copyToClipboard } from '../../utils'; +import Button from '../../components/Button'; +import { RIGHT_SIDEBAR_WIDTH_KEY } from '../../services/storage'; +import useResizeableWidth from '../../hooks/useResizeableWidth'; + +type Props = { + repoName: string; + markdown: string; +}; + +const FileExplanation = ({ repoName, markdown }: Props) => { + const { openFileModal } = useContext(FileModalContext); + const { width, handleResize } = useResizeableWidth( + RIGHT_SIDEBAR_WIDTH_KEY, + 384, + true, + ); + + return ( +
+
+
+ + +
+
+
+
+ ); +}; + +export default FileExplanation; diff --git a/client/src/pages/ResultFull/index.tsx b/client/src/pages/ResultFull/index.tsx index 7045aaa75c..a3b4207048 100644 --- a/client/src/pages/ResultFull/index.tsx +++ b/client/src/pages/ResultFull/index.tsx @@ -17,12 +17,16 @@ import useAppNavigation from '../../hooks/useAppNavigation'; import FileMenu from '../../components/FileMenu'; import SkeletonItem from '../../components/SkeletonItem'; import IpynbRenderer from '../../components/IpynbRenderer'; +import useConversation from '../../hooks/useConversation'; +import FileExplanation from './FileExplanation'; type Props = { data: any; isLoading: boolean; repoName: string; selectedBranch: string | null; + recordId: number; + threadId: string; }; const SIDEBAR_WIDTH = 324; @@ -32,9 +36,16 @@ const HORIZONTAL_PADDINGS = 64; const VERTICAL_PADDINGS = 32; const BREADCRUMBS_HEIGHT = 47; -const ResultFull = ({ data, isLoading, selectedBranch }: Props) => { +const ResultFull = ({ + data, + isLoading, + selectedBranch, + recordId, + threadId, +}: Props) => { const { navigateFullResult, navigateRepoPath } = useAppNavigation(); const [result, setResult] = useState(null); + const { data: answer } = useConversation(threadId, recordId); useEffect(() => { if (!data || data?.data?.[0]?.kind !== 'file') { @@ -62,7 +73,7 @@ const ResultFull = ({ data, isLoading, selectedBranch }: Props) => { return; } if (isFile) { - navigateFullResult(result.repoName, path); + navigateFullResult(path); } else { navigateRepoPath(result.repoName, path); } @@ -96,97 +107,108 @@ const ResultFull = ({ data, isLoading, selectedBranch }: Props) => { }, [result]); return ( -
-
-
-
- - {!!result && !!breadcrumbs.length ? ( -
- -
- ) : ( -
- -
- )} -
-
-

- {result?.code.split('\n').length} lines ({result?.loc} loc) ·{' '} - {result?.size ? humanFileSize(result?.size) : ''} -

- -
-
-
-
- {!result ? ( -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- + <> +
+
+
+
+ + {!!result && !!breadcrumbs.length ? ( +
+
-
+ ) : ( +
-
- -
-
- ) : result.language === 'jupyter notebook' ? ( - - ) : ( - +
+

+ {result?.code.split('\n').length} lines ({result?.loc} loc) ·{' '} + {result?.size ? humanFileSize(result?.size) : ''} +

+ - )} +
+
+
+
+ {!result ? ( +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ ) : result.language === 'jupyter notebook' ? ( + + ) : ( + + )} +
-
+ {!!answer && ( + + )} + ); }; diff --git a/client/src/pages/ResultModal/ModeToggle.tsx b/client/src/pages/ResultModal/ModeToggle.tsx index b1b7d0a28c..6a39d8a85a 100644 --- a/client/src/pages/ResultModal/ModeToggle.tsx +++ b/client/src/pages/ResultModal/ModeToggle.tsx @@ -28,10 +28,10 @@ const ModeToggle = ({ const handleFull = useCallback(() => { closeFileModalOpen(); - navigateFullResult(repoName, relativePath, { + navigateFullResult(relativePath, { scrollToLine: searchParams.get('modalScrollToLine') || '', }); - }, [searchParams, repoName, relativePath, closeFileModalOpen]); + }, [searchParams, relativePath, closeFileModalOpen]); const handleModal = useCallback(() => { startTransition(() => { diff --git a/client/src/pages/index.tsx b/client/src/pages/index.tsx index 6acf436571..f4da14618d 100644 --- a/client/src/pages/index.tsx +++ b/client/src/pages/index.tsx @@ -177,6 +177,8 @@ const ContentContainer = ({ tab }: { tab: UITabType }) => { isLoading={loading} repoName={tab.repoName} selectedBranch={selectedBranch} + recordId={navigatedItem?.recordId!} + threadId={navigatedItem?.threadId!} /> ); case 'article-response': diff --git a/client/src/services/storage.ts b/client/src/services/storage.ts index 2fa0454797..fe218caf3c 100644 --- a/client/src/services/storage.ts +++ b/client/src/services/storage.ts @@ -30,3 +30,5 @@ export const LAST_ACTIVE_TAB_KEY = 'last_active_tab'; export const TABS_HISTORY_KEY = 'tabs_history'; export const PROMPT_GUIDE_DONE = 'prompt_guide_done'; export const LANGUAGE_KEY = 'language'; +export const RIGHT_SIDEBAR_WIDTH_KEY = 'right_sidebar_width_key'; +export const LEFT_SIDEBAR_WIDTH_KEY = 'left_sidebar_width_key'; diff --git a/client/src/types/api.ts b/client/src/types/api.ts index a95630a1ef..06c68842e0 100644 --- a/client/src/types/api.ts +++ b/client/src/types/api.ts @@ -246,6 +246,7 @@ export type ConversationType = { answer: string; paths: string[]; response_timestamp: string; + focused_chunk: { file_path: string } | null; }; export interface SuggestionsResponse { diff --git a/client/src/types/general.ts b/client/src/types/general.ts index eab0b3db11..c42e9c838a 100644 --- a/client/src/types/general.ts +++ b/client/src/types/general.ts @@ -213,6 +213,7 @@ export type ChatMessageServer = { results?: string; queryId: string; responseTimestamp: string; + explainedFile?: string; }; export type ChatMessage = ChatMessageUser | ChatMessageServer; @@ -300,3 +301,8 @@ export type IpynbCellType = { outputs?: IpynbOutputType[]; input?: string[]; }; + +export type FileHighlightsType = Record< + string, + ({ lines: [number, number]; color: string; index: number } | undefined)[] +>; diff --git a/client/src/utils/navigationUtils.ts b/client/src/utils/navigationUtils.ts index ba240d439c..8cfb6eaa1d 100644 --- a/client/src/utils/navigationUtils.ts +++ b/client/src/utils/navigationUtils.ts @@ -22,6 +22,8 @@ export const buildURLPart = (navItem: NavigationItem) => { case 'full-result': return `full-result?${new URLSearchParams({ path: navItem.path || '', + threadId: navItem.threadId?.toString() || '', + recordId: navItem.recordId?.toString() || '', ...navItem.pathParams, }).toString()}`; case 'conversation-result': diff --git a/client/tailwind.config.cjs b/client/tailwind.config.cjs index 182f15f2c9..40bc4e205b 100644 --- a/client/tailwind.config.cjs +++ b/client/tailwind.config.cjs @@ -196,6 +196,6 @@ module.exports = { namedGroups: ["tooltip","summary"], }, plugins: [ - require('tailwindcss-labeled-groups')(['custom', 'summary']) + require('tailwindcss-labeled-groups')(['custom', 'summary' , 'code']) ], }; diff --git a/server/bleep/src/webserver.rs b/server/bleep/src/webserver.rs index 8bc406bd91..0476dee0bd 100644 --- a/server/bleep/src/webserver.rs +++ b/server/bleep/src/webserver.rs @@ -56,7 +56,8 @@ pub async fn start(app: Application) -> anyhow::Result<()> { // misc .route("/search", get(semantic::complex_search)) .route("/file", get(file::handle)) - .route("/answer", get(answer::handle)) + .route("/answer", get(answer::answer)) + .route("/answer/explain", get(answer::explain)) .route( "/answer/conversations", get(answer::conversations::list).delete(answer::conversations::delete), diff --git a/server/bleep/src/webserver/answer.rs b/server/bleep/src/webserver/answer.rs index 5efe8dd413..f9a0093e94 100644 --- a/server/bleep/src/webserver/answer.rs +++ b/server/bleep/src/webserver/answer.rs @@ -25,6 +25,8 @@ use tiktoken_rs::CoreBPE; use tokio::sync::mpsc::Sender; use tracing::{debug, info, warn}; +use self::{conversations::ConversationId, exchange::FocusedChunk}; + use super::middleware::User; use crate::{ analytics::{EventData, QueryEvent}, @@ -77,7 +79,7 @@ pub(super) async fn vote( } #[derive(Clone, Debug, serde::Deserialize)] -pub struct Params { +pub struct Answer { pub q: String, pub repo_ref: RepoRef, #[serde(default = "default_thread_id")] @@ -91,17 +93,94 @@ fn default_thread_id() -> uuid::Uuid { uuid::Uuid::new_v4() } -pub(super) async fn handle( - Query(params): Query, +pub(super) async fn answer( + Query(params): Query, Extension(app): Extension, Extension(user): Extension, ) -> super::Result { let query_id = uuid::Uuid::new_v4(); - let response = _handle( - Query(params.clone()), - Extension(app.clone()), - Extension(user.clone()), + + let conversation_id = ConversationId { + user_id: user + .login() + .ok_or_else(|| super::Error::user("didn't have user ID"))? + .to_string(), + thread_id: params.thread_id, + }; + + let (_, mut exchanges) = conversations::load(&app.sql, &conversation_id) + .await? + .unwrap_or_else(|| (params.repo_ref.clone(), Vec::new())); + + let Answer { + parent_exchange_id, + q, + .. + } = ¶ms; + + if let Some(parent_exchange_id) = parent_exchange_id { + let truncate_from_index = if parent_exchange_id.is_nil() { + 0 + } else { + exchanges + .iter() + .position(|e| e.id == *parent_exchange_id) + .ok_or_else(|| super::Error::user("parent query id not found in exchanges"))? + + 1 + }; + + exchanges.truncate(truncate_from_index); + } + + let query = parser::parse_nl(q) + .context("parse error")? + .into_semantic() + .context("got a 'Grep' query")? + .into_owned(); + let query_target = query + .target + .as_ref() + .context("query was empty")? + .as_plain() + .context("user query was not plain text")? + .clone() + .into_owned(); + + let action = Action::Query(query_target); + exchanges.push(Exchange::new(query_id, query)); + + execute_agent( + params.clone(), + app.clone(), + user.clone(), + query_id, + conversation_id, + exchanges, + action, + ) + .await +} + +/// Like `try_execute_agent`, but additionally logs errors in our analytics. +async fn execute_agent( + params: Answer, + app: Application, + user: User, + query_id: uuid::Uuid, + conversation_id: ConversationId, + exchanges: Vec, + action: Action, +) -> super::Result< + Sse> + Send>>>, +> { + let response = try_execute_agent( + params.clone(), + app.clone(), + user.clone(), query_id, + conversation_id, + exchanges, + action, ) .await; @@ -111,7 +190,7 @@ pub(super) async fn handle( &QueryEvent { query_id, thread_id: params.thread_id, - repo_ref: Some(params.repo_ref.clone()), + repo_ref: Some(params.repo_ref), data: EventData::output_stage("error") .with_payload("status", err.status.as_u16()) .with_payload("message", err.message()), @@ -122,28 +201,19 @@ pub(super) async fn handle( response } -pub(super) async fn _handle( - Query(params): Query, - Extension(app): Extension, - Extension(user): Extension, +async fn try_execute_agent( + params: Answer, + app: Application, + user: User, query_id: uuid::Uuid, + conversation_id: ConversationId, + exchanges: Vec, + mut action: Action, ) -> super::Result< Sse> + Send>>>, > { QueryLog::new(&app.sql).insert(¶ms.q).await?; - let conversation_id = conversations::ConversationId { - user_id: user - .login() - .ok_or_else(|| super::Error::user("didn't have user ID"))? - .to_string(), - thread_id: params.thread_id, - }; - - let (repo_ref, mut exchanges) = conversations::load(&app.sql, &conversation_id) - .await? - .unwrap_or_else(|| (params.repo_ref.clone(), Vec::new())); - let gh_token = app .github_token() .map_err(|e| super::Error::user(e).with_status(StatusCode::UNAUTHORIZED))? @@ -180,45 +250,12 @@ pub(super) async fn _handle( } }; - let Params { + let Answer { thread_id, - parent_exchange_id, - q, + repo_ref, .. - } = params; - - if let Some(parent_exchange_id) = parent_exchange_id { - let truncate_from_index = if parent_exchange_id.is_nil() { - 0 - } else { - exchanges - .iter() - .position(|e| e.id == parent_exchange_id) - .ok_or_else(|| super::Error::user("parent query id not found in exchanges"))? - + 1 - }; - - exchanges.truncate(truncate_from_index); - } - - let query = parser::parse_nl(&q) - .context("parse error")? - .into_semantic() - .context("got a 'Grep' query")? - .into_owned(); - let query_target = query - .target - .as_ref() - .context("query was empty")? - .as_plain() - .context("user query was not plain text")? - .clone() - .into_owned(); - - exchanges.push(Exchange::new(query_id, query)); - + } = params.clone(); let stream = async_stream::try_stream! { - let mut action = Action::Query(query_target); let (exchange_tx, exchange_rx) = tokio::sync::mpsc::channel(10); let mut agent = Agent { @@ -332,6 +369,103 @@ pub(super) async fn _handle( Ok(Sse::new(Box::pin(stream))) } +#[derive(serde::Deserialize)] +pub struct Explain { + pub relative_path: String, + pub line_start: usize, + pub line_end: usize, + pub branch: Option, + pub repo_ref: RepoRef, + #[serde(default = "default_thread_id")] + pub thread_id: uuid::Uuid, +} + +pub async fn explain( + Query(params): Query, + Extension(app): Extension, + Extension(user): Extension, +) -> super::Result { + let query_id = uuid::Uuid::new_v4(); + + // We synthesize a virtual `/answer` request. + let virtual_req = Answer { + q: format!( + "Explain lines {} - {} in {}", + params.line_start, params.line_end, params.relative_path + ), + repo_ref: params.repo_ref, + thread_id: params.thread_id, + parent_exchange_id: None, + }; + + let conversation_id = ConversationId { + thread_id: params.thread_id, + user_id: user + .login() + .ok_or_else(|| super::Error::user("didn't have user ID"))? + .to_string(), + }; + + let mut query = parser::parse_nl(&virtual_req.q) + .context("failed to parse virtual answer query")? + .into_semantic() + // We synthesize the query, this should never fail. + .unwrap() + .into_owned(); + + if let Some(branch) = params.branch { + query + .branch + .insert(Literal::Plain(std::borrow::Cow::Owned(branch))); + } + + let file_content = app + .indexes + .file + .by_path(&virtual_req.repo_ref, ¶ms.relative_path, None) + .await + .context("file retrieval failed")? + .context("did not find requested file")? + .content; + + let snippet = file_content + .lines() + .skip(params.line_start.saturating_sub(1)) + .take(params.line_end + 1 - params.line_start) + .collect::>() + .join("\n"); + + let mut exchange = Exchange::new(query_id, query); + + exchange.focused_chunk = Some(FocusedChunk { + file_path: params.relative_path.clone(), + start_line: params.line_start, + end_line: params.line_end, + }); + + exchange.paths.push(params.relative_path.clone()); + exchange.code_chunks.push(CodeChunk { + path: params.relative_path.clone(), + alias: 0, + start_line: params.line_start, + end_line: params.line_end, + snippet, + }); + + let action = Action::Answer { paths: vec![0] }; + + execute_agent( + virtual_req, + app, + user, + query_id, + conversation_id, + vec![exchange], + action, + ) + .await +} + #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub struct CodeChunk { path: String, @@ -396,6 +530,7 @@ impl Drop for Agent { impl Agent { /// Mark this agent as "completed", preventing an analytics message from sending on drop. fn complete(&mut self) { + // Checked in `Drop::drop` self.complete = true; } @@ -477,8 +612,9 @@ impl Agent { ) .unwrap(); + let paths = self.paths(); let mut history = vec![llm_gateway::api::Message::system(&prompts::system( - &self.paths(), + paths.iter().map(String::as_str), ))]; history.extend(self.history()?); diff --git a/server/bleep/src/webserver/answer/exchange.rs b/server/bleep/src/webserver/answer/exchange.rs index d7ad61605f..bcd35d4349 100644 --- a/server/bleep/src/webserver/answer/exchange.rs +++ b/server/bleep/src/webserver/answer/exchange.rs @@ -20,13 +20,24 @@ pub struct Exchange { pub query: SemanticQuery<'static>, pub answer: Option, pub search_steps: Vec, - conclusion: Option, pub paths: Vec, pub code_chunks: Vec, + + /// A specifically chosen "focused" code chunk. + /// + /// This is different from the `code_chunks` list, as focused code chunks also contain the full + /// surrounding context from the source file, not just the relevant snippet. + /// + /// In the context of the app, this can be used to show code side-by-side with an outcome, such + /// as when displaying an article. + pub focused_chunk: Option, + #[serde(skip_serializing_if = "Option::is_none")] query_timestamp: Option>, #[serde(skip_serializing_if = "Option::is_none")] response_timestamp: Option>, + + conclusion: Option, } impl Exchange { @@ -188,6 +199,13 @@ impl SearchStep { } } +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Default)] +pub struct FocusedChunk { + pub file_path: String, + pub start_line: usize, + pub end_line: usize, +} + #[derive(Debug)] pub enum Update { StartStep(SearchStep), diff --git a/server/bleep/src/webserver/answer/prompts.rs b/server/bleep/src/webserver/answer/prompts.rs index e9348767df..22bd069848 100644 --- a/server/bleep/src/webserver/answer/prompts.rs +++ b/server/bleep/src/webserver/answer/prompts.rs @@ -79,12 +79,14 @@ pub fn functions(add_proc: bool) -> serde_json::Value { funcs } -pub fn system(paths: &Vec) -> String { +pub fn system<'a>(paths: impl IntoIterator) -> String { let mut s = "".to_string(); - if !paths.is_empty() { + let mut paths = paths.into_iter().peekable(); + + if paths.peek().is_some() { s.push_str("## PATHS ##\nalias, path\n"); - for (i, path) in paths.iter().enumerate() { + for (i, path) in paths.enumerate() { s.push_str(&format!("{}, {}\n", i, path)); } }