Skip to content

Commit e38f469

Browse files
Various refactors, including: 1) move LLM invocation into a sperate 'agent' file. 2) Refactor the chat thread list item style to support resizable panels, 3) various other touch ups and improvements.
1 parent cc06480 commit e38f469

21 files changed

Lines changed: 1011 additions & 782 deletions

packages/web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
"@radix-ui/react-alert-dialog": "^1.1.5",
5353
"@radix-ui/react-avatar": "^1.1.2",
5454
"@radix-ui/react-checkbox": "^1.3.2",
55+
"@radix-ui/react-collapsible": "^1.1.11",
5556
"@radix-ui/react-dialog": "^1.1.4",
5657
"@radix-ui/react-dropdown-menu": "^2.1.1",
5758
"@radix-ui/react-hover-card": "^1.1.6",

packages/web/src/app/[domain]/chat/layout.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,20 @@
1+
'use client';
12

23
import { AnimatedResizableHandle } from '@/components/ui/animatedResizableHandle';
34
import { ResizablePanelGroup } from '@/components/ui/resizable';
45
import { ChatSidePanel } from './components/chatSidePanel';
56
import { TopBar } from '../components/topBar';
67
import { ChatName } from './components/chatName';
78
import { NavigationGuardProvider } from 'next-navigation-guard';
9+
import { useDomain } from '@/hooks/useDomain';
810

911
interface LayoutProps {
1012
children: React.ReactNode;
11-
params: {
12-
domain: string;
13-
}
1413
}
1514

