Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions starguide_flutter/lib/chat/starguide_chat_input.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:starguide_flutter/config/constants.dart';

/// Text input widget used to compose chat messages.
///
/// Displays a single-line text field and a send button that triggers [onSend]
/// when pressed or when the user submits the input field.
class StarguideChatInput extends StatefulWidget {
const StarguideChatInput({
super.key,
Expand All @@ -13,11 +17,22 @@ class StarguideChatInput extends StatefulWidget {
required this.focusNode,
});

/// Callback invoked when the user sends a message.
final void Function(String message) onSend;

/// Controller that holds the current input text.
final TextEditingController textController;

/// Whether the send button is enabled.
final bool enabled;

/// Indicates if the server is currently generating an answer.
final bool isGeneratingResponse;

/// Number of chat requests made so far.
final int numChatRequests;

/// Focus node for controlling keyboard focus.
final FocusNode focusNode;

@override
Expand All @@ -29,6 +44,7 @@ class _StarguideChatInputState extends State<StarguideChatInput> {
Widget build(BuildContext context) {
final theme = Theme.of(context);

// Select an appropriate hint based on the number of requests made.
final String hintText;

if (widget.numChatRequests >= kMaxChatRequests) {
Expand Down Expand Up @@ -67,13 +83,15 @@ class _StarguideChatInputState extends State<StarguideChatInput> {
),
controller: widget.textController,
onSubmitted: (value) {
// Send the message when the user hits enter.
widget.onSend(widget.textController.text);
widget.textController.clear();
widget.focusNode.requestFocus();
},
),
),
if (widget.isGeneratingResponse)
// Show a spinner while the server generates a reply.
Container(
padding: const EdgeInsets.all(10),
width: 40,
Expand All @@ -96,6 +114,7 @@ class _StarguideChatInputState extends State<StarguideChatInput> {
),
onPressed: widget.enabled
? () {
// Trigger message send and clear the input field.
widget.onSend(widget.textController.text);
widget.textController.clear();
}
Expand Down
15 changes: 14 additions & 1 deletion starguide_flutter/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,14 @@ late final Highlighter highlighterDart;
late final Highlighter highlighterYaml;
late final Highlighter highlighterSql;

/// Entry point for the Starguide Flutter application.
///
/// Initializes syntax highlighters and reCAPTCHA for web before launching the
/// app widget tree.
void main() async {
WidgetsFlutterBinding.ensureInitialized();

// Initialize the highlighter.
// Initialize the syntax highlighters used for code snippets.
await Highlighter.initialize(['dart', 'yaml', 'sql']);
var theme = await HighlighterTheme.loadDarkTheme();
highlighterDart = Highlighter(
Expand All @@ -45,6 +49,7 @@ void main() async {
theme: theme,
);

// Prepare reCAPTCHA on web platforms.
if (kIsWeb) {
await GRecaptchaV3.hideBadge();
await GRecaptchaV3.ready(
Expand All @@ -54,6 +59,7 @@ void main() async {
runApp(const StarguideApp());
}

/// Root widget that sets up the application theme.
class StarguideApp extends StatelessWidget {
const StarguideApp({super.key});

Expand All @@ -67,6 +73,7 @@ class StarguideApp extends StatelessWidget {
}
}

/// Top level chat page widget containing the main application state.
class StarguideChatPage extends StatefulWidget {
const StarguideChatPage({
super.key,
Expand Down Expand Up @@ -149,10 +156,12 @@ class StarguideChatPageState extends State<StarguideChatPage> {
kIsWeb ? (await GRecaptchaV3.execute('create_chat_session'))! : '',
);

// Ask the server and stream the response as it is generated.
final responseStream = client.starguide.ask(_chatSession!, text);

var accumulatedText = '';

// Insert a placeholder message that will be updated as chunks arrive.
_currentResponse = TextMessage(
id: _uuid.v4(),
authorId: _model.id,
Expand All @@ -161,6 +170,7 @@ class StarguideChatPageState extends State<StarguideChatPage> {
);
await _chatController.insertMessage(_currentResponse!);

// Append each chunk to the message displayed to the user.
await for (final chunk in responseStream) {
accumulatedText += chunk;
final newMessage = _currentResponse!.copyWith(text: accumulatedText);
Expand All @@ -173,6 +183,7 @@ class StarguideChatPageState extends State<StarguideChatPage> {
_isGeneratingResponse = false;
});
} catch (e) {
// Flag a connection error so a reconnect UI can be shown.
setState(() {
_connectionError = true;
});
Expand All @@ -181,6 +192,7 @@ class StarguideChatPageState extends State<StarguideChatPage> {
}

void _handleMessageSend(String text) async {
// First show the user's message in the chat list.
await _chatController.insertMessage(
TextMessage(
id: _uuid.v4(),
Expand All @@ -190,6 +202,7 @@ class StarguideChatPageState extends State<StarguideChatPage> {
),
);

// Then send the message to the server.
_sendMessage(text);
}

Expand Down
17 changes: 12 additions & 5 deletions starguide_server/lib/server.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,29 +7,36 @@ import 'package:starguide_server/src/web/routes/root.dart';
import 'src/generated/protocol.dart';
import 'src/generated/endpoints.dart';

/// Bootstraps and starts the Starguide Serverpod instance.
///
/// Sets up routes, configures the data fetcher, and then starts the
/// Serverpod service. This function is the main entrypoint of the server
/// application.
void run(List<String> args) async {
// Initialize Serverpod and connect it with your generated code.
// Initialize Serverpod and connect it with the generated protocol and
// endpoints from the `serverpod generate` command.
final pod = Serverpod(
args,
Protocol(),
Endpoints(),
);

// Prepare the data fetcher that downloads and caches external content.
configureDataFetcher();
DataFetcher.instance.register(pod);

// Setup a default page at the web root.
// Setup default routes for serving a simple landing page and the web app.
pod.webServer.addRoute(RouteRoot(), '/');
pod.webServer.addRoute(RouteRoot(), '/index.html');
// Serve all files in the /static directory.
// Serve all files in the /static directory such as Flutter web builds.
pod.webServer.addRoute(
RouteStaticDirectory(serverDirectory: 'app', basePath: '/'),
'/*',
);

// Start the server.
// Start the Serverpod backend.
await pod.start();

// Start fetching data.
// Begin periodic data fetching jobs.
await DataFetcher.instance.startFetching(pod);
}
30 changes: 27 additions & 3 deletions starguide_server/lib/src/business/data_fetcher.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,25 @@ const _futureCallIdentifier = 'DataFetcher';

const _fetchRetryDelay = Duration(minutes: 1);

/// Handles scheduled fetching and caching of external documents.
///
/// The [DataFetcher] orchestrates downloading content from configured
/// [DataSource]s, creates [RAGDocument]s, and stores them in the database. It
/// also periodically cleans up old data.
class DataFetcher {
static DataFetcher? _instance;

/// Registered data sources that will be crawled for content.
final List<DataSource> dataSources;

/// How long a fetched document is considered fresh.
final Duration cacheDuration;

/// Duration after which old documents are removed from storage.
final Duration removeOldDataAfter;

/// Configures the singleton [DataFetcher] instance. Subsequent calls have no
/// effect once the instance has been created.
static void configure(
List<DataSource> dataSources, {
Duration cacheDuration = const Duration(days: 1),
Expand All @@ -29,6 +41,7 @@ class DataFetcher {
);
}

/// Accessor for the configured [DataFetcher] singleton.
static DataFetcher get instance => _instance!;

DataFetcher._({
Expand All @@ -37,15 +50,17 @@ class DataFetcher {
this.removeOldDataAfter = const Duration(days: 3),
});

/// Registers the background job with Serverpod so that it can be scheduled.
void register(Serverpod pod) {
pod.registerFutureCall(_FetchDataFutureCall(), _futureCallName);
}

/// Starts the fetching process by scheduling the first job immediately.
Future<void> startFetching(Serverpod pod) async {
// Cancel any existing future calls, to avoid duplicates.
// Cancel any existing future calls to avoid duplicates after restarts.
await pod.cancelFutureCall(_futureCallIdentifier);

// Kick off the data fetcher.
// Kick off the data fetcher by scheduling the first future call now.
pod.futureCallWithDelay(
_futureCallName,
DataFetcherTask(
Expand Down Expand Up @@ -74,23 +89,27 @@ class DataFetcher {
) async {
final genAi = GenerativeAi();

// Generate a short description used for listing the document.
session.log('Summarizing document for description.');
final shortDescription = await genAi.generateSimpleAnswer(
Prompts.instance.get('summarize_document_for_description')! +
rawDocument.document,
);

// Summaries are embedded to allow similarity searches.
session.log('Summarizing document for embedding.');
final embeddingSummary = await genAi.generateSimpleAnswer(
Prompts.instance.get('summarize_document_for_embedding')! +
rawDocument.document,
);

// Create an embedding vector for the summary text.
session.log('Generating embedding for summary.');
final embedding = await genAi.generateEmbedding(embeddingSummary);

session.log('Embeddings generated.');

// Build the final document object to be stored in the database.
return RAGDocument(
title: rawDocument.title,
sourceUrl: rawDocument.sourceUrl,
Expand Down Expand Up @@ -129,10 +148,15 @@ class DataFetcher {
}
}

/// Determines if a given [sourceUrl] needs to be fetched again.
///
/// Returns `true` when the URL hasn't been cached recently, meaning new
/// content should be downloaded.
Future<bool> shouldFetchUrl(Session session, Uri sourceUrl) async {
session.log('Checking if should fetch url: $sourceUrl');

// Check if the url is already in the database.
// Look up the document in the database and check if it has been fetched
// within the allowed cache duration.
final document = await RAGDocument.db.findFirstRow(
session,
where: (t) =>
Expand Down
20 changes: 14 additions & 6 deletions starguide_server/lib/src/business/search.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import 'package:starguide_server/src/generated/protocol.dart';
import 'package:starguide_server/src/generative_ai/generative_ai.dart';
import 'package:starguide_server/src/generative_ai/prompts.dart';

/// Searches the documentation RAG store for entries relevant to [question].
///
/// Uses generative AI to pick the most relevant documentation URLs based on
/// [conversation] context and returns the matching [RAGDocument]s.
Future<List<RAGDocument>> searchDocumentation(
Session session,
List<ChatMessage> conversation,
Expand All @@ -12,7 +16,7 @@ Future<List<RAGDocument>> searchDocumentation(
final genAi = GenerativeAi();
var documents = <RAGDocument>[];

// Search documentation for the most relevant URLs.
// Ask the AI to suggest the most relevant documentation URLs.
final toc = await DocsTableOfContents.getTableOfContents(session);
final urls = await genAi.generateUrlList(
systemPrompt: Prompts.instance.get('search_toc')! + toc,
Expand All @@ -26,6 +30,7 @@ Future<List<RAGDocument>> searchDocumentation(
],
);

// Load the referenced documents from the database.
for (final url in urls) {
var document = await RAGDocument.db.findFirstRow(
session,
Expand All @@ -40,15 +45,18 @@ Future<List<RAGDocument>> searchDocumentation(
return documents;
}

/// Searches GitHub discussions stored in the RAG database for similar topics.
///
/// The [question] is transformed into the same style as stored discussions and
/// then embedded to find the closest matches.
Future<List<RAGDocument>> searchDiscussions(
Session session,
List<ChatMessage> conversation,
String question,
) async {
final genAi = GenerativeAi();

// Transform the question to a question to what it like looks like in the
// RAG database.
// Transform the question into the expected format stored in the database.
String transformedQuestion;
if (conversation.isEmpty) {
transformedQuestion = await genAi.generateSimpleAnswer(
Expand All @@ -62,18 +70,18 @@ Future<List<RAGDocument>> searchDiscussions(
conversation: conversation,
);

// Concatenate the answer stream.
// Concatenate the streamed answer into a single string.
var answer = '';
await for (var chunk in answerStream) {
answer += chunk;
}
transformedQuestion = answer;
}

// Create an embedding for the question.
// Create an embedding for the transformed question.
final embedding = await genAi.generateEmbedding(transformedQuestion);

// Find the most similar question in the RAG database.
// Find the most similar discussion in the database using cosine distance.
final documents = await RAGDocument.db.find(
session,
orderBy: (rag) => rag.embedding.distanceCosine(embedding),
Expand Down
Loading