16-
export default function Layout({ children, params: { domain } }: LayoutProps) {
15+
export default function Layout({ children }: LayoutProps) {
16+
const domain = useDomain();
17+
1718
return (
1819
// @note: we use a navigation guard here since we don't support resuming streams yet.
1920
// @see: https://ai-sdk.dev/docs/ai-sdk-ui/chatbot-message-persistence#resuming-ongoing-streams

packages/web/src/app/api/(server)/chat/route.ts

Lines changed: 66 additions & 170 deletions
Large diffs are not rendered by default.
Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
'use client';
22

3+
import { cn } from "@/lib/utils";
34
import { ResizableHandle } from "./resizable";
45

5-
export const AnimatedResizableHandle = () => {
6+
interface AnimatedResizableHandleProps {
7+
className?: string;
8+
}
9+
10+
export const AnimatedResizableHandle = ({ className }: AnimatedResizableHandleProps) => {
611
return (
712
<ResizableHandle
8-
className="w-[1px] bg-accent transition-colors delay-50 data-[resize-handle-state=drag]:bg-accent-foreground data-[resize-handle-state=hover]:bg-accent-foreground"
13+
className={cn("w-[1px] bg-accent transition-colors delay-50 data-[resize-handle-state=drag]:bg-accent-foreground data-[resize-handle-state=hover]:bg-accent-foreground", className)}
914
/>
1015
)
1116
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
"use client"
2+
3+
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
4+
5+
const Collapsible = CollapsiblePrimitive.Root
6+
7+
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
8+
9+
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
10+
11+
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

packages/web/src/env.mjs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,9 +112,11 @@ export const env = createEnv({
112112
AWS_SECRET_ACCESS_KEY: z.string().optional(),
113113
AWS_REGION: z.string().optional(),
114114

115-
SOURCEBOT_CHAT_MAX_OUTPUT_TOKENS: numberSchema.default(5000),
116115
SOURCEBOT_CHAT_MODEL_TEMPERATURE: numberSchema.default(0.3),
117116
SOURCEBOT_CHAT_FILE_MAX_CHARACTERS: numberSchema.default(4000),
117+
118+
SOURCEBOT_CHAT_MAX_STEP_COUNT: numberSchema.default(20),
119+
118120
DEBUG_WRITE_CHAT_MESSAGES_TO_FILE: booleanSchema.default('false'),
119121
},
120122
// @NOTE: Please make sure of the following:
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
import { env } from "@/env.mjs";
2+
import { getFileSource } from "@/features/search/fileSourceApi";
3+
import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants";
4+
import { isServiceError } from "@/lib/utils";
5+
import { ProviderOptions } from "@ai-sdk/provider-utils";
6+
import { createLogger } from "@sourcebot/logger";
7+
import { LanguageModel, ModelMessage, StopCondition, streamText } from "ai";
8+
import { ANSWER_TAG, FILE_REFERENCE_PREFIX, toolNames } from "./constants";
9+
import { createCodeSearchTool, findSymbolDefinitionsTool, findSymbolReferencesTool, readFilesTool } from "./tools";
10+
import { FileSource, Source } from "./types";
11+
import { fileReferenceToString, sourceCodeToModelOutput } from "./utils";
12+
13+
const logger = createLogger('chat-agent');
14+
15+
interface AgentOptions {
16+
model: LanguageModel;
17+
providerOptions?: ProviderOptions;
18+
headers?: Record<string, string>;
19+
selectedRepos: string[];
20+
inputMessages: ModelMessage[];
21+
inputSources: Source[];
22+
onWriteSource: (source: Source) => void;
23+
}
24+
25+
// If the agent exceeds the step count, then we will stop.
26+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
27+
const stepCountIsGTE = (stepCount: number): StopCondition<any> => {
28+
return ({ steps }) => steps.length >= stepCount;
29+
}
30+
31+
export const createAgentStream = async ({
32+
model,
33+
providerOptions,
34+
headers,
35+
inputMessages,
36+
inputSources,
37+
selectedRepos,
38+
onWriteSource,
39+
}: AgentOptions) => {
40+
const baseSystemPrompt = createBaseSystemPrompt({
41+
selectedRepos,
42+
});
43+
44+
const stream = streamText({
45+
model,
46+
providerOptions,
47+
headers,
48+
system: baseSystemPrompt,
49+
messages: inputMessages,
50+
tools: {
51+
[toolNames.searchCode]: createCodeSearchTool(selectedRepos),
52+
[toolNames.readFiles]: readFilesTool,
53+
[toolNames.findSymbolReferences]: findSymbolReferencesTool,
54+
[toolNames.findSymbolDefinitions]: findSymbolDefinitionsTool,
55+
},
56+
prepareStep: async ({ stepNumber }) => {
57+
// The first step attaches any mentioned sources to the system prompt.
58+
if (stepNumber === 0 && inputSources.length > 0) {
59+
const fileSources = inputSources.filter((source) => source.type === 'file');
60+
61+
const resolvedFileSources = (
62+
await Promise.all(fileSources.map(resolveFileSource)))
63+
.filter((source) => source !== undefined)
64+
65+
const fileSourcesSystemPrompt = await createFileSourcesSystemPrompt({
66+
files: resolvedFileSources
67+
});
68+
69+
return {
70+
system: `${baseSystemPrompt}\n\n${fileSourcesSystemPrompt}`
71+
}
72+
}
73+
74+
return undefined;
75+
},
76+
temperature: env.SOURCEBOT_CHAT_MODEL_TEMPERATURE,
77+
stopWhen: [
78+
stepCountIsGTE(env.SOURCEBOT_CHAT_MAX_STEP_COUNT),
79+
],
80+
toolChoice: "auto", // Let the model decide when to use tools
81+
onStepFinish: ({ toolResults }) => {
82+
// This takes care of extracting any sources that the LLM has seen as part of
83+
// the tool calls it made.
84+
toolResults.forEach(({ output, toolName }) => {
85+
if (isServiceError(output)) {
86+
// is there something we want to do here?
87+
return;
88+
}
89+
90+
if (toolName === toolNames.readFiles) {
91+
output.forEach((file) => {
92+
onWriteSource({
93+
type: 'file',
94+
language: file.language,
95+
repo: file.repository,
96+
path: file.path,
97+
revision: file.revision,
98+
name: file.path.split('/').pop() ?? file.path,
99+
})
100+
})
101+
}
102+
else if (toolName === toolNames.searchCode) {
103+
output.files.forEach((file) => {
104+
onWriteSource({
105+
type: 'file',
106+
language: file.language,
107+
repo: file.repository,
108+
path: file.fileName,
109+
revision: file.revision,
110+
name: file.fileName.split('/').pop() ?? file.fileName,
111+
})
112+
})
113+
}
114+
else if (toolName === toolNames.findSymbolDefinitions || toolName === toolNames.findSymbolReferences) {
115+
output.forEach((file) => {
116+
onWriteSource({
117+
type: 'file',
118+
language: file.language,
119+
repo: file.repository,
120+
path: file.fileName,
121+
revision: file.revision,
122+
name: file.fileName.split('/').pop() ?? file.fileName,
123+
})
124+
})
125+
}
126+
})
127+
}
128+
});
129+
130+
return stream;
131+
}
132+
133+
interface BaseSystemPromptOptions {
134+
selectedRepos: string[];
135+
}
136+
137+
export const createBaseSystemPrompt = ({
138+
selectedRepos,
139+
}: BaseSystemPromptOptions) => {
140+
return `
141+
You are a powerful agentic AI code assistant built into Sourcebot, the world's best code-intelligence platform. Your job is to help developers understand and navigate their large codebases.
142+
143+
<workflow>
144+
Your workflow has two distinct phases:
145+
146+
**Phase 1: Research & Analysis**
147+
- Analyze the user's question and determine what context you need
148+
- Use available tools to gather code, search repositories, find references, etc.
149+
- Think through the problem and collect all relevant information
150+
- Do NOT provide partial answers or explanations during this phase
151+
152+
**Phase 2: Structured Response**
153+
- **MANDATORY**: You MUST always enter this phase and provide a structured markdown response, regardless of whether phase 1 was completed or interrupted
154+
- Provide your final response based on whatever context you have available
155+
- Always format your response according to the required response format below
156+
</workflow>
157+
158+
<available_repositories>
159+
The following repositories are available for analysis:
160+
${selectedRepos.map(repo => `- ${repo}`).join('\n')}
161+
</available_repositories>
162+
163+
<research_phase_instructions>
164+
During the research phase, you have these tools available:
165+
- \`${toolNames.searchCode}\`: Search for code patterns, functions, or text across repositories
166+
- \`${toolNames.readFiles}\`: Read the contents of specific files
167+
- \`${toolNames.findSymbolReferences}\`: Find where symbols are referenced
168+
- \`${toolNames.findSymbolDefinitions}\`: Find where symbols are defined
169+
170+
Use these tools to gather comprehensive context before answering. Always explain why you're using each tool.
171+
</research_phase_instructions>
172+
173+
<answer_instructions>
174+
When you have sufficient context, output your answer as a structured markdown response.
175+
176+
**Required Response Format:**
177+
- **CRITICAL**: You MUST always prefix your answer with a \`${ANSWER_TAG}\` tag at the very top of your response
178+
- **CRITICAL**: You MUST provide your complete response in markdown format with embedded code references
179+
- **CODE REFERENCE REQUIREMENT**: Whenever you mention, discuss, or refer to ANY specific part of the code (files, functions, variables, methods, classes, imports, etc.), you MUST immediately follow with a code reference using the format \`${fileReferenceToString({ fileName: 'filename'})}\` or \`${fileReferenceToString({ fileName: 'filename', range: { startLine: 1, endLine: 10 } })}\` (where the numbers are the start and end line numbers of the code snippet). This includes:
180+
- Files (e.g., "The \`auth.ts\` file" → must include \`${fileReferenceToString({ fileName: 'auth.ts' })}\`)
181+
- Function names (e.g., "The \`getRepos()\` function" → must include \`${fileReferenceToString({ fileName: 'auth.ts', range: { startLine: 15, endLine: 20 } })}\`)
182+
- Variable names (e.g., "The \`suggestionQuery\` variable" → must include \`${fileReferenceToString({ fileName: 'search.ts', range: { startLine: 42, endLine: 42 } })}\`)
183+
- Code patterns (e.g., "using \`file:\${suggestionQuery}\` pattern" → must include \`${fileReferenceToString({ fileName: 'search.ts', range: { startLine: 10, endLine: 15 } })}\`)
184+
- Any code snippet or line you're explaining
185+
- Class names, method calls, imports, etc.
186+
- Be clear and very concise. Use bullet points where appropriate
187+
- Do NOT explain code without providing the exact location reference. Every code mention requires a corresponding \`${FILE_REFERENCE_PREFIX}\` reference
188+
- If you cannot provide a code reference for something you're discussing, do not mention that specific code element
189+
- Always prefer to use \`${FILE_REFERENCE_PREFIX}\` over \`\`\`code\`\`\` blocks.
190+
191+
**Example answer structure:**
192+
\`\`\`markdown
193+
${ANSWER_TAG}
194+
Authentication in Sourcebot is built on NextAuth.js with a session-based approach using JWT tokens and Prisma as the database adapter ${fileReferenceToString({ fileName: 'auth.ts', range: { startLine: 135, endLine: 140 } })}. The system supports multiple authentication providers and implements organization-based authorization with role-defined permissions.
195+
\`\`\`
196+
197+
</answer_instructions>
198+
`;
199+
}
200+
201+
interface FileSourcesSystemPromptOptions {
202+
files: {
203+
path: string;
204+
source: string;
205+
repo: string;
206+
language: string;
207+
revision: string;
208+
}[];
209+
}
210+
211+
const createFileSourcesSystemPrompt = async ({ files }: FileSourcesSystemPromptOptions) => {
212+
return `
213+
The user has mentioned the following files, which are automatically included for analysis.
214+
215+
${files.map(file => `<file path="${file.path}" repository="${file.repo}" language="${file.language}" revision="${file.revision}">
216+
${sourceCodeToModelOutput(file.source).output}
217+
</file>`).join('\n\n')}
218+
`.trim();
219+
}
220+
221+
const resolveFileSource = async ({ path, repo, revision }: FileSource) => {
222+
const fileSource = await getFileSource({
223+
fileName: path,
224+
repository: repo,
225+
branch: revision,
226+
// @todo: handle multi-tenancy.
227+
}, SINGLE_TENANT_ORG_DOMAIN);
228+
229+
if (isServiceError(fileSource)) {
230+
// @todo: handle this
231+
logger.error("Error fetching file source:", fileSource)
232+
return undefined;
233+
}
234+
235+
return {
236+
path,
237+
source: fileSource.source,
238+
repo,
239+
language: fileSource.language,
240+
revision,
241+
}
242+
}

0 commit comments

Comments
 (0)