Merged
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed
- Fixed issue where chat threads created via the `/api/chat/blocking` endpoint would not have any messages when called without authentication. [#907](https://github.com/sourcebot-dev/sourcebot/pull/907)

### Changed
- Added `chatId` to all chat related posthog events. [#907](https://github.com/sourcebot-dev/sourcebot/pull/907)
Comment on lines +13 to +14

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Minor wording and categorization nits on the chatId entry.

Three small issues on line 14:

  1. Wrong section — the verb "Added" signals a new feature/field, which belongs under ### Added rather than ### Changed. Existing entries that introduce new fields consistently live in ### Added (e.g. "Added \install_id` to PostHog event properties."` in v4.10.30).
  2. CapitalizationposthogPostHog (consistent with every other reference in the file, e.g. lines 56, 190, 531).
  3. Missing hyphenchat relatedchat-related (compound modifier before a noun; also flagged by static analysis).
✏️ Proposed fix
-### Changed-- Added `chatId` to all chat related posthog events. [`#907`](https://github.com/sourcebot-dev/sourcebot/pull/907)+### Added+- Added `chatId` to all chat-related PostHog events. [`#907`](https://github.com/sourcebot-dev/sourcebot/pull/907)
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
### Changed
- Added `chatId` to all chatrelated posthog events. [#907](https://github.com/sourcebot-dev/sourcebot/pull/907)
### Added
- Added `chatId` to all chat-related PostHog events. [`#907`](https://github.com/sourcebot-dev/sourcebot/pull/907)
🧰 Tools
🪛 LanguageTool

[grammar] ~14-~14: Use a hyphen to join words.
Context: ...### Changed - Added chatId to all chat related posthog events. [#907](https://g...

(QB_NEW_EN_HYPHEN)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@CHANGELOG.md` around lines 13 - 14, Move the `chatId` line from the "Changed"
section into the "Added" section and update its wording to read: use "PostHog"
(capital P and H) and hyphenate "chat-related", e.g. "Added `chatId` to all
chat-related PostHog event properties." so it matches existing changelog
conventions.


## [4.11.2] - 2026-02-18

### Fixed
Expand Down
35 changes: 27 additions & 8 deletions packages/web/src/app/api/(server)/chat/blocking/route.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { sew } from "@/actions";
import { _getConfiguredLanguageModelsFull, _getAISDKLanguageModelAndOptions, updateChatMessages, generateAndUpdateChatNameFromMessage } from "@/features/chat/actions";
import { _getConfiguredLanguageModelsFull, _getAISDKLanguageModelAndOptions, _updateChatMessages, generateAndUpdateChatNameFromMessage, _generateChatNameFromMessage } from "@/features/chat/actions";
import { LanguageModelInfo, languageModelInfoSchema, SBChatMessage, SearchScope } from "@/features/chat/types";
import { convertLLMOutputToPortableMarkdown, getAnswerPartFromAssistantMessage, getLanguageModelKey } from "@/features/chat/utils";
import { ErrorCode } from "@/lib/errorCodes";
Expand All@@ -15,6 +15,7 @@ import { z } from "zod";
import { createMessageStream } from "../route";
import { InferUIMessageChunk, UITools, UIDataTypes, UIMessage } from "ai";
import { apiHandler } from "@/lib/apiHandler";
import { captureEvent } from "@/lib/posthog";

const logger = createLogger('chat-blocking-api');

Expand DownExpand Up@@ -115,6 +116,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
});

await captureEvent('wa_chat_thread_created', {
chatId: chat.id,
isAnonymous: !user,
});

// Run the agent to completion
logger.debug(`Starting blocking agent for chat ${chat.id}`, {
chatId: chat.id,
Expand DownExpand Up@@ -155,7 +161,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
// We'll capture the final messages and usage from the stream
let finalMessages: SBChatMessage[] = [];

await captureEvent('wa_chat_message_sent', {
chatId: chat.id,
messageCount: 1,
});

const stream = await createMessageStream({
chatId: chat.id,
messages: [userMessage],
selectedSearchScopes,
model,
Expand All@@ -180,21 +192,28 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
})

await Promise.all([
const [_, name] = await Promise.all([
// Consume the stream fully to trigger onFinish
blockStreamUntilFinish(stream),
// Generate and update the chat name
generateAndUpdateChatNameFromMessage({
chatId: chat.id,
languageModelId: languageModelConfig.model,
_generateChatNameFromMessage({
message: query,
languageModelConfig,
})
]);

// Persist the messages to the chat
await updateChatMessages({
chatId: chat.id,
messages: finalMessages,
await _updateChatMessages({ chatId: chat.id, messages: finalMessages, prisma });
Comment thread
brendan-kellam marked this conversation as resolved.

// Update the chat name
await prisma.chat.update({
where: {
id: chat.id,
orgId: org.id,
},
data: {
name: name,
},
});

// Extract the answer text from the assistant message
Expand Down
17 changes: 12 additions & 5 deletions packages/web/src/app/api/(server)/chat/route.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { sew } from "@/actions";
import { _getConfiguredLanguageModelsFull, _getAISDKLanguageModelAndOptions, updateChatMessages, _isOwnerOfChat } from "@/features/chat/actions";
import { _getConfiguredLanguageModelsFull, _getAISDKLanguageModelAndOptions, _updateChatMessages, _isOwnerOfChat } from "@/features/chat/actions";
import { createAgentStream } from "@/features/chat/agent";
import { additionalChatRequestParamsSchema, LanguageModelInfo, SBChatMessage, SearchScope } from "@/features/chat/types";
import { getAnswerPartFromAssistantMessage, getLanguageModelKey } from "@/features/chat/utils";
Expand All@@ -12,6 +12,7 @@ import { LanguageModelV2 as AISDKLanguageModelV2 } from "@ai-sdk/provider";
import * as Sentry from "@sentry/nextjs";
import { PrismaClient } from "@sourcebot/db";
import { createLogger } from "@sourcebot/shared";
import { captureEvent } from "@/lib/posthog";
import {
createUIMessageStream,
createUIMessageStreamResponse,
Expand DownExpand Up@@ -88,7 +89,13 @@ export const POST = apiHandler(async (req: NextRequest) => {

const { model, providerOptions } = await _getAISDKLanguageModelAndOptions(languageModelConfig);

await captureEvent('wa_chat_message_sent', {
chatId: id,
messageCount: messages.length,
});

const stream = await createMessageStream({
chatId: id,
messages,
selectedSearchScopes,
model,
Expand All@@ -97,10 +104,7 @@ export const POST = apiHandler(async (req: NextRequest) => {
orgId: org.id,
prisma,
onFinish: async ({ messages }) => {
await updateChatMessages({
chatId: id,
messages
});
await _updateChatMessages({ chatId: id, messages, prisma });
},
onError: (error: unknown) => {
logger.error(error);
Expand DownExpand Up@@ -146,6 +150,7 @@ const mergeStreamAsync = async (stream: StreamTextResult<any, any>, writer: UIMe
}

interface CreateMessageStreamResponseProps {
chatId: string;
messages: SBChatMessage[];
selectedSearchScopes: SearchScope[];
model: AISDKLanguageModelV2;
Expand All@@ -158,6 +163,7 @@ interface CreateMessageStreamResponseProps {
}

export const createMessageStream = async ({
chatId,
messages,
selectedSearchScopes,
model,
Expand DownExpand Up@@ -242,6 +248,7 @@ export const createMessageStream = async ({
});
},
traceId,
chatId,
});

await mergeStreamAsync(researchStream, writer, {
Expand Down
163 changes: 99 additions & 64 deletions packages/web/src/features/chat/actions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,7 @@
import { sew } from "@/actions";
import { getAuditService } from "@/ee/features/audit/factory";
import { ErrorCode } from "@/lib/errorCodes";
import { notFound, serviceErrorResponse } from "@/lib/serviceError";
import { notFound, ServiceError } from "@/lib/serviceError";
import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock';
import { AnthropicProviderOptions, createAnthropic } from '@ai-sdk/anthropic';
import { createAzure } from '@ai-sdk/azure';
Expand DownExpand Up@@ -62,7 +62,7 @@ export const _isOwnerOfChat = async (chat: Chat, user: User | undefined): Promis
/**
* Checks if a user has been explicitly shared access to a chat.
*/
export const _hasSharedAccess = async ({prisma, chatId, userId}: {prisma: PrismaClient, chatId: string, userId: string | undefined}): Promise<boolean> => {
export const _hasSharedAccess = async ({prisma, chatId, userId}: {prisma: PrismaClient, chatId: string, userId: string | undefined}): Promise<boolean> => {
if (!userId) {
return false;
}
Expand All@@ -79,6 +79,55 @@ export const _hasSharedAccess = async ({prisma, chatId, userId}: {prisma: Prisma
return share !== null;
};

export const _updateChatMessages = async ({ chatId, messages, prisma }: { chatId: string, messages: SBChatMessage[], prisma: PrismaClient }) => {
await prisma.chat.update({
where: {
id: chatId,
},
data: {
messages: messages as unknown as Prisma.InputJsonValue,
},
});

if (env.DEBUG_WRITE_CHAT_MESSAGES_TO_FILE) {
const chatDir = path.join(env.DATA_CACHE_DIR, 'chats');
if (!fs.existsSync(chatDir)) {
fs.mkdirSync(chatDir, { recursive: true });
}

const chatFile = path.join(chatDir, `${chatId}.json`);
fs.writeFileSync(chatFile, JSON.stringify(messages, null, 2));

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
This path depends on a
user-provided value
.

Copilot Autofix

AI 7 months ago

In general, to fix uncontrolled data in a path expression when only a filename is needed, validate or sanitize the user-controlled value before using it, e.g. by whitelisting allowed characters or using a library like sanitize-filename. This prevents .., path separators, or other special characters from influencing directory traversal.

For this specific case in packages/web/src/features/chat/actions.ts, the simplest safe fix without changing existing functionality is:

  • Keep chatDir as-is (path.join(env.DATA_CACHE_DIR, 'chats')).
  • Before constructing chatFile, derive a safe filename from chatId:
    • Either strictly validate that chatId matches an expected pattern (for example a UUID or alphanumeric string) and reject/short-circuit if it does not; or
    • Sanitize chatId to strip out disallowed characters and ensure it cannot contain path separators or ...
  • Then build chatFile with this safe filename and write to it.

Because we cannot assume anything about how chatId is formatted elsewhere, a robust fix inside this snippet is to sanitize / normalize chatId to an allow-listed pattern (e.g. [A-Za-z0-9_-]) and fall back to a deterministic safe name if sanitization would yield an empty string. This avoids introducing new dependencies while keeping behavior for normal IDs identical.

Concretely:

  • In _updateChatMessages, right before const chatFile = path.join(chatDir, \${chatId}.json`);, create a safeChatIdvariable that strips all characters except[A-Za-z0-9_-]`.
  • If safeChatId is empty after stripping, assign a safe placeholder like "unknown-chat".
  • Use safeChatId instead of chatId in the filename interpolation.

No new imports are required; we can do this with basic string replacement and a regular expression. The only file to modify is packages/web/src/features/chat/actions.ts, within the _updateChatMessages function around lines 92–100.

Suggested changeset 1
packages/web/src/features/chat/actions.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/packages/web/src/features/chat/actions.ts b/packages/web/src/features/chat/actions.ts
--- a/packages/web/src/features/chat/actions.ts
+++ b/packages/web/src/features/chat/actions.ts
@@ -95,7 +95,13 @@
fs.mkdirSync(chatDir, { recursive: true });
}
- const chatFile = path.join(chatDir, `${chatId}.json`);
+ // Sanitize chatId to prevent path traversal or invalid filename characters.
+ let safeChatId = chatId.replace(/[^a-zA-Z0-9_-]/g, '');
+ if (!safeChatId) {
+ safeChatId = 'unknown-chat';
+ }
+
+ const chatFile = path.join(chatDir, `${safeChatId}.json`);
fs.writeFileSync(chatFile, JSON.stringify(messages, null, 2));
}
};
EOF
@@ -95,7 +95,13 @@
fs.mkdirSync(chatDir,{recursive: true});
}

constchatFile=path.join(chatDir,`${chatId}.json`);
// Sanitize chatId to prevent path traversal or invalid filename characters.
letsafeChatId=chatId.replace(/[^a-zA-Z0-9_-]/g,'');
if(!safeChatId){
safeChatId='unknown-chat';
}

constchatFile=path.join(chatDir,`${safeChatId}.json`);
fs.writeFileSync(chatFile,JSON.stringify(messages,null,2));
}
};
Copilot is powered by AI and may make mistakes. Always verify output.
Unable to commit as this autofix suggestion is now outdated

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ignoring since a) this code is only called when a debug flag is set, and b) the chatId is a cuid.

}
Comment thread
brendan-kellam marked this conversation as resolved.
};


export const _generateChatNameFromMessage = async ({ message, languageModelConfig }: { message: string, languageModelConfig: LanguageModel }) => {
const { model } = await _getAISDKLanguageModelAndOptions(languageModelConfig);

const prompt = `Convert this question into a short topic title (max 50 characters).

Rules:
- Do NOT include question words (what, where, how, why, when, which)
- Do NOT end with a question mark
- Capitalize the first letter of the title
- Focus on the subject/topic being discussed
- Make it sound like a file name or category

Examples:
"Where is the authentication code?" → "Authentication Code"
"How to setup the database?" → "Database Setup"
"What are the API endpoints?" → "API Endpoints"

User question: ${message}`;

const result = await generateText({
model,
prompt,
});

return result.text;
}
Comment thread
brendan-kellam marked this conversation as resolved.

export const createChat = async () => sew(() =>
withOptionalAuthV2(async ({ org, user, prisma }) => {
const isGuestUser = user === undefined;
Expand DownExpand Up@@ -112,6 +161,11 @@ export const createChat = async () => sew(() =>
});
}

await captureEvent('wa_chat_thread_created', {
chatId: chat.id,
isAnonymous: isGuestUser,
});

return {
id: chat.id,
isAnonymous: isGuestUser,
Expand All@@ -133,7 +187,7 @@ export const getChatInfo = async ({ chatId }: { chatId: string }) => sew(() =>
}

const isOwner = await _isOwnerOfChat(chat, user);
const isSharedWithUser = await _hasSharedAccess({prisma, chatId, userId: user?.id});
const isSharedWithUser = await _hasSharedAccess({prisma, chatId, userId: user?.id});

// Private chats can only be viewed by the owner or users it's been shared with
if (chat.visibility === ChatVisibility.PRIVATE && !isOwner && !isSharedWithUser) {
Expand DownExpand Up@@ -170,24 +224,7 @@ export const updateChatMessages = async ({ chatId, messages }: { chatId: string,
return notFound();
}

await prisma.chat.update({
where: {
id: chatId,
},
data: {
messages: messages as unknown as Prisma.InputJsonValue,
},
});

if (env.DEBUG_WRITE_CHAT_MESSAGES_TO_FILE) {
const chatDir = path.join(env.DATA_CACHE_DIR, 'chats');
if (!fs.existsSync(chatDir)) {
fs.mkdirSync(chatDir, { recursive: true });
}

const chatFile = path.join(chatDir, `${chatId}.json`);
fs.writeFileSync(chatFile, JSON.stringify(messages, null, 2));
}
await _updateChatMessages({ chatId, messages, prisma });

return {
success: true,
Expand DownExpand Up@@ -295,54 +332,52 @@ export const updateChatVisibility = async ({ chatId, visibility }: { chatId: str
);

export const generateAndUpdateChatNameFromMessage = async ({ chatId, languageModelId, message }: { chatId: string, languageModelId: string, message: string }) => sew(() =>
withOptionalAuthV2(async () => {
// From the language model ID, attempt to find the
// corresponding config in `config.json`.
const languageModelConfig =
(await _getConfiguredLanguageModelsFull())
.find((model) => model.model === languageModelId);

if (!languageModelConfig) {
return serviceErrorResponse({
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: `Language model ${languageModelId} is not configured.`,
});
}

const { model } = await _getAISDKLanguageModelAndOptions(languageModelConfig);

const prompt = `Convert this question into a short topic title (max 50 characters).
withOptionalAuthV2(async ({ prisma, user, org }) => {
const chat = await prisma.chat.findUnique({
where: {
id: chatId,
orgId: org.id,
},
});

Rules:
- Do NOT include question words (what, where, how, why, when, which)
- Do NOT end with a question mark
- Capitalize the first letter of the title
- Focus on the subject/topic being discussed
- Make it sound like a file name or category
if (!chat) {
return notFound();
}

Examples:
"Where is the authentication code?" → "Authentication Code"
"How to setup the database?" → "Database Setup"
"What are the API endpoints?" → "API Endpoints"
const isOwner = await _isOwnerOfChat(chat, user);
if (!isOwner) {
return notFound();
}

User question: ${message}`;
const languageModelConfig =
(await _getConfiguredLanguageModelsFull())
.find((model) => model.model === languageModelId);

const result = await generateText({
model,
prompt,
});
if (!languageModelConfig) {
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: `Language model ${languageModelId} is not configured.`,
} satisfies ServiceError;
}
Comment thread
brendan-kellam marked this conversation as resolved.

await updateChatName({
chatId,
name: result.text,
});
const name = await _generateChatNameFromMessage({ message, languageModelConfig });

return {
success: true,
}
await prisma.chat.update({
where: {
id: chatId,
orgId: org.id,
},
data: {
name: name,
},
})
)

return {
success: true,
}
})
)

export const deleteChat = async ({ chatId }: { chatId: string }) => sew(() =>
withAuthV2(async ({ org, user, prisma }) => {
Expand DownExpand Up@@ -436,7 +471,7 @@ export const duplicateChat = async ({ chatId, newName }: { chatId: string, newNa

// Check if user can access the chat (owner, shared, or public)
const isOwner = await _isOwnerOfChat(originalChat, user);
const isSharedWithUser = await _hasSharedAccess({prisma, chatId, userId: user?.id});
const isSharedWithUser = await _hasSharedAccess({prisma, chatId, userId: user?.id});
if (originalChat.visibility === ChatVisibility.PRIVATE && !isOwner && !isSharedWithUser) {
return notFound();
}
Expand DownExpand Up@@ -617,7 +652,7 @@ export const submitFeedback = async ({
}

// When a chat is private, only the creator or shared users can submit feedback.
const isSharedWithUser = await _hasSharedAccess({prisma, chatId, userId: user?.id});
const isSharedWithUser = await _hasSharedAccess({prisma, chatId, userId: user?.id});
if (chat.visibility === ChatVisibility.PRIVATE && chat.createdById !== user?.id && !isSharedWithUser) {
return notFound();
}
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed
- Fixed issue where chat threads created via the `/api/chat/blocking` endpoint would not have any messages when called without authentication. [#907](https://github.com/sourcebot-dev/sourcebot/pull/907)

### Changed
- Added `chatId` to all chat related posthog events. [#907](https://github.com/sourcebot-dev/sourcebot/pull/907)
Comment on lines +13 to +14

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Minor wording and categorization nits on the chatId entry.

Three small issues on line 14:

  1. Wrong section — the verb "Added" signals a new feature/field, which belongs under ### Added rather than ### Changed. Existing entries that introduce new fields consistently live in ### Added (e.g. "Added \install_id` to PostHog event properties."` in v4.10.30).
  2. CapitalizationposthogPostHog (consistent with every other reference in the file, e.g. lines 56, 190, 531).
  3. Missing hyphenchat relatedchat-related (compound modifier before a noun; also flagged by static analysis).
✏️ Proposed fix
-### Changed-- Added `chatId` to all chat related posthog events. [`#907`](https://github.com/sourcebot-dev/sourcebot/pull/907)+### Added+- Added `chatId` to all chat-related PostHog events. [`#907`](https://github.com/sourcebot-dev/sourcebot/pull/907)
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
### Changed
- Added `chatId` to all chatrelated posthog events. [#907](https://github.com/sourcebot-dev/sourcebot/pull/907)
### Added
- Added `chatId` to all chat-related PostHog events. [`#907`](https://github.com/sourcebot-dev/sourcebot/pull/907)
🧰 Tools
🪛 LanguageTool

[grammar] ~14-~14: Use a hyphen to join words.
Context: ...### Changed - Added chatId to all chat related posthog events. [#907](https://g...

(QB_NEW_EN_HYPHEN)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@CHANGELOG.md` around lines 13 - 14, Move the `chatId` line from the "Changed"
section into the "Added" section and update its wording to read: use "PostHog"
(capital P and H) and hyphenate "chat-related", e.g. "Added `chatId` to all
chat-related PostHog event properties." so it matches existing changelog
conventions.


## [4.11.2] - 2026-02-18

### Fixed
Expand Down
35 changes: 27 additions & 8 deletions packages/web/src/app/api/(server)/chat/blocking/route.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { sew } from "@/actions";
import { _getConfiguredLanguageModelsFull, _getAISDKLanguageModelAndOptions, updateChatMessages, generateAndUpdateChatNameFromMessage } from "@/features/chat/actions";
import { _getConfiguredLanguageModelsFull, _getAISDKLanguageModelAndOptions, _updateChatMessages, generateAndUpdateChatNameFromMessage, _generateChatNameFromMessage } from "@/features/chat/actions";
import { LanguageModelInfo, languageModelInfoSchema, SBChatMessage, SearchScope } from "@/features/chat/types";
import { convertLLMOutputToPortableMarkdown, getAnswerPartFromAssistantMessage, getLanguageModelKey } from "@/features/chat/utils";
import { ErrorCode } from "@/lib/errorCodes";
Expand All@@ -15,6 +15,7 @@ import { z } from "zod";
import { createMessageStream } from "../route";
import { InferUIMessageChunk, UITools, UIDataTypes, UIMessage } from "ai";
import { apiHandler } from "@/lib/apiHandler";
import { captureEvent } from "@/lib/posthog";

const logger = createLogger('chat-blocking-api');

Expand DownExpand Up@@ -115,6 +116,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
});

await captureEvent('wa_chat_thread_created', {
chatId: chat.id,
isAnonymous: !user,
});

// Run the agent to completion
logger.debug(`Starting blocking agent for chat ${chat.id}`, {
chatId: chat.id,
Expand DownExpand Up@@ -155,7 +161,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
// We'll capture the final messages and usage from the stream
let finalMessages: SBChatMessage[] = [];

await captureEvent('wa_chat_message_sent', {
chatId: chat.id,
messageCount: 1,
});

const stream = await createMessageStream({
chatId: chat.id,
messages: [userMessage],
selectedSearchScopes,
model,
Expand All@@ -180,21 +192,28 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
})

await Promise.all([
const [_, name] = await Promise.all([
// Consume the stream fully to trigger onFinish
blockStreamUntilFinish(stream),
// Generate and update the chat name
generateAndUpdateChatNameFromMessage({
chatId: chat.id,
languageModelId: languageModelConfig.model,
_generateChatNameFromMessage({
message: query,
languageModelConfig,
})
]);

// Persist the messages to the chat
await updateChatMessages({
chatId: chat.id,
messages: finalMessages,
await _updateChatMessages({ chatId: chat.id, messages: finalMessages, prisma });
Comment thread
brendan-kellam marked this conversation as resolved.

// Update the chat name
await prisma.chat.update({
where: {
id: chat.id,
orgId: org.id,
},
data: {
name: name,
},
});

// Extract the answer text from the assistant message
Expand Down
17 changes: 12 additions & 5 deletions packages/web/src/app/api/(server)/chat/route.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { sew } from "@/actions";
import { _getConfiguredLanguageModelsFull, _getAISDKLanguageModelAndOptions, updateChatMessages, _isOwnerOfChat } from "@/features/chat/actions";
import { _getConfiguredLanguageModelsFull, _getAISDKLanguageModelAndOptions, _updateChatMessages, _isOwnerOfChat } from "@/features/chat/actions";
import { createAgentStream } from "@/features/chat/agent";
import { additionalChatRequestParamsSchema, LanguageModelInfo, SBChatMessage, SearchScope } from "@/features/chat/types";
import { getAnswerPartFromAssistantMessage, getLanguageModelKey } from "@/features/chat/utils";
Expand All@@ -12,6 +12,7 @@ import { LanguageModelV2 as AISDKLanguageModelV2 } from "@ai-sdk/provider";
import * as Sentry from "@sentry/nextjs";
import { PrismaClient } from "@sourcebot/db";
import { createLogger } from "@sourcebot/shared";
import { captureEvent } from "@/lib/posthog";
import {
createUIMessageStream,
createUIMessageStreamResponse,
Expand DownExpand Up@@ -88,7 +89,13 @@ export const POST = apiHandler(async (req: NextRequest) => {

const { model, providerOptions } = await _getAISDKLanguageModelAndOptions(languageModelConfig);

await captureEvent('wa_chat_message_sent', {
chatId: id,
messageCount: messages.length,
});

const stream = await createMessageStream({
chatId: id,
messages,
selectedSearchScopes,
model,
Expand All@@ -97,10 +104,7 @@ export const POST = apiHandler(async (req: NextRequest) => {
orgId: org.id,
prisma,
onFinish: async ({ messages }) => {
await updateChatMessages({
chatId: id,
messages
});
await _updateChatMessages({ chatId: id, messages, prisma });
},
onError: (error: unknown) => {
logger.error(error);
Expand DownExpand Up@@ -146,6 +150,7 @@ const mergeStreamAsync = async (stream: StreamTextResult<any, any>, writer: UIMe
}

interface CreateMessageStreamResponseProps {
chatId: string;
messages: SBChatMessage[];
selectedSearchScopes: SearchScope[];
model: AISDKLanguageModelV2;
Expand All@@ -158,6 +163,7 @@ interface CreateMessageStreamResponseProps {
}

export const createMessageStream = async ({
chatId,
messages,
selectedSearchScopes,
model,
Expand DownExpand Up@@ -242,6 +248,7 @@ export const createMessageStream = async ({
});
},
traceId,
chatId,
});

await mergeStreamAsync(researchStream, writer, {
Expand Down
163 changes: 99 additions & 64 deletions packages/web/src/features/chat/actions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,7 @@
import { sew } from "@/actions";
import { getAuditService } from "@/ee/features/audit/factory";
import { ErrorCode } from "@/lib/errorCodes";
import { notFound, serviceErrorResponse } from "@/lib/serviceError";
import { notFound, ServiceError } from "@/lib/serviceError";
import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock';
import { AnthropicProviderOptions, createAnthropic } from '@ai-sdk/anthropic';
import { createAzure } from '@ai-sdk/azure';
Expand DownExpand Up@@ -62,7 +62,7 @@ export const _isOwnerOfChat = async (chat: Chat, user: User | undefined): Promis
/**
* Checks if a user has been explicitly shared access to a chat.
*/
export const _hasSharedAccess = async ({prisma, chatId, userId}: {prisma: PrismaClient, chatId: string, userId: string | undefined}): Promise<boolean> => {
export const _hasSharedAccess = async ({prisma, chatId, userId}: {prisma: PrismaClient, chatId: string, userId: string | undefined}): Promise<boolean> => {
if (!userId) {
return false;
}
Expand All@@ -79,6 +79,55 @@ export const _hasSharedAccess = async ({prisma, chatId, userId}: {prisma: Prisma
return share !== null;
};

export const _updateChatMessages = async ({ chatId, messages, prisma }: { chatId: string, messages: SBChatMessage[], prisma: PrismaClient }) => {
await prisma.chat.update({
where: {
id: chatId,
},
data: {
messages: messages as unknown as Prisma.InputJsonValue,
},
});

if (env.DEBUG_WRITE_CHAT_MESSAGES_TO_FILE) {
const chatDir = path.join(env.DATA_CACHE_DIR, 'chats');
if (!fs.existsSync(chatDir)) {
fs.mkdirSync(chatDir, { recursive: true });
}

const chatFile = path.join(chatDir, `${chatId}.json`);
fs.writeFileSync(chatFile, JSON.stringify(messages, null, 2));

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
This path depends on a
user-provided value
.

Copilot Autofix

AI 7 months ago

In general, to fix uncontrolled data in a path expression when only a filename is needed, validate or sanitize the user-controlled value before using it, e.g. by whitelisting allowed characters or using a library like sanitize-filename. This prevents .., path separators, or other special characters from influencing directory traversal.

For this specific case in packages/web/src/features/chat/actions.ts, the simplest safe fix without changing existing functionality is:

  • Keep chatDir as-is (path.join(env.DATA_CACHE_DIR, 'chats')).
  • Before constructing chatFile, derive a safe filename from chatId:
    • Either strictly validate that chatId matches an expected pattern (for example a UUID or alphanumeric string) and reject/short-circuit if it does not; or
    • Sanitize chatId to strip out disallowed characters and ensure it cannot contain path separators or ...
  • Then build chatFile with this safe filename and write to it.

Because we cannot assume anything about how chatId is formatted elsewhere, a robust fix inside this snippet is to sanitize / normalize chatId to an allow-listed pattern (e.g. [A-Za-z0-9_-]) and fall back to a deterministic safe name if sanitization would yield an empty string. This avoids introducing new dependencies while keeping behavior for normal IDs identical.

Concretely:

  • In _updateChatMessages, right before const chatFile = path.join(chatDir, \${chatId}.json`);, create a safeChatIdvariable that strips all characters except[A-Za-z0-9_-]`.
  • If safeChatId is empty after stripping, assign a safe placeholder like "unknown-chat".
  • Use safeChatId instead of chatId in the filename interpolation.

No new imports are required; we can do this with basic string replacement and a regular expression. The only file to modify is packages/web/src/features/chat/actions.ts, within the _updateChatMessages function around lines 92–100.

Suggested changeset 1
packages/web/src/features/chat/actions.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/packages/web/src/features/chat/actions.ts b/packages/web/src/features/chat/actions.ts
--- a/packages/web/src/features/chat/actions.ts
+++ b/packages/web/src/features/chat/actions.ts
@@ -95,7 +95,13 @@
fs.mkdirSync(chatDir, { recursive: true });
}
- const chatFile = path.join(chatDir, `${chatId}.json`);
+ // Sanitize chatId to prevent path traversal or invalid filename characters.
+ let safeChatId = chatId.replace(/[^a-zA-Z0-9_-]/g, '');
+ if (!safeChatId) {
+ safeChatId = 'unknown-chat';
+ }
+
+ const chatFile = path.join(chatDir, `${safeChatId}.json`);
fs.writeFileSync(chatFile, JSON.stringify(messages, null, 2));
}
};
EOF
@@ -95,7 +95,13 @@
fs.mkdirSync(chatDir,{recursive: true});
}

constchatFile=path.join(chatDir,`${chatId}.json`);
// Sanitize chatId to prevent path traversal or invalid filename characters.
letsafeChatId=chatId.replace(/[^a-zA-Z0-9_-]/g,'');
if(!safeChatId){
safeChatId='unknown-chat';
}

constchatFile=path.join(chatDir,`${safeChatId}.json`);
fs.writeFileSync(chatFile,JSON.stringify(messages,null,2));
}
};
Copilot is powered by AI and may make mistakes. Always verify output.
Unable to commit as this autofix suggestion is now outdated

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ignoring since a) this code is only called when a debug flag is set, and b) the chatId is a cuid.

}
Comment thread
brendan-kellam marked this conversation as resolved.
};


export const _generateChatNameFromMessage = async ({ message, languageModelConfig }: { message: string, languageModelConfig: LanguageModel }) => {
const { model } = await _getAISDKLanguageModelAndOptions(languageModelConfig);

const prompt = `Convert this question into a short topic title (max 50 characters).

Rules:
- Do NOT include question words (what, where, how, why, when, which)
- Do NOT end with a question mark
- Capitalize the first letter of the title
- Focus on the subject/topic being discussed
- Make it sound like a file name or category

Examples:
"Where is the authentication code?" → "Authentication Code"
"How to setup the database?" → "Database Setup"
"What are the API endpoints?" → "API Endpoints"

User question: ${message}`;

const result = await generateText({
model,
prompt,
});

return result.text;
}
Comment thread
brendan-kellam marked this conversation as resolved.

export const createChat = async () => sew(() =>
withOptionalAuthV2(async ({ org, user, prisma }) => {
const isGuestUser = user === undefined;
Expand DownExpand Up@@ -112,6 +161,11 @@ export const createChat = async () => sew(() =>
});
}

await captureEvent('wa_chat_thread_created', {
chatId: chat.id,
isAnonymous: isGuestUser,
});

return {
id: chat.id,
isAnonymous: isGuestUser,
Expand All@@ -133,7 +187,7 @@ export const getChatInfo = async ({ chatId }: { chatId: string }) => sew(() =>
}

const isOwner = await _isOwnerOfChat(chat, user);
const isSharedWithUser = await _hasSharedAccess({prisma, chatId, userId: user?.id});
const isSharedWithUser = await _hasSharedAccess({prisma, chatId, userId: user?.id});

// Private chats can only be viewed by the owner or users it's been shared with
if (chat.visibility === ChatVisibility.PRIVATE && !isOwner && !isSharedWithUser) {
Expand DownExpand Up@@ -170,24 +224,7 @@ export const updateChatMessages = async ({ chatId, messages }: { chatId: string,
return notFound();
}

await prisma.chat.update({
where: {
id: chatId,
},
data: {
messages: messages as unknown as Prisma.InputJsonValue,
},
});

if (env.DEBUG_WRITE_CHAT_MESSAGES_TO_FILE) {
const chatDir = path.join(env.DATA_CACHE_DIR, 'chats');
if (!fs.existsSync(chatDir)) {
fs.mkdirSync(chatDir, { recursive: true });
}

const chatFile = path.join(chatDir, `${chatId}.json`);
fs.writeFileSync(chatFile, JSON.stringify(messages, null, 2));
}
await _updateChatMessages({ chatId, messages, prisma });

return {
success: true,
Expand DownExpand Up@@ -295,54 +332,52 @@ export const updateChatVisibility = async ({ chatId, visibility }: { chatId: str
);

export const generateAndUpdateChatNameFromMessage = async ({ chatId, languageModelId, message }: { chatId: string, languageModelId: string, message: string }) => sew(() =>
withOptionalAuthV2(async () => {
// From the language model ID, attempt to find the
// corresponding config in `config.json`.
const languageModelConfig =
(await _getConfiguredLanguageModelsFull())
.find((model) => model.model === languageModelId);

if (!languageModelConfig) {
return serviceErrorResponse({
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: `Language model ${languageModelId} is not configured.`,
});
}

const { model } = await _getAISDKLanguageModelAndOptions(languageModelConfig);

const prompt = `Convert this question into a short topic title (max 50 characters).
withOptionalAuthV2(async ({ prisma, user, org }) => {
const chat = await prisma.chat.findUnique({
where: {
id: chatId,
orgId: org.id,
},
});

Rules:
- Do NOT include question words (what, where, how, why, when, which)
- Do NOT end with a question mark
- Capitalize the first letter of the title
- Focus on the subject/topic being discussed
- Make it sound like a file name or category
if (!chat) {
return notFound();
}

Examples:
"Where is the authentication code?" → "Authentication Code"
"How to setup the database?" → "Database Setup"
"What are the API endpoints?" → "API Endpoints"
const isOwner = await _isOwnerOfChat(chat, user);
if (!isOwner) {
return notFound();
}

User question: ${message}`;
const languageModelConfig =
(await _getConfiguredLanguageModelsFull())
.find((model) => model.model === languageModelId);

const result = await generateText({
model,
prompt,
});
if (!languageModelConfig) {
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: `Language model ${languageModelId} is not configured.`,
} satisfies ServiceError;
}
Comment thread
brendan-kellam marked this conversation as resolved.

await updateChatName({
chatId,
name: result.text,
});
const name = await _generateChatNameFromMessage({ message, languageModelConfig });

return {
success: true,
}
await prisma.chat.update({
where: {
id: chatId,
orgId: org.id,
},
data: {
name: name,
},
})
)

return {
success: true,
}
})
)

export const deleteChat = async ({ chatId }: { chatId: string }) => sew(() =>
withAuthV2(async ({ org, user, prisma }) => {
Expand DownExpand Up@@ -436,7 +471,7 @@ export const duplicateChat = async ({ chatId, newName }: { chatId: string, newNa

// Check if user can access the chat (owner, shared, or public)
const isOwner = await _isOwnerOfChat(originalChat, user);
const isSharedWithUser = await _hasSharedAccess({prisma, chatId, userId: user?.id});
const isSharedWithUser = await _hasSharedAccess({prisma, chatId, userId: user?.id});
if (originalChat.visibility === ChatVisibility.PRIVATE && !isOwner && !isSharedWithUser) {
return notFound();
}
Expand DownExpand Up@@ -617,7 +652,7 @@ export const submitFeedback = async ({
}

// When a chat is private, only the creator or shared users can submit feedback.
const isSharedWithUser = await _hasSharedAccess({prisma, chatId, userId: user?.id});
const isSharedWithUser = await _hasSharedAccess({prisma, chatId, userId: user?.id});
if (chat.visibility === ChatVisibility.PRIVATE && chat.createdById !== user?.id && !isSharedWithUser) {
return notFound();
}
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed
- Fixed issue where chat threads created via the `/api/chat/blocking` endpoint would not have any messages when called without authentication. [#907](https://github.com/sourcebot-dev/sourcebot/pull/907)

### Changed
- Added `chatId` to all chat related posthog events. [#907](https://github.com/sourcebot-dev/sourcebot/pull/907)
Comment on lines +13 to +14

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Minor wording and categorization nits on the chatId entry.

Three small issues on line 14:

  1. Wrong section — the verb "Added" signals a new feature/field, which belongs under ### Added rather than ### Changed. Existing entries that introduce new fields consistently live in ### Added (e.g. "Added \install_id` to PostHog event properties."` in v4.10.30).
  2. CapitalizationposthogPostHog (consistent with every other reference in the file, e.g. lines 56, 190, 531).
  3. Missing hyphenchat relatedchat-related (compound modifier before a noun; also flagged by static analysis).
✏️ Proposed fix
-### Changed-- Added `chatId` to all chat related posthog events. [`#907`](https://github.com/sourcebot-dev/sourcebot/pull/907)+### Added+- Added `chatId` to all chat-related PostHog events. [`#907`](https://github.com/sourcebot-dev/sourcebot/pull/907)
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
### Changed
- Added `chatId` to all chatrelated posthog events. [#907](https://github.com/sourcebot-dev/sourcebot/pull/907)
### Added
- Added `chatId` to all chat-related PostHog events. [`#907`](https://github.com/sourcebot-dev/sourcebot/pull/907)
🧰 Tools
🪛 LanguageTool

[grammar] ~14-~14: Use a hyphen to join words.
Context: ...### Changed - Added chatId to all chat related posthog events. [#907](https://g...

(QB_NEW_EN_HYPHEN)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@CHANGELOG.md` around lines 13 - 14, Move the `chatId` line from the "Changed"
section into the "Added" section and update its wording to read: use "PostHog"
(capital P and H) and hyphenate "chat-related", e.g. "Added `chatId` to all
chat-related PostHog event properties." so it matches existing changelog
conventions.


## [4.11.2] - 2026-02-18

### Fixed
Expand Down
35 changes: 27 additions & 8 deletions packages/web/src/app/api/(server)/chat/blocking/route.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { sew } from "@/actions";
import { _getConfiguredLanguageModelsFull, _getAISDKLanguageModelAndOptions, updateChatMessages, generateAndUpdateChatNameFromMessage } from "@/features/chat/actions";
import { _getConfiguredLanguageModelsFull, _getAISDKLanguageModelAndOptions, _updateChatMessages, generateAndUpdateChatNameFromMessage, _generateChatNameFromMessage } from "@/features/chat/actions";
import { LanguageModelInfo, languageModelInfoSchema, SBChatMessage, SearchScope } from "@/features/chat/types";
import { convertLLMOutputToPortableMarkdown, getAnswerPartFromAssistantMessage, getLanguageModelKey } from "@/features/chat/utils";
import { ErrorCode } from "@/lib/errorCodes";
Expand All@@ -15,6 +15,7 @@ import { z } from "zod";
import { createMessageStream } from "../route";
import { InferUIMessageChunk, UITools, UIDataTypes, UIMessage } from "ai";
import { apiHandler } from "@/lib/apiHandler";
import { captureEvent } from "@/lib/posthog";

const logger = createLogger('chat-blocking-api');

Expand DownExpand Up@@ -115,6 +116,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
});

await captureEvent('wa_chat_thread_created', {
chatId: chat.id,
isAnonymous: !user,
});

// Run the agent to completion
logger.debug(`Starting blocking agent for chat ${chat.id}`, {
chatId: chat.id,
Expand DownExpand Up@@ -155,7 +161,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
// We'll capture the final messages and usage from the stream
let finalMessages: SBChatMessage[] = [];

await captureEvent('wa_chat_message_sent', {
chatId: chat.id,
messageCount: 1,
});

const stream = await createMessageStream({
chatId: chat.id,
messages: [userMessage],
selectedSearchScopes,
model,
Expand All@@ -180,21 +192,28 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
})

await Promise.all([
const [_, name] = await Promise.all([
// Consume the stream fully to trigger onFinish
blockStreamUntilFinish(stream),
// Generate and update the chat name
generateAndUpdateChatNameFromMessage({
chatId: chat.id,
languageModelId: languageModelConfig.model,
_generateChatNameFromMessage({
message: query,
languageModelConfig,
})
]);

// Persist the messages to the chat
await updateChatMessages({
chatId: chat.id,
messages: finalMessages,
await _updateChatMessages({ chatId: chat.id, messages: finalMessages, prisma });
Comment thread
brendan-kellam marked this conversation as resolved.

// Update the chat name
await prisma.chat.update({
where: {
id: chat.id,
orgId: org.id,
},
data: {
name: name,
},
});

// Extract the answer text from the assistant message
Expand Down
17 changes: 12 additions & 5 deletions packages/web/src/app/api/(server)/chat/route.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { sew } from "@/actions";
import { _getConfiguredLanguageModelsFull, _getAISDKLanguageModelAndOptions, updateChatMessages, _isOwnerOfChat } from "@/features/chat/actions";
import { _getConfiguredLanguageModelsFull, _getAISDKLanguageModelAndOptions, _updateChatMessages, _isOwnerOfChat } from "@/features/chat/actions";
import { createAgentStream } from "@/features/chat/agent";
import { additionalChatRequestParamsSchema, LanguageModelInfo, SBChatMessage, SearchScope } from "@/features/chat/types";
import { getAnswerPartFromAssistantMessage, getLanguageModelKey } from "@/features/chat/utils";
Expand All@@ -12,6 +12,7 @@ import { LanguageModelV2 as AISDKLanguageModelV2 } from "@ai-sdk/provider";
import * as Sentry from "@sentry/nextjs";
import { PrismaClient } from "@sourcebot/db";
import { createLogger } from "@sourcebot/shared";
import { captureEvent } from "@/lib/posthog";
import {
createUIMessageStream,
createUIMessageStreamResponse,
Expand DownExpand Up@@ -88,7 +89,13 @@ export const POST = apiHandler(async (req: NextRequest) => {

const { model, providerOptions } = await _getAISDKLanguageModelAndOptions(languageModelConfig);

await captureEvent('wa_chat_message_sent', {
chatId: id,
messageCount: messages.length,
});

const stream = await createMessageStream({
chatId: id,
messages,
selectedSearchScopes,
model,
Expand All@@ -97,10 +104,7 @@ export const POST = apiHandler(async (req: NextRequest) => {
orgId: org.id,
prisma,
onFinish: async ({ messages }) => {
await updateChatMessages({
chatId: id,
messages
});
await _updateChatMessages({ chatId: id, messages, prisma });
},
onError: (error: unknown) => {
logger.error(error);
Expand DownExpand Up@@ -146,6 +150,7 @@ const mergeStreamAsync = async (stream: StreamTextResult<any, any>, writer: UIMe
}

interface CreateMessageStreamResponseProps {
chatId: string;
messages: SBChatMessage[];
selectedSearchScopes: SearchScope[];
model: AISDKLanguageModelV2;
Expand All@@ -158,6 +163,7 @@ interface CreateMessageStreamResponseProps {
}

export const createMessageStream = async ({
chatId,
messages,
selectedSearchScopes,
model,
Expand DownExpand Up@@ -242,6 +248,7 @@ export const createMessageStream = async ({
});
},
traceId,
chatId,
});

await mergeStreamAsync(researchStream, writer, {
Expand Down
163 changes: 99 additions & 64 deletions packages/web/src/features/chat/actions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,7 @@
import { sew } from "@/actions";
import { getAuditService } from "@/ee/features/audit/factory";
import { ErrorCode } from "@/lib/errorCodes";
import { notFound, serviceErrorResponse } from "@/lib/serviceError";
import { notFound, ServiceError } from "@/lib/serviceError";
import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock';
import { AnthropicProviderOptions, createAnthropic } from '@ai-sdk/anthropic';
import { createAzure } from '@ai-sdk/azure';
Expand DownExpand Up@@ -62,7 +62,7 @@ export const _isOwnerOfChat = async (chat: Chat, user: User | undefined): Promis
/**
* Checks if a user has been explicitly shared access to a chat.
*/
export const _hasSharedAccess = async ({prisma, chatId, userId}: {prisma: PrismaClient, chatId: string, userId: string | undefined}): Promise<boolean> => {
export const _hasSharedAccess = async ({prisma, chatId, userId}: {prisma: PrismaClient, chatId: string, userId: string | undefined}): Promise<boolean> => {
if (!userId) {
return false;
}
Expand All@@ -79,6 +79,55 @@ export const _hasSharedAccess = async ({prisma, chatId, userId}: {prisma: Prisma
return share !== null;
};

export const _updateChatMessages = async ({ chatId, messages, prisma }: { chatId: string, messages: SBChatMessage[], prisma: PrismaClient }) => {
await prisma.chat.update({
where: {
id: chatId,
},
data: {
messages: messages as unknown as Prisma.InputJsonValue,
},
});

if (env.DEBUG_WRITE_CHAT_MESSAGES_TO_FILE) {
const chatDir = path.join(env.DATA_CACHE_DIR, 'chats');
if (!fs.existsSync(chatDir)) {
fs.mkdirSync(chatDir, { recursive: true });
}

const chatFile = path.join(chatDir, `${chatId}.json`);
fs.writeFileSync(chatFile, JSON.stringify(messages, null, 2));

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
This path depends on a
user-provided value
.

Copilot Autofix

AI 7 months ago

In general, to fix uncontrolled data in a path expression when only a filename is needed, validate or sanitize the user-controlled value before using it, e.g. by whitelisting allowed characters or using a library like sanitize-filename. This prevents .., path separators, or other special characters from influencing directory traversal.

For this specific case in packages/web/src/features/chat/actions.ts, the simplest safe fix without changing existing functionality is:

  • Keep chatDir as-is (path.join(env.DATA_CACHE_DIR, 'chats')).
  • Before constructing chatFile, derive a safe filename from chatId:
    • Either strictly validate that chatId matches an expected pattern (for example a UUID or alphanumeric string) and reject/short-circuit if it does not; or
    • Sanitize chatId to strip out disallowed characters and ensure it cannot contain path separators or ...
  • Then build chatFile with this safe filename and write to it.

Because we cannot assume anything about how chatId is formatted elsewhere, a robust fix inside this snippet is to sanitize / normalize chatId to an allow-listed pattern (e.g. [A-Za-z0-9_-]) and fall back to a deterministic safe name if sanitization would yield an empty string. This avoids introducing new dependencies while keeping behavior for normal IDs identical.

Concretely:

  • In _updateChatMessages, right before const chatFile = path.join(chatDir, \${chatId}.json`);, create a safeChatIdvariable that strips all characters except[A-Za-z0-9_-]`.
  • If safeChatId is empty after stripping, assign a safe placeholder like "unknown-chat".
  • Use safeChatId instead of chatId in the filename interpolation.

No new imports are required; we can do this with basic string replacement and a regular expression. The only file to modify is packages/web/src/features/chat/actions.ts, within the _updateChatMessages function around lines 92–100.

Suggested changeset 1
packages/web/src/features/chat/actions.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/packages/web/src/features/chat/actions.ts b/packages/web/src/features/chat/actions.ts
--- a/packages/web/src/features/chat/actions.ts
+++ b/packages/web/src/features/chat/actions.ts
@@ -95,7 +95,13 @@
fs.mkdirSync(chatDir, { recursive: true });
}
- const chatFile = path.join(chatDir, `${chatId}.json`);
+ // Sanitize chatId to prevent path traversal or invalid filename characters.
+ let safeChatId = chatId.replace(/[^a-zA-Z0-9_-]/g, '');
+ if (!safeChatId) {
+ safeChatId = 'unknown-chat';
+ }
+
+ const chatFile = path.join(chatDir, `${safeChatId}.json`);
fs.writeFileSync(chatFile, JSON.stringify(messages, null, 2));
}
};
EOF
@@ -95,7 +95,13 @@
fs.mkdirSync(chatDir,{recursive: true});
}

constchatFile=path.join(chatDir,`${chatId}.json`);
// Sanitize chatId to prevent path traversal or invalid filename characters.
letsafeChatId=chatId.replace(/[^a-zA-Z0-9_-]/g,'');
if(!safeChatId){
safeChatId='unknown-chat';
}

constchatFile=path.join(chatDir,`${safeChatId}.json`);
fs.writeFileSync(chatFile,JSON.stringify(messages,null,2));
}
};
Copilot is powered by AI and may make mistakes. Always verify output.
Unable to commit as this autofix suggestion is now outdated

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ignoring since a) this code is only called when a debug flag is set, and b) the chatId is a cuid.

}
Comment thread
brendan-kellam marked this conversation as resolved.
};


export const _generateChatNameFromMessage = async ({ message, languageModelConfig }: { message: string, languageModelConfig: LanguageModel }) => {
const { model } = await _getAISDKLanguageModelAndOptions(languageModelConfig);

const prompt = `Convert this question into a short topic title (max 50 characters).

Rules:
- Do NOT include question words (what, where, how, why, when, which)
- Do NOT end with a question mark
- Capitalize the first letter of the title
- Focus on the subject/topic being discussed
- Make it sound like a file name or category

Examples:
"Where is the authentication code?" → "Authentication Code"
"How to setup the database?" → "Database Setup"
"What are the API endpoints?" → "API Endpoints"

User question: ${message}`;

const result = await generateText({
model,
prompt,
});

return result.text;
}
Comment thread
brendan-kellam marked this conversation as resolved.

export const createChat = async () => sew(() =>
withOptionalAuthV2(async ({ org, user, prisma }) => {
const isGuestUser = user === undefined;
Expand DownExpand Up@@ -112,6 +161,11 @@ export const createChat = async () => sew(() =>
});
}

await captureEvent('wa_chat_thread_created', {
chatId: chat.id,
isAnonymous: isGuestUser,
});

return {
id: chat.id,
isAnonymous: isGuestUser,
Expand All@@ -133,7 +187,7 @@ export const getChatInfo = async ({ chatId }: { chatId: string }) => sew(() =>
}

const isOwner = await _isOwnerOfChat(chat, user);
const isSharedWithUser = await _hasSharedAccess({prisma, chatId, userId: user?.id});
const isSharedWithUser = await _hasSharedAccess({prisma, chatId, userId: user?.id});

// Private chats can only be viewed by the owner or users it's been shared with
if (chat.visibility === ChatVisibility.PRIVATE && !isOwner && !isSharedWithUser) {
Expand DownExpand Up@@ -170,24 +224,7 @@ export const updateChatMessages = async ({ chatId, messages }: { chatId: string,
return notFound();
}

await prisma.chat.update({
where: {
id: chatId,
},
data: {
messages: messages as unknown as Prisma.InputJsonValue,
},
});

if (env.DEBUG_WRITE_CHAT_MESSAGES_TO_FILE) {
const chatDir = path.join(env.DATA_CACHE_DIR, 'chats');
if (!fs.existsSync(chatDir)) {
fs.mkdirSync(chatDir, { recursive: true });
}

const chatFile = path.join(chatDir, `${chatId}.json`);
fs.writeFileSync(chatFile, JSON.stringify(messages, null, 2));
}
await _updateChatMessages({ chatId, messages, prisma });

return {
success: true,
Expand DownExpand Up@@ -295,54 +332,52 @@ export const updateChatVisibility = async ({ chatId, visibility }: { chatId: str
);

export const generateAndUpdateChatNameFromMessage = async ({ chatId, languageModelId, message }: { chatId: string, languageModelId: string, message: string }) => sew(() =>
withOptionalAuthV2(async () => {
// From the language model ID, attempt to find the
// corresponding config in `config.json`.
const languageModelConfig =
(await _getConfiguredLanguageModelsFull())
.find((model) => model.model === languageModelId);

if (!languageModelConfig) {
return serviceErrorResponse({
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: `Language model ${languageModelId} is not configured.`,
});
}

const { model } = await _getAISDKLanguageModelAndOptions(languageModelConfig);

const prompt = `Convert this question into a short topic title (max 50 characters).
withOptionalAuthV2(async ({ prisma, user, org }) => {
const chat = await prisma.chat.findUnique({
where: {
id: chatId,
orgId: org.id,
},
});

Rules:
- Do NOT include question words (what, where, how, why, when, which)
- Do NOT end with a question mark
- Capitalize the first letter of the title
- Focus on the subject/topic being discussed
- Make it sound like a file name or category
if (!chat) {
return notFound();
}

Examples:
"Where is the authentication code?" → "Authentication Code"
"How to setup the database?" → "Database Setup"
"What are the API endpoints?" → "API Endpoints"
const isOwner = await _isOwnerOfChat(chat, user);
if (!isOwner) {
return notFound();
}

User question: ${message}`;
const languageModelConfig =
(await _getConfiguredLanguageModelsFull())
.find((model) => model.model === languageModelId);

const result = await generateText({
model,
prompt,
});
if (!languageModelConfig) {
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: `Language model ${languageModelId} is not configured.`,
} satisfies ServiceError;
}
Comment thread
brendan-kellam marked this conversation as resolved.

await updateChatName({
chatId,
name: result.text,
});
const name = await _generateChatNameFromMessage({ message, languageModelConfig });

return {
success: true,
}
await prisma.chat.update({
where: {
id: chatId,
orgId: org.id,
},
data: {
name: name,
},
})
)

return {
success: true,
}
})
)

export const deleteChat = async ({ chatId }: { chatId: string }) => sew(() =>
withAuthV2(async ({ org, user, prisma }) => {
Expand DownExpand Up@@ -436,7 +471,7 @@ export const duplicateChat = async ({ chatId, newName }: { chatId: string, newNa

// Check if user can access the chat (owner, shared, or public)
const isOwner = await _isOwnerOfChat(originalChat, user);
const isSharedWithUser = await _hasSharedAccess({prisma, chatId, userId: user?.id});
const isSharedWithUser = await _hasSharedAccess({prisma, chatId, userId: user?.id});
if (originalChat.visibility === ChatVisibility.PRIVATE && !isOwner && !isSharedWithUser) {
return notFound();
}
Expand DownExpand Up@@ -617,7 +652,7 @@ export const submitFeedback = async ({
}

// When a chat is private, only the creator or shared users can submit feedback.
const isSharedWithUser = await _hasSharedAccess({prisma, chatId, userId: user?.id});
const isSharedWithUser = await _hasSharedAccess({prisma, chatId, userId: user?.id});
if (chat.visibility === ChatVisibility.PRIVATE && chat.createdById !== user?.id && !isSharedWithUser) {
return notFound();
}
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed
- Fixed issue where chat threads created via the `/api/chat/blocking` endpoint would not have any messages when called without authentication. [#907](https://github.com/sourcebot-dev/sourcebot/pull/907)

### Changed
- Added `chatId` to all chat related posthog events. [#907](https://github.com/sourcebot-dev/sourcebot/pull/907)
Comment on lines +13 to +14

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Minor wording and categorization nits on the chatId entry.

Three small issues on line 14:

  1. Wrong section — the verb "Added" signals a new feature/field, which belongs under ### Added rather than ### Changed. Existing entries that introduce new fields consistently live in ### Added (e.g. "Added \install_id` to PostHog event properties."` in v4.10.30).
  2. CapitalizationposthogPostHog (consistent with every other reference in the file, e.g. lines 56, 190, 531).
  3. Missing hyphenchat relatedchat-related (compound modifier before a noun; also flagged by static analysis).
✏️ Proposed fix
-### Changed-- Added `chatId` to all chat related posthog events. [`#907`](https://github.com/sourcebot-dev/sourcebot/pull/907)+### Added+- Added `chatId` to all chat-related PostHog events. [`#907`](https://github.com/sourcebot-dev/sourcebot/pull/907)
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
### Changed
- Added `chatId` to all chatrelated posthog events. [#907](https://github.com/sourcebot-dev/sourcebot/pull/907)
### Added
- Added `chatId` to all chat-related PostHog events. [`#907`](https://github.com/sourcebot-dev/sourcebot/pull/907)
🧰 Tools
🪛 LanguageTool

[grammar] ~14-~14: Use a hyphen to join words.
Context: ...### Changed - Added chatId to all chat related posthog events. [#907](https://g...

(QB_NEW_EN_HYPHEN)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@CHANGELOG.md` around lines 13 - 14, Move the `chatId` line from the "Changed"
section into the "Added" section and update its wording to read: use "PostHog"
(capital P and H) and hyphenate "chat-related", e.g. "Added `chatId` to all
chat-related PostHog event properties." so it matches existing changelog
conventions.


## [4.11.2] - 2026-02-18

### Fixed
Expand Down
35 changes: 27 additions & 8 deletions packages/web/src/app/api/(server)/chat/blocking/route.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { sew } from "@/actions";
import { _getConfiguredLanguageModelsFull, _getAISDKLanguageModelAndOptions, updateChatMessages, generateAndUpdateChatNameFromMessage } from "@/features/chat/actions";
import { _getConfiguredLanguageModelsFull, _getAISDKLanguageModelAndOptions, _updateChatMessages, generateAndUpdateChatNameFromMessage, _generateChatNameFromMessage } from "@/features/chat/actions";
import { LanguageModelInfo, languageModelInfoSchema, SBChatMessage, SearchScope } from "@/features/chat/types";
import { convertLLMOutputToPortableMarkdown, getAnswerPartFromAssistantMessage, getLanguageModelKey } from "@/features/chat/utils";
import { ErrorCode } from "@/lib/errorCodes";
Expand All@@ -15,6 +15,7 @@ import { z } from "zod";
import { createMessageStream } from "../route";
import { InferUIMessageChunk, UITools, UIDataTypes, UIMessage } from "ai";
import { apiHandler } from "@/lib/apiHandler";
import { captureEvent } from "@/lib/posthog";

const logger = createLogger('chat-blocking-api');

Expand DownExpand Up@@ -115,6 +116,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
});

await captureEvent('wa_chat_thread_created', {
chatId: chat.id,
isAnonymous: !user,
});

// Run the agent to completion
logger.debug(`Starting blocking agent for chat ${chat.id}`, {
chatId: chat.id,
Expand DownExpand Up@@ -155,7 +161,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
// We'll capture the final messages and usage from the stream
let finalMessages: SBChatMessage[] = [];

await captureEvent('wa_chat_message_sent', {
chatId: chat.id,
messageCount: 1,
});

const stream = await createMessageStream({
chatId: chat.id,
messages: [userMessage],
selectedSearchScopes,
model,
Expand All@@ -180,21 +192,28 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
})

await Promise.all([
const [_, name] = await Promise.all([
// Consume the stream fully to trigger onFinish
blockStreamUntilFinish(stream),
// Generate and update the chat name
generateAndUpdateChatNameFromMessage({
chatId: chat.id,
languageModelId: languageModelConfig.model,
_generateChatNameFromMessage({
message: query,
languageModelConfig,
})
]);

// Persist the messages to the chat
await updateChatMessages({
chatId: chat.id,
messages: finalMessages,
await _updateChatMessages({ chatId: chat.id, messages: finalMessages, prisma });
Comment thread
brendan-kellam marked this conversation as resolved.

// Update the chat name
await prisma.chat.update({
where: {
id: chat.id,
orgId: org.id,
},
data: {
name: name,
},
});

// Extract the answer text from the assistant message
Expand Down
17 changes: 12 additions & 5 deletions packages/web/src/app/api/(server)/chat/route.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { sew } from "@/actions";
import { _getConfiguredLanguageModelsFull, _getAISDKLanguageModelAndOptions, updateChatMessages, _isOwnerOfChat } from "@/features/chat/actions";
import { _getConfiguredLanguageModelsFull, _getAISDKLanguageModelAndOptions, _updateChatMessages, _isOwnerOfChat } from "@/features/chat/actions";
import { createAgentStream } from "@/features/chat/agent";
import { additionalChatRequestParamsSchema, LanguageModelInfo, SBChatMessage, SearchScope } from "@/features/chat/types";
import { getAnswerPartFromAssistantMessage, getLanguageModelKey } from "@/features/chat/utils";
Expand All@@ -12,6 +12,7 @@ import { LanguageModelV2 as AISDKLanguageModelV2 } from "@ai-sdk/provider";
import * as Sentry from "@sentry/nextjs";
import { PrismaClient } from "@sourcebot/db";
import { createLogger } from "@sourcebot/shared";
import { captureEvent } from "@/lib/posthog";
import {
createUIMessageStream,
createUIMessageStreamResponse,
Expand DownExpand Up@@ -88,7 +89,13 @@ export const POST = apiHandler(async (req: NextRequest) => {

const { model, providerOptions } = await _getAISDKLanguageModelAndOptions(languageModelConfig);

await captureEvent('wa_chat_message_sent', {
chatId: id,
messageCount: messages.length,
});

const stream = await createMessageStream({
chatId: id,
messages,
selectedSearchScopes,
model,
Expand All@@ -97,10 +104,7 @@ export const POST = apiHandler(async (req: NextRequest) => {
orgId: org.id,
prisma,
onFinish: async ({ messages }) => {
await updateChatMessages({
chatId: id,
messages
});
await _updateChatMessages({ chatId: id, messages, prisma });
},
onError: (error: unknown) => {
logger.error(error);
Expand DownExpand Up@@ -146,6 +150,7 @@ const mergeStreamAsync = async (stream: StreamTextResult<any, any>, writer: UIMe
}

interface CreateMessageStreamResponseProps {
chatId: string;
messages: SBChatMessage[];
selectedSearchScopes: SearchScope[];
model: AISDKLanguageModelV2;
Expand All@@ -158,6 +163,7 @@ interface CreateMessageStreamResponseProps {
}

export const createMessageStream = async ({
chatId,
messages,
selectedSearchScopes,
model,
Expand DownExpand Up@@ -242,6 +248,7 @@ export const createMessageStream = async ({
});
},
traceId,
chatId,
});

await mergeStreamAsync(researchStream, writer, {
Expand Down
163 changes: 99 additions & 64 deletions packages/web/src/features/chat/actions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,7 @@
import { sew } from "@/actions";
import { getAuditService } from "@/ee/features/audit/factory";
import { ErrorCode } from "@/lib/errorCodes";
import { notFound, serviceErrorResponse } from "@/lib/serviceError";
import { notFound, ServiceError } from "@/lib/serviceError";
import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock';
import { AnthropicProviderOptions, createAnthropic } from '@ai-sdk/anthropic';
import { createAzure } from '@ai-sdk/azure';
Expand DownExpand Up@@ -62,7 +62,7 @@ export const _isOwnerOfChat = async (chat: Chat, user: User | undefined): Promis
/**
* Checks if a user has been explicitly shared access to a chat.
*/
export const _hasSharedAccess = async ({prisma, chatId, userId}: {prisma: PrismaClient, chatId: string, userId: string | undefined}): Promise<boolean> => {
export const _hasSharedAccess = async ({prisma, chatId, userId}: {prisma: PrismaClient, chatId: string, userId: string | undefined}): Promise<boolean> => {
if (!userId) {
return false;
}
Expand All@@ -79,6 +79,55 @@ export const _hasSharedAccess = async ({prisma, chatId, userId}: {prisma: Prisma
return share !== null;
};

export const _updateChatMessages = async ({ chatId, messages, prisma }: { chatId: string, messages: SBChatMessage[], prisma: PrismaClient }) => {
await prisma.chat.update({
where: {
id: chatId,
},
data: {
messages: messages as unknown as Prisma.InputJsonValue,
},
});

if (env.DEBUG_WRITE_CHAT_MESSAGES_TO_FILE) {
const chatDir = path.join(env.DATA_CACHE_DIR, 'chats');
if (!fs.existsSync(chatDir)) {
fs.mkdirSync(chatDir, { recursive: true });
}

const chatFile = path.join(chatDir, `${chatId}.json`);
fs.writeFileSync(chatFile, JSON.stringify(messages, null, 2));

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
This path depends on a
user-provided value
.

Copilot Autofix

AI 7 months ago

In general, to fix uncontrolled data in a path expression when only a filename is needed, validate or sanitize the user-controlled value before using it, e.g. by whitelisting allowed characters or using a library like sanitize-filename. This prevents .., path separators, or other special characters from influencing directory traversal.

For this specific case in packages/web/src/features/chat/actions.ts, the simplest safe fix without changing existing functionality is:

  • Keep chatDir as-is (path.join(env.DATA_CACHE_DIR, 'chats')).
  • Before constructing chatFile, derive a safe filename from chatId:
    • Either strictly validate that chatId matches an expected pattern (for example a UUID or alphanumeric string) and reject/short-circuit if it does not; or
    • Sanitize chatId to strip out disallowed characters and ensure it cannot contain path separators or ...
  • Then build chatFile with this safe filename and write to it.

Because we cannot assume anything about how chatId is formatted elsewhere, a robust fix inside this snippet is to sanitize / normalize chatId to an allow-listed pattern (e.g. [A-Za-z0-9_-]) and fall back to a deterministic safe name if sanitization would yield an empty string. This avoids introducing new dependencies while keeping behavior for normal IDs identical.

Concretely:

  • In _updateChatMessages, right before const chatFile = path.join(chatDir, \${chatId}.json`);, create a safeChatIdvariable that strips all characters except[A-Za-z0-9_-]`.
  • If safeChatId is empty after stripping, assign a safe placeholder like "unknown-chat".
  • Use safeChatId instead of chatId in the filename interpolation.

No new imports are required; we can do this with basic string replacement and a regular expression. The only file to modify is packages/web/src/features/chat/actions.ts, within the _updateChatMessages function around lines 92–100.

Suggested changeset 1
packages/web/src/features/chat/actions.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/packages/web/src/features/chat/actions.ts b/packages/web/src/features/chat/actions.ts
--- a/packages/web/src/features/chat/actions.ts
+++ b/packages/web/src/features/chat/actions.ts
@@ -95,7 +95,13 @@
fs.mkdirSync(chatDir, { recursive: true });
}
- const chatFile = path.join(chatDir, `${chatId}.json`);
+ // Sanitize chatId to prevent path traversal or invalid filename characters.
+ let safeChatId = chatId.replace(/[^a-zA-Z0-9_-]/g, '');
+ if (!safeChatId) {
+ safeChatId = 'unknown-chat';
+ }
+
+ const chatFile = path.join(chatDir, `${safeChatId}.json`);
fs.writeFileSync(chatFile, JSON.stringify(messages, null, 2));
}
};
EOF
@@ -95,7 +95,13 @@
fs.mkdirSync(chatDir,{recursive: true});
}

constchatFile=path.join(chatDir,`${chatId}.json`);
// Sanitize chatId to prevent path traversal or invalid filename characters.
letsafeChatId=chatId.replace(/[^a-zA-Z0-9_-]/g,'');
if(!safeChatId){
safeChatId='unknown-chat';
}

constchatFile=path.join(chatDir,`${safeChatId}.json`);
fs.writeFileSync(chatFile,JSON.stringify(messages,null,2));
}
};
Copilot is powered by AI and may make mistakes. Always verify output.
Unable to commit as this autofix suggestion is now outdated

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ignoring since a) this code is only called when a debug flag is set, and b) the chatId is a cuid.

}
Comment thread
brendan-kellam marked this conversation as resolved.
};


export const _generateChatNameFromMessage = async ({ message, languageModelConfig }: { message: string, languageModelConfig: LanguageModel }) => {
const { model } = await _getAISDKLanguageModelAndOptions(languageModelConfig);

const prompt = `Convert this question into a short topic title (max 50 characters).

Rules:
- Do NOT include question words (what, where, how, why, when, which)
- Do NOT end with a question mark
- Capitalize the first letter of the title
- Focus on the subject/topic being discussed
- Make it sound like a file name or category

Examples:
"Where is the authentication code?" → "Authentication Code"
"How to setup the database?" → "Database Setup"
"What are the API endpoints?" → "API Endpoints"

User question: ${message}`;

const result = await generateText({
model,
prompt,
});

return result.text;
}
Comment thread
brendan-kellam marked this conversation as resolved.

export const createChat = async () => sew(() =>
withOptionalAuthV2(async ({ org, user, prisma }) => {
const isGuestUser = user === undefined;
Expand DownExpand Up@@ -112,6 +161,11 @@ export const createChat = async () => sew(() =>
});
}

await captureEvent('wa_chat_thread_created', {
chatId: chat.id,
isAnonymous: isGuestUser,
});

return {
id: chat.id,
isAnonymous: isGuestUser,
Expand All@@ -133,7 +187,7 @@ export const getChatInfo = async ({ chatId }: { chatId: string }) => sew(() =>
}

const isOwner = await _isOwnerOfChat(chat, user);
const isSharedWithUser = await _hasSharedAccess({prisma, chatId, userId: user?.id});
const isSharedWithUser = await _hasSharedAccess({prisma, chatId, userId: user?.id});

// Private chats can only be viewed by the owner or users it's been shared with
if (chat.visibility === ChatVisibility.PRIVATE && !isOwner && !isSharedWithUser) {
Expand DownExpand Up@@ -170,24 +224,7 @@ export const updateChatMessages = async ({ chatId, messages }: { chatId: string,
return notFound();
}

await prisma.chat.update({
where: {
id: chatId,
},
data: {
messages: messages as unknown as Prisma.InputJsonValue,
},
});

if (env.DEBUG_WRITE_CHAT_MESSAGES_TO_FILE) {
const chatDir = path.join(env.DATA_CACHE_DIR, 'chats');
if (!fs.existsSync(chatDir)) {
fs.mkdirSync(chatDir, { recursive: true });
}

const chatFile = path.join(chatDir, `${chatId}.json`);
fs.writeFileSync(chatFile, JSON.stringify(messages, null, 2));
}
await _updateChatMessages({ chatId, messages, prisma });

return {
success: true,
Expand DownExpand Up@@ -295,54 +332,52 @@ export const updateChatVisibility = async ({ chatId, visibility }: { chatId: str
);

export const generateAndUpdateChatNameFromMessage = async ({ chatId, languageModelId, message }: { chatId: string, languageModelId: string, message: string }) => sew(() =>
withOptionalAuthV2(async () => {
// From the language model ID, attempt to find the
// corresponding config in `config.json`.
const languageModelConfig =
(await _getConfiguredLanguageModelsFull())
.find((model) => model.model === languageModelId);

if (!languageModelConfig) {
return serviceErrorResponse({
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: `Language model ${languageModelId} is not configured.`,
});
}

const { model } = await _getAISDKLanguageModelAndOptions(languageModelConfig);

const prompt = `Convert this question into a short topic title (max 50 characters).
withOptionalAuthV2(async ({ prisma, user, org }) => {
const chat = await prisma.chat.findUnique({
where: {
id: chatId,
orgId: org.id,
},
});

Rules:
- Do NOT include question words (what, where, how, why, when, which)
- Do NOT end with a question mark
- Capitalize the first letter of the title
- Focus on the subject/topic being discussed
- Make it sound like a file name or category
if (!chat) {
return notFound();
}

Examples:
"Where is the authentication code?" → "Authentication Code"
"How to setup the database?" → "Database Setup"
"What are the API endpoints?" → "API Endpoints"
const isOwner = await _isOwnerOfChat(chat, user);
if (!isOwner) {
return notFound();
}

User question: ${message}`;
const languageModelConfig =
(await _getConfiguredLanguageModelsFull())
.find((model) => model.model === languageModelId);

const result = await generateText({
model,
prompt,
});
if (!languageModelConfig) {
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: `Language model ${languageModelId} is not configured.`,
} satisfies ServiceError;
}
Comment thread
brendan-kellam marked this conversation as resolved.

await updateChatName({
chatId,
name: result.text,
});
const name = await _generateChatNameFromMessage({ message, languageModelConfig });

return {
success: true,
}
await prisma.chat.update({
where: {
id: chatId,
orgId: org.id,
},
data: {
name: name,
},
})
)

return {
success: true,
}
})
)

export const deleteChat = async ({ chatId }: { chatId: string }) => sew(() =>
withAuthV2(async ({ org, user, prisma }) => {
Expand DownExpand Up@@ -436,7 +471,7 @@ export const duplicateChat = async ({ chatId, newName }: { chatId: string, newNa

// Check if user can access the chat (owner, shared, or public)
const isOwner = await _isOwnerOfChat(originalChat, user);
const isSharedWithUser = await _hasSharedAccess({prisma, chatId, userId: user?.id});
const isSharedWithUser = await _hasSharedAccess({prisma, chatId, userId: user?.id});
if (originalChat.visibility === ChatVisibility.PRIVATE && !isOwner && !isSharedWithUser) {
return notFound();
}
Expand DownExpand Up@@ -617,7 +652,7 @@ export const submitFeedback = async ({
}

// When a chat is private, only the creator or shared users can submit feedback.
const isSharedWithUser = await _hasSharedAccess({prisma, chatId, userId: user?.id});
const isSharedWithUser = await _hasSharedAccess({prisma, chatId, userId: user?.id});
if (chat.visibility === ChatVisibility.PRIVATE && chat.createdById !== user?.id && !isSharedWithUser) {
return notFound();
}
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed
- Fixed issue where chat threads created via the `/api/chat/blocking` endpoint would not have any messages when called without authentication. [#907](https://github.com/sourcebot-dev/sourcebot/pull/907)

### Changed
- Added `chatId` to all chat related posthog events. [#907](https://github.com/sourcebot-dev/sourcebot/pull/907)
Comment on lines +13 to +14

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Minor wording and categorization nits on the chatId entry.

Three small issues on line 14:

  1. Wrong section — the verb "Added" signals a new feature/field, which belongs under ### Added rather than ### Changed. Existing entries that introduce new fields consistently live in ### Added (e.g. "Added \install_id` to PostHog event properties."` in v4.10.30).
  2. CapitalizationposthogPostHog (consistent with every other reference in the file, e.g. lines 56, 190, 531).
  3. Missing hyphenchat relatedchat-related (compound modifier before a noun; also flagged by static analysis).
✏️ Proposed fix
-### Changed-- Added `chatId` to all chat related posthog events. [`#907`](https://github.com/sourcebot-dev/sourcebot/pull/907)+### Added+- Added `chatId` to all chat-related PostHog events. [`#907`](https://github.com/sourcebot-dev/sourcebot/pull/907)
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
### Changed
- Added `chatId` to all chatrelated posthog events. [#907](https://github.com/sourcebot-dev/sourcebot/pull/907)
### Added
- Added `chatId` to all chat-related PostHog events. [`#907`](https://github.com/sourcebot-dev/sourcebot/pull/907)
🧰 Tools
🪛 LanguageTool

[grammar] ~14-~14: Use a hyphen to join words.
Context: ...### Changed - Added chatId to all chat related posthog events. [#907](https://g...

(QB_NEW_EN_HYPHEN)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@CHANGELOG.md` around lines 13 - 14, Move the `chatId` line from the "Changed"
section into the "Added" section and update its wording to read: use "PostHog"
(capital P and H) and hyphenate "chat-related", e.g. "Added `chatId` to all
chat-related PostHog event properties." so it matches existing changelog
conventions.


## [4.11.2] - 2026-02-18

### Fixed
Expand Down
35 changes: 27 additions & 8 deletions packages/web/src/app/api/(server)/chat/blocking/route.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { sew } from "@/actions";
import { _getConfiguredLanguageModelsFull, _getAISDKLanguageModelAndOptions, updateChatMessages, generateAndUpdateChatNameFromMessage } from "@/features/chat/actions";
import { _getConfiguredLanguageModelsFull, _getAISDKLanguageModelAndOptions, _updateChatMessages, generateAndUpdateChatNameFromMessage, _generateChatNameFromMessage } from "@/features/chat/actions";
import { LanguageModelInfo, languageModelInfoSchema, SBChatMessage, SearchScope } from "@/features/chat/types";
import { convertLLMOutputToPortableMarkdown, getAnswerPartFromAssistantMessage, getLanguageModelKey } from "@/features/chat/utils";
import { ErrorCode } from "@/lib/errorCodes";
Expand All@@ -15,6 +15,7 @@ import { z } from "zod";
import { createMessageStream } from "../route";
import { InferUIMessageChunk, UITools, UIDataTypes, UIMessage } from "ai";
import { apiHandler } from "@/lib/apiHandler";
import { captureEvent } from "@/lib/posthog";

const logger = createLogger('chat-blocking-api');

Expand DownExpand Up@@ -115,6 +116,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
});

await captureEvent('wa_chat_thread_created', {
chatId: chat.id,
isAnonymous: !user,
});

// Run the agent to completion
logger.debug(`Starting blocking agent for chat ${chat.id}`, {
chatId: chat.id,
Expand DownExpand Up@@ -155,7 +161,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
// We'll capture the final messages and usage from the stream
let finalMessages: SBChatMessage[] = [];

await captureEvent('wa_chat_message_sent', {
chatId: chat.id,
messageCount: 1,
});

const stream = await createMessageStream({
chatId: chat.id,
messages: [userMessage],
selectedSearchScopes,
model,
Expand All@@ -180,21 +192,28 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
})

await Promise.all([
const [_, name] = await Promise.all([
// Consume the stream fully to trigger onFinish
blockStreamUntilFinish(stream),
// Generate and update the chat name
generateAndUpdateChatNameFromMessage({
chatId: chat.id,
languageModelId: languageModelConfig.model,
_generateChatNameFromMessage({
message: query,
languageModelConfig,
})
]);

// Persist the messages to the chat
await updateChatMessages({
chatId: chat.id,
messages: finalMessages,
await _updateChatMessages({ chatId: chat.id, messages: finalMessages, prisma });
Comment thread
brendan-kellam marked this conversation as resolved.

// Update the chat name
await prisma.chat.update({
where: {
id: chat.id,
orgId: org.id,
},
data: {
name: name,
},
});

// Extract the answer text from the assistant message
Expand Down
17 changes: 12 additions & 5 deletions packages/web/src/app/api/(server)/chat/route.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { sew } from "@/actions";
import { _getConfiguredLanguageModelsFull, _getAISDKLanguageModelAndOptions, updateChatMessages, _isOwnerOfChat } from "@/features/chat/actions";
import { _getConfiguredLanguageModelsFull, _getAISDKLanguageModelAndOptions, _updateChatMessages, _isOwnerOfChat } from "@/features/chat/actions";
import { createAgentStream } from "@/features/chat/agent";
import { additionalChatRequestParamsSchema, LanguageModelInfo, SBChatMessage, SearchScope } from "@/features/chat/types";
import { getAnswerPartFromAssistantMessage, getLanguageModelKey } from "@/features/chat/utils";
Expand All@@ -12,6 +12,7 @@ import { LanguageModelV2 as AISDKLanguageModelV2 } from "@ai-sdk/provider";
import * as Sentry from "@sentry/nextjs";
import { PrismaClient } from "@sourcebot/db";
import { createLogger } from "@sourcebot/shared";
import { captureEvent } from "@/lib/posthog";
import {
createUIMessageStream,
createUIMessageStreamResponse,
Expand DownExpand Up@@ -88,7 +89,13 @@ export const POST = apiHandler(async (req: NextRequest) => {

const { model, providerOptions } = await _getAISDKLanguageModelAndOptions(languageModelConfig);

await captureEvent('wa_chat_message_sent', {
chatId: id,
messageCount: messages.length,
});

const stream = await createMessageStream({
chatId: id,
messages,
selectedSearchScopes,
model,
Expand All@@ -97,10 +104,7 @@ export const POST = apiHandler(async (req: NextRequest) => {
orgId: org.id,
prisma,
onFinish: async ({ messages }) => {
await updateChatMessages({
chatId: id,
messages
});
await _updateChatMessages({ chatId: id, messages, prisma });
},
onError: (error: unknown) => {
logger.error(error);
Expand DownExpand Up@@ -146,6 +150,7 @@ const mergeStreamAsync = async (stream: StreamTextResult<any, any>, writer: UIMe
}

interface CreateMessageStreamResponseProps {
chatId: string;
messages: SBChatMessage[];
selectedSearchScopes: SearchScope[];
model: AISDKLanguageModelV2;
Expand All@@ -158,6 +163,7 @@ interface CreateMessageStreamResponseProps {
}

export const createMessageStream = async ({
chatId,
messages,
selectedSearchScopes,
model,
Expand DownExpand Up@@ -242,6 +248,7 @@ export const createMessageStream = async ({
});
},
traceId,
chatId,
});

await mergeStreamAsync(researchStream, writer, {
Expand Down
163 changes: 99 additions & 64 deletions packages/web/src/features/chat/actions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,7 @@
import { sew } from "@/actions";
import { getAuditService } from "@/ee/features/audit/factory";
import { ErrorCode } from "@/lib/errorCodes";
import { notFound, serviceErrorResponse } from "@/lib/serviceError";
import { notFound, ServiceError } from "@/lib/serviceError";
import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock';
import { AnthropicProviderOptions, createAnthropic } from '@ai-sdk/anthropic';
import { createAzure } from '@ai-sdk/azure';
Expand DownExpand Up@@ -62,7 +62,7 @@ export const _isOwnerOfChat = async (chat: Chat, user: User | undefined): Promis
/**
* Checks if a user has been explicitly shared access to a chat.
*/
export const _hasSharedAccess = async ({prisma, chatId, userId}: {prisma: PrismaClient, chatId: string, userId: string | undefined}): Promise<boolean> => {
export const _hasSharedAccess = async ({prisma, chatId, userId}: {prisma: PrismaClient, chatId: string, userId: string | undefined}): Promise<boolean> => {
if (!userId) {
return false;
}
Expand All@@ -79,6 +79,55 @@ export const _hasSharedAccess = async ({prisma, chatId, userId}: {prisma: Prisma
return share !== null;
};

export const _updateChatMessages = async ({ chatId, messages, prisma }: { chatId: string, messages: SBChatMessage[], prisma: PrismaClient }) => {
await prisma.chat.update({
where: {
id: chatId,
},
data: {
messages: messages as unknown as Prisma.InputJsonValue,
},
});

if (env.DEBUG_WRITE_CHAT_MESSAGES_TO_FILE) {
const chatDir = path.join(env.DATA_CACHE_DIR, 'chats');
if (!fs.existsSync(chatDir)) {
fs.mkdirSync(chatDir, { recursive: true });
}

const chatFile = path.join(chatDir, `${chatId}.json`);
fs.writeFileSync(chatFile, JSON.stringify(messages, null, 2));

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
This path depends on a
user-provided value
.

Copilot Autofix

AI 7 months ago

In general, to fix uncontrolled data in a path expression when only a filename is needed, validate or sanitize the user-controlled value before using it, e.g. by whitelisting allowed characters or using a library like sanitize-filename. This prevents .., path separators, or other special characters from influencing directory traversal.

For this specific case in packages/web/src/features/chat/actions.ts, the simplest safe fix without changing existing functionality is:

  • Keep chatDir as-is (path.join(env.DATA_CACHE_DIR, 'chats')).
  • Before constructing chatFile, derive a safe filename from chatId:
    • Either strictly validate that chatId matches an expected pattern (for example a UUID or alphanumeric string) and reject/short-circuit if it does not; or
    • Sanitize chatId to strip out disallowed characters and ensure it cannot contain path separators or ...
  • Then build chatFile with this safe filename and write to it.

Because we cannot assume anything about how chatId is formatted elsewhere, a robust fix inside this snippet is to sanitize / normalize chatId to an allow-listed pattern (e.g. [A-Za-z0-9_-]) and fall back to a deterministic safe name if sanitization would yield an empty string. This avoids introducing new dependencies while keeping behavior for normal IDs identical.

Concretely:

  • In _updateChatMessages, right before const chatFile = path.join(chatDir, \${chatId}.json`);, create a safeChatIdvariable that strips all characters except[A-Za-z0-9_-]`.
  • If safeChatId is empty after stripping, assign a safe placeholder like "unknown-chat".
  • Use safeChatId instead of chatId in the filename interpolation.

No new imports are required; we can do this with basic string replacement and a regular expression. The only file to modify is packages/web/src/features/chat/actions.ts, within the _updateChatMessages function around lines 92–100.

Suggested changeset 1
packages/web/src/features/chat/actions.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/packages/web/src/features/chat/actions.ts b/packages/web/src/features/chat/actions.ts
--- a/packages/web/src/features/chat/actions.ts
+++ b/packages/web/src/features/chat/actions.ts
@@ -95,7 +95,13 @@
fs.mkdirSync(chatDir, { recursive: true });
}
- const chatFile = path.join(chatDir, `${chatId}.json`);
+ // Sanitize chatId to prevent path traversal or invalid filename characters.
+ let safeChatId = chatId.replace(/[^a-zA-Z0-9_-]/g, '');
+ if (!safeChatId) {
+ safeChatId = 'unknown-chat';
+ }
+
+ const chatFile = path.join(chatDir, `${safeChatId}.json`);
fs.writeFileSync(chatFile, JSON.stringify(messages, null, 2));
}
};
EOF
@@ -95,7 +95,13 @@
fs.mkdirSync(chatDir,{recursive: true});
}

constchatFile=path.join(chatDir,`${chatId}.json`);
// Sanitize chatId to prevent path traversal or invalid filename characters.
letsafeChatId=chatId.replace(/[^a-zA-Z0-9_-]/g,'');
if(!safeChatId){
safeChatId='unknown-chat';
}

constchatFile=path.join(chatDir,`${safeChatId}.json`);
fs.writeFileSync(chatFile,JSON.stringify(messages,null,2));
}
};
Copilot is powered by AI and may make mistakes. Always verify output.
Unable to commit as this autofix suggestion is now outdated

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ignoring since a) this code is only called when a debug flag is set, and b) the chatId is a cuid.

}
Comment thread
brendan-kellam marked this conversation as resolved.
};


export const _generateChatNameFromMessage = async ({ message, languageModelConfig }: { message: string, languageModelConfig: LanguageModel }) => {
const { model } = await _getAISDKLanguageModelAndOptions(languageModelConfig);

const prompt = `Convert this question into a short topic title (max 50 characters).

Rules:
- Do NOT include question words (what, where, how, why, when, which)
- Do NOT end with a question mark
- Capitalize the first letter of the title
- Focus on the subject/topic being discussed
- Make it sound like a file name or category

Examples:
"Where is the authentication code?" → "Authentication Code"
"How to setup the database?" → "Database Setup"
"What are the API endpoints?" → "API Endpoints"

User question: ${message}`;

const result = await generateText({
model,
prompt,
});

return result.text;
}
Comment thread
brendan-kellam marked this conversation as resolved.

export const createChat = async () => sew(() =>
withOptionalAuthV2(async ({ org, user, prisma }) => {
const isGuestUser = user === undefined;
Expand DownExpand Up@@ -112,6 +161,11 @@ export const createChat = async () => sew(() =>
});
}

await captureEvent('wa_chat_thread_created', {
chatId: chat.id,
isAnonymous: isGuestUser,
});

return {
id: chat.id,
isAnonymous: isGuestUser,
Expand All@@ -133,7 +187,7 @@ export const getChatInfo = async ({ chatId }: { chatId: string }) => sew(() =>
}

const isOwner = await _isOwnerOfChat(chat, user);
const isSharedWithUser = await _hasSharedAccess({prisma, chatId, userId: user?.id});
const isSharedWithUser = await _hasSharedAccess({prisma, chatId, userId: user?.id});

// Private chats can only be viewed by the owner or users it's been shared with
if (chat.visibility === ChatVisibility.PRIVATE && !isOwner && !isSharedWithUser) {
Expand DownExpand Up@@ -170,24 +224,7 @@ export const updateChatMessages = async ({ chatId, messages }: { chatId: string,
return notFound();
}

await prisma.chat.update({
where: {
id: chatId,
},
data: {
messages: messages as unknown as Prisma.InputJsonValue,
},
});

if (env.DEBUG_WRITE_CHAT_MESSAGES_TO_FILE) {
const chatDir = path.join(env.DATA_CACHE_DIR, 'chats');
if (!fs.existsSync(chatDir)) {
fs.mkdirSync(chatDir, { recursive: true });
}

const chatFile = path.join(chatDir, `${chatId}.json`);
fs.writeFileSync(chatFile, JSON.stringify(messages, null, 2));
}
await _updateChatMessages({ chatId, messages, prisma });

return {
success: true,
Expand DownExpand Up@@ -295,54 +332,52 @@ export const updateChatVisibility = async ({ chatId, visibility }: { chatId: str
);

export const generateAndUpdateChatNameFromMessage = async ({ chatId, languageModelId, message }: { chatId: string, languageModelId: string, message: string }) => sew(() =>
withOptionalAuthV2(async () => {
// From the language model ID, attempt to find the
// corresponding config in `config.json`.
const languageModelConfig =
(await _getConfiguredLanguageModelsFull())
.find((model) => model.model === languageModelId);

if (!languageModelConfig) {
return serviceErrorResponse({
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: `Language model ${languageModelId} is not configured.`,
});
}

const { model } = await _getAISDKLanguageModelAndOptions(languageModelConfig);

const prompt = `Convert this question into a short topic title (max 50 characters).
withOptionalAuthV2(async ({ prisma, user, org }) => {
const chat = await prisma.chat.findUnique({
where: {
id: chatId,
orgId: org.id,
},
});

Rules:
- Do NOT include question words (what, where, how, why, when, which)
- Do NOT end with a question mark
- Capitalize the first letter of the title
- Focus on the subject/topic being discussed
- Make it sound like a file name or category
if (!chat) {
return notFound();
}

Examples:
"Where is the authentication code?" → "Authentication Code"
"How to setup the database?" → "Database Setup"
"What are the API endpoints?" → "API Endpoints"
const isOwner = await _isOwnerOfChat(chat, user);
if (!isOwner) {
return notFound();
}

User question: ${message}`;
const languageModelConfig =
(await _getConfiguredLanguageModelsFull())
.find((model) => model.model === languageModelId);

const result = await generateText({
model,
prompt,
});
if (!languageModelConfig) {
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: `Language model ${languageModelId} is not configured.`,
} satisfies ServiceError;
}
Comment thread
brendan-kellam marked this conversation as resolved.

await updateChatName({
chatId,
name: result.text,
});
const name = await _generateChatNameFromMessage({ message, languageModelConfig });

return {
success: true,
}
await prisma.chat.update({
where: {
id: chatId,
orgId: org.id,
},
data: {
name: name,
},
})
)

return {
success: true,
}
})
)

export const deleteChat = async ({ chatId }: { chatId: string }) => sew(() =>
withAuthV2(async ({ org, user, prisma }) => {
Expand DownExpand Up@@ -436,7 +471,7 @@ export const duplicateChat = async ({ chatId, newName }: { chatId: string, newNa

// Check if user can access the chat (owner, shared, or public)
const isOwner = await _isOwnerOfChat(originalChat, user);
const isSharedWithUser = await _hasSharedAccess({prisma, chatId, userId: user?.id});
const isSharedWithUser = await _hasSharedAccess({prisma, chatId, userId: user?.id});
if (originalChat.visibility === ChatVisibility.PRIVATE && !isOwner && !isSharedWithUser) {
return notFound();
}
Expand DownExpand Up@@ -617,7 +652,7 @@ export const submitFeedback = async ({
}

// When a chat is private, only the creator or shared users can submit feedback.
const isSharedWithUser = await _hasSharedAccess({prisma, chatId, userId: user?.id});
const isSharedWithUser = await _hasSharedAccess({prisma, chatId, userId: user?.id});
if (chat.visibility === ChatVisibility.PRIVATE && chat.createdById !== user?.id && !isSharedWithUser) {
return notFound();
}
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed
- Fixed issue where chat threads created via the `/api/chat/blocking` endpoint would not have any messages when called without authentication. [#907](https://github.com/sourcebot-dev/sourcebot/pull/907)

### Changed
- Added `chatId` to all chat related posthog events. [#907](https://github.com/sourcebot-dev/sourcebot/pull/907)
Comment on lines +13 to +14

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Minor wording and categorization nits on the chatId entry.

Three small issues on line 14:

  1. Wrong section — the verb "Added" signals a new feature/field, which belongs under ### Added rather than ### Changed. Existing entries that introduce new fields consistently live in ### Added (e.g. "Added \install_id` to PostHog event properties."` in v4.10.30).
  2. CapitalizationposthogPostHog (consistent with every other reference in the file, e.g. lines 56, 190, 531).
  3. Missing hyphenchat relatedchat-related (compound modifier before a noun; also flagged by static analysis).
✏️ Proposed fix
-### Changed-- Added `chatId` to all chat related posthog events. [`#907`](https://github.com/sourcebot-dev/sourcebot/pull/907)+### Added+- Added `chatId` to all chat-related PostHog events. [`#907`](https://github.com/sourcebot-dev/sourcebot/pull/907)
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
### Changed
- Added `chatId` to all chatrelated posthog events. [#907](https://github.com/sourcebot-dev/sourcebot/pull/907)
### Added
- Added `chatId` to all chat-related PostHog events. [`#907`](https://github.com/sourcebot-dev/sourcebot/pull/907)
🧰 Tools
🪛 LanguageTool

[grammar] ~14-~14: Use a hyphen to join words.
Context: ...### Changed - Added chatId to all chat related posthog events. [#907](https://g...

(QB_NEW_EN_HYPHEN)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@CHANGELOG.md` around lines 13 - 14, Move the `chatId` line from the "Changed"
section into the "Added" section and update its wording to read: use "PostHog"
(capital P and H) and hyphenate "chat-related", e.g. "Added `chatId` to all
chat-related PostHog event properties." so it matches existing changelog
conventions.


## [4.11.2] - 2026-02-18

### Fixed
Expand Down
35 changes: 27 additions & 8 deletions packages/web/src/app/api/(server)/chat/blocking/route.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { sew } from "@/actions";
import { _getConfiguredLanguageModelsFull, _getAISDKLanguageModelAndOptions, updateChatMessages, generateAndUpdateChatNameFromMessage } from "@/features/chat/actions";
import { _getConfiguredLanguageModelsFull, _getAISDKLanguageModelAndOptions, _updateChatMessages, generateAndUpdateChatNameFromMessage, _generateChatNameFromMessage } from "@/features/chat/actions";
import { LanguageModelInfo, languageModelInfoSchema, SBChatMessage, SearchScope } from "@/features/chat/types";
import { convertLLMOutputToPortableMarkdown, getAnswerPartFromAssistantMessage, getLanguageModelKey } from "@/features/chat/utils";
import { ErrorCode } from "@/lib/errorCodes";
Expand All@@ -15,6 +15,7 @@ import { z } from "zod";
import { createMessageStream } from "../route";
import { InferUIMessageChunk, UITools, UIDataTypes, UIMessage } from "ai";
import { apiHandler } from "@/lib/apiHandler";
import { captureEvent } from "@/lib/posthog";

const logger = createLogger('chat-blocking-api');

Expand DownExpand Up@@ -115,6 +116,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
});

await captureEvent('wa_chat_thread_created', {
chatId: chat.id,
isAnonymous: !user,
});

// Run the agent to completion
logger.debug(`Starting blocking agent for chat ${chat.id}`, {
chatId: chat.id,
Expand DownExpand Up@@ -155,7 +161,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
// We'll capture the final messages and usage from the stream
let finalMessages: SBChatMessage[] = [];

await captureEvent('wa_chat_message_sent', {
chatId: chat.id,
messageCount: 1,
});

const stream = await createMessageStream({
chatId: chat.id,
messages: [userMessage],
selectedSearchScopes,
model,
Expand All@@ -180,21 +192,28 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
})

await Promise.all([
const [_, name] = await Promise.all([
// Consume the stream fully to trigger onFinish
blockStreamUntilFinish(stream),
// Generate and update the chat name
generateAndUpdateChatNameFromMessage({
chatId: chat.id,
languageModelId: languageModelConfig.model,
_generateChatNameFromMessage({
message: query,
languageModelConfig,
})
]);

// Persist the messages to the chat
await updateChatMessages({
chatId: chat.id,
messages: finalMessages,
await _updateChatMessages({ chatId: chat.id, messages: finalMessages, prisma });
Comment thread
brendan-kellam marked this conversation as resolved.

// Update the chat name
await prisma.chat.update({
where: {
id: chat.id,
orgId: org.id,
},
data: {
name: name,
},
});

// Extract the answer text from the assistant message
Expand Down
17 changes: 12 additions & 5 deletions packages/web/src/app/api/(server)/chat/route.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { sew } from "@/actions";
import { _getConfiguredLanguageModelsFull, _getAISDKLanguageModelAndOptions, updateChatMessages, _isOwnerOfChat } from "@/features/chat/actions";
import { _getConfiguredLanguageModelsFull, _getAISDKLanguageModelAndOptions, _updateChatMessages, _isOwnerOfChat } from "@/features/chat/actions";
import { createAgentStream } from "@/features/chat/agent";
import { additionalChatRequestParamsSchema, LanguageModelInfo, SBChatMessage, SearchScope } from "@/features/chat/types";
import { getAnswerPartFromAssistantMessage, getLanguageModelKey } from "@/features/chat/utils";
Expand All@@ -12,6 +12,7 @@ import { LanguageModelV2 as AISDKLanguageModelV2 } from "@ai-sdk/provider";
import * as Sentry from "@sentry/nextjs";
import { PrismaClient } from "@sourcebot/db";
import { createLogger } from "@sourcebot/shared";
import { captureEvent } from "@/lib/posthog";
import {
createUIMessageStream,
createUIMessageStreamResponse,
Expand DownExpand Up@@ -88,7 +89,13 @@ export const POST = apiHandler(async (req: NextRequest) => {

const { model, providerOptions } = await _getAISDKLanguageModelAndOptions(languageModelConfig);

await captureEvent('wa_chat_message_sent', {
chatId: id,
messageCount: messages.length,
});

const stream = await createMessageStream({
chatId: id,
messages,
selectedSearchScopes,
model,
Expand All@@ -97,10 +104,7 @@ export const POST = apiHandler(async (req: NextRequest) => {
orgId: org.id,
prisma,
onFinish: async ({ messages }) => {
await updateChatMessages({
chatId: id,
messages
});
await _updateChatMessages({ chatId: id, messages, prisma });
},
onError: (error: unknown) => {
logger.error(error);
Expand DownExpand Up@@ -146,6 +150,7 @@ const mergeStreamAsync = async (stream: StreamTextResult<any, any>, writer: UIMe
}

interface CreateMessageStreamResponseProps {
chatId: string;
messages: SBChatMessage[];
selectedSearchScopes: SearchScope[];
model: AISDKLanguageModelV2;
Expand All@@ -158,6 +163,7 @@ interface CreateMessageStreamResponseProps {
}

export const createMessageStream = async ({
chatId,
messages,
selectedSearchScopes,
model,
Expand DownExpand Up@@ -242,6 +248,7 @@ export const createMessageStream = async ({
});
},
traceId,
chatId,
});

await mergeStreamAsync(researchStream, writer, {
Expand Down
163 changes: 99 additions & 64 deletions packages/web/src/features/chat/actions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,7 @@
import { sew } from "@/actions";
import { getAuditService } from "@/ee/features/audit/factory";
import { ErrorCode } from "@/lib/errorCodes";
import { notFound, serviceErrorResponse } from "@/lib/serviceError";
import { notFound, ServiceError } from "@/lib/serviceError";
import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock';
import { AnthropicProviderOptions, createAnthropic } from '@ai-sdk/anthropic';
import { createAzure } from '@ai-sdk/azure';
Expand DownExpand Up@@ -62,7 +62,7 @@ export const _isOwnerOfChat = async (chat: Chat, user: User | undefined): Promis
/**
* Checks if a user has been explicitly shared access to a chat.
*/
export const _hasSharedAccess = async ({prisma, chatId, userId}: {prisma: PrismaClient, chatId: string, userId: string | undefined}): Promise<boolean> => {
export const _hasSharedAccess = async ({prisma, chatId, userId}: {prisma: PrismaClient, chatId: string, userId: string | undefined}): Promise<boolean> => {
if (!userId) {
return false;
}
Expand All@@ -79,6 +79,55 @@ export const _hasSharedAccess = async ({prisma, chatId, userId}: {prisma: Prisma
return share !== null;
};

export const _updateChatMessages = async ({ chatId, messages, prisma }: { chatId: string, messages: SBChatMessage[], prisma: PrismaClient }) => {
await prisma.chat.update({
where: {
id: chatId,
},
data: {
messages: messages as unknown as Prisma.InputJsonValue,
},
});

if (env.DEBUG_WRITE_CHAT_MESSAGES_TO_FILE) {
const chatDir = path.join(env.DATA_CACHE_DIR, 'chats');
if (!fs.existsSync(chatDir)) {
fs.mkdirSync(chatDir, { recursive: true });
}

const chatFile = path.join(chatDir, `${chatId}.json`);
fs.writeFileSync(chatFile, JSON.stringify(messages, null, 2));

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
This path depends on a
user-provided value
.

Copilot Autofix

AI 7 months ago

In general, to fix uncontrolled data in a path expression when only a filename is needed, validate or sanitize the user-controlled value before using it, e.g. by whitelisting allowed characters or using a library like sanitize-filename. This prevents .., path separators, or other special characters from influencing directory traversal.

For this specific case in packages/web/src/features/chat/actions.ts, the simplest safe fix without changing existing functionality is:

  • Keep chatDir as-is (path.join(env.DATA_CACHE_DIR, 'chats')).
  • Before constructing chatFile, derive a safe filename from chatId:
    • Either strictly validate that chatId matches an expected pattern (for example a UUID or alphanumeric string) and reject/short-circuit if it does not; or
    • Sanitize chatId to strip out disallowed characters and ensure it cannot contain path separators or ...
  • Then build chatFile with this safe filename and write to it.

Because we cannot assume anything about how chatId is formatted elsewhere, a robust fix inside this snippet is to sanitize / normalize chatId to an allow-listed pattern (e.g. [A-Za-z0-9_-]) and fall back to a deterministic safe name if sanitization would yield an empty string. This avoids introducing new dependencies while keeping behavior for normal IDs identical.

Concretely:

  • In _updateChatMessages, right before const chatFile = path.join(chatDir, \${chatId}.json`);, create a safeChatIdvariable that strips all characters except[A-Za-z0-9_-]`.
  • If safeChatId is empty after stripping, assign a safe placeholder like "unknown-chat".
  • Use safeChatId instead of chatId in the filename interpolation.

No new imports are required; we can do this with basic string replacement and a regular expression. The only file to modify is packages/web/src/features/chat/actions.ts, within the _updateChatMessages function around lines 92–100.

Suggested changeset 1
packages/web/src/features/chat/actions.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/packages/web/src/features/chat/actions.ts b/packages/web/src/features/chat/actions.ts
--- a/packages/web/src/features/chat/actions.ts
+++ b/packages/web/src/features/chat/actions.ts
@@ -95,7 +95,13 @@
fs.mkdirSync(chatDir, { recursive: true });
}
- const chatFile = path.join(chatDir, `${chatId}.json`);
+ // Sanitize chatId to prevent path traversal or invalid filename characters.
+ let safeChatId = chatId.replace(/[^a-zA-Z0-9_-]/g, '');
+ if (!safeChatId) {
+ safeChatId = 'unknown-chat';
+ }
+
+ const chatFile = path.join(chatDir, `${safeChatId}.json`);
fs.writeFileSync(chatFile, JSON.stringify(messages, null, 2));
}
};
EOF
@@ -95,7 +95,13 @@
fs.mkdirSync(chatDir,{recursive: true});
}

constchatFile=path.join(chatDir,`${chatId}.json`);
// Sanitize chatId to prevent path traversal or invalid filename characters.
letsafeChatId=chatId.replace(/[^a-zA-Z0-9_-]/g,'');
if(!safeChatId){
safeChatId='unknown-chat';
}

constchatFile=path.join(chatDir,`${safeChatId}.json`);
fs.writeFileSync(chatFile,JSON.stringify(messages,null,2));
}
};
Copilot is powered by AI and may make mistakes. Always verify output.
Unable to commit as this autofix suggestion is now outdated

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ignoring since a) this code is only called when a debug flag is set, and b) the chatId is a cuid.

}
Comment thread
brendan-kellam marked this conversation as resolved.
};


export const _generateChatNameFromMessage = async ({ message, languageModelConfig }: { message: string, languageModelConfig: LanguageModel }) => {
const { model } = await _getAISDKLanguageModelAndOptions(languageModelConfig);

const prompt = `Convert this question into a short topic title (max 50 characters).

Rules:
- Do NOT include question words (what, where, how, why, when, which)
- Do NOT end with a question mark
- Capitalize the first letter of the title
- Focus on the subject/topic being discussed
- Make it sound like a file name or category

Examples:
"Where is the authentication code?" → "Authentication Code"
"How to setup the database?" → "Database Setup"
"What are the API endpoints?" → "API Endpoints"

User question: ${message}`;

const result = await generateText({
model,
prompt,
});

return result.text;
}
Comment thread
brendan-kellam marked this conversation as resolved.

export const createChat = async () => sew(() =>
withOptionalAuthV2(async ({ org, user, prisma }) => {
const isGuestUser = user === undefined;
Expand DownExpand Up@@ -112,6 +161,11 @@ export const createChat = async () => sew(() =>
});
}

await captureEvent('wa_chat_thread_created', {
chatId: chat.id,
isAnonymous: isGuestUser,
});

return {
id: chat.id,
isAnonymous: isGuestUser,
Expand All@@ -133,7 +187,7 @@ export const getChatInfo = async ({ chatId }: { chatId: string }) => sew(() =>
}

const isOwner = await _isOwnerOfChat(chat, user);
const isSharedWithUser = await _hasSharedAccess({prisma, chatId, userId: user?.id});
const isSharedWithUser = await _hasSharedAccess({prisma, chatId, userId: user?.id});

// Private chats can only be viewed by the owner or users it's been shared with
if (chat.visibility === ChatVisibility.PRIVATE && !isOwner && !isSharedWithUser) {
Expand DownExpand Up@@ -170,24 +224,7 @@ export const updateChatMessages = async ({ chatId, messages }: { chatId: string,
return notFound();
}

await prisma.chat.update({
where: {
id: chatId,
},
data: {
messages: messages as unknown as Prisma.InputJsonValue,
},
});

if (env.DEBUG_WRITE_CHAT_MESSAGES_TO_FILE) {
const chatDir = path.join(env.DATA_CACHE_DIR, 'chats');
if (!fs.existsSync(chatDir)) {
fs.mkdirSync(chatDir, { recursive: true });
}

const chatFile = path.join(chatDir, `${chatId}.json`);
fs.writeFileSync(chatFile, JSON.stringify(messages, null, 2));
}
await _updateChatMessages({ chatId, messages, prisma });

return {
success: true,
Expand DownExpand Up@@ -295,54 +332,52 @@ export const updateChatVisibility = async ({ chatId, visibility }: { chatId: str
);

export const generateAndUpdateChatNameFromMessage = async ({ chatId, languageModelId, message }: { chatId: string, languageModelId: string, message: string }) => sew(() =>
withOptionalAuthV2(async () => {
// From the language model ID, attempt to find the
// corresponding config in `config.json`.
const languageModelConfig =
(await _getConfiguredLanguageModelsFull())
.find((model) => model.model === languageModelId);

if (!languageModelConfig) {
return serviceErrorResponse({
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: `Language model ${languageModelId} is not configured.`,
});
}

const { model } = await _getAISDKLanguageModelAndOptions(languageModelConfig);

const prompt = `Convert this question into a short topic title (max 50 characters).
withOptionalAuthV2(async ({ prisma, user, org }) => {
const chat = await prisma.chat.findUnique({
where: {
id: chatId,
orgId: org.id,
},
});

Rules:
- Do NOT include question words (what, where, how, why, when, which)
- Do NOT end with a question mark
- Capitalize the first letter of the title
- Focus on the subject/topic being discussed
- Make it sound like a file name or category
if (!chat) {
return notFound();
}

Examples:
"Where is the authentication code?" → "Authentication Code"
"How to setup the database?" → "Database Setup"
"What are the API endpoints?" → "API Endpoints"
const isOwner = await _isOwnerOfChat(chat, user);
if (!isOwner) {
return notFound();
}

User question: ${message}`;
const languageModelConfig =
(await _getConfiguredLanguageModelsFull())
.find((model) => model.model === languageModelId);

const result = await generateText({
model,
prompt,
});
if (!languageModelConfig) {
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: `Language model ${languageModelId} is not configured.`,
} satisfies ServiceError;
}
Comment thread
brendan-kellam marked this conversation as resolved.

await updateChatName({
chatId,
name: result.text,
});
const name = await _generateChatNameFromMessage({ message, languageModelConfig });

return {
success: true,
}
await prisma.chat.update({
where: {
id: chatId,
orgId: org.id,
},
data: {
name: name,
},
})
)

return {
success: true,
}
})
)

export const deleteChat = async ({ chatId }: { chatId: string }) => sew(() =>
withAuthV2(async ({ org, user, prisma }) => {
Expand DownExpand Up@@ -436,7 +471,7 @@ export const duplicateChat = async ({ chatId, newName }: { chatId: string, newNa

// Check if user can access the chat (owner, shared, or public)
const isOwner = await _isOwnerOfChat(originalChat, user);
const isSharedWithUser = await _hasSharedAccess({prisma, chatId, userId: user?.id});
const isSharedWithUser = await _hasSharedAccess({prisma, chatId, userId: user?.id});
if (originalChat.visibility === ChatVisibility.PRIVATE && !isOwner && !isSharedWithUser) {
return notFound();
}
Expand DownExpand Up@@ -617,7 +652,7 @@ export const submitFeedback = async ({
}

// When a chat is private, only the creator or shared users can submit feedback.
const isSharedWithUser = await _hasSharedAccess({prisma, chatId, userId: user?.id});
const isSharedWithUser = await _hasSharedAccess({prisma, chatId, userId: user?.id});
if (chat.visibility === ChatVisibility.PRIVATE && chat.createdById !== user?.id && !isSharedWithUser) {
return notFound();
}
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed
- Fixed issue where chat threads created via the `/api/chat/blocking` endpoint would not have any messages when called without authentication. [#907](https://github.com/sourcebot-dev/sourcebot/pull/907)

### Changed
- Added `chatId` to all chat related posthog events. [#907](https://github.com/sourcebot-dev/sourcebot/pull/907)
Comment on lines +13 to +14

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Minor wording and categorization nits on the chatId entry.

Three small issues on line 14:

  1. Wrong section — the verb "Added" signals a new feature/field, which belongs under ### Added rather than ### Changed. Existing entries that introduce new fields consistently live in ### Added (e.g. "Added \install_id` to PostHog event properties."` in v4.10.30).
  2. CapitalizationposthogPostHog (consistent with every other reference in the file, e.g. lines 56, 190, 531).
  3. Missing hyphenchat relatedchat-related (compound modifier before a noun; also flagged by static analysis).
✏️ Proposed fix
-### Changed-- Added `chatId` to all chat related posthog events. [`#907`](https://github.com/sourcebot-dev/sourcebot/pull/907)+### Added+- Added `chatId` to all chat-related PostHog events. [`#907`](https://github.com/sourcebot-dev/sourcebot/pull/907)
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
### Changed
- Added `chatId` to all chatrelated posthog events. [#907](https://github.com/sourcebot-dev/sourcebot/pull/907)
### Added
- Added `chatId` to all chat-related PostHog events. [`#907`](https://github.com/sourcebot-dev/sourcebot/pull/907)
🧰 Tools
🪛 LanguageTool

[grammar] ~14-~14: Use a hyphen to join words.
Context: ...### Changed - Added chatId to all chat related posthog events. [#907](https://g...

(QB_NEW_EN_HYPHEN)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@CHANGELOG.md` around lines 13 - 14, Move the `chatId` line from the "Changed"
section into the "Added" section and update its wording to read: use "PostHog"
(capital P and H) and hyphenate "chat-related", e.g. "Added `chatId` to all
chat-related PostHog event properties." so it matches existing changelog
conventions.


## [4.11.2] - 2026-02-18

### Fixed
Expand Down
35 changes: 27 additions & 8 deletions packages/web/src/app/api/(server)/chat/blocking/route.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { sew } from "@/actions";
import { _getConfiguredLanguageModelsFull, _getAISDKLanguageModelAndOptions, updateChatMessages, generateAndUpdateChatNameFromMessage } from "@/features/chat/actions";
import { _getConfiguredLanguageModelsFull, _getAISDKLanguageModelAndOptions, _updateChatMessages, generateAndUpdateChatNameFromMessage, _generateChatNameFromMessage } from "@/features/chat/actions";
import { LanguageModelInfo, languageModelInfoSchema, SBChatMessage, SearchScope } from "@/features/chat/types";
import { convertLLMOutputToPortableMarkdown, getAnswerPartFromAssistantMessage, getLanguageModelKey } from "@/features/chat/utils";
import { ErrorCode } from "@/lib/errorCodes";
Expand All@@ -15,6 +15,7 @@ import { z } from "zod";
import { createMessageStream } from "../route";
import { InferUIMessageChunk, UITools, UIDataTypes, UIMessage } from "ai";
import { apiHandler } from "@/lib/apiHandler";
import { captureEvent } from "@/lib/posthog";

const logger = createLogger('chat-blocking-api');

Expand DownExpand Up@@ -115,6 +116,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
});

await captureEvent('wa_chat_thread_created', {
chatId: chat.id,
isAnonymous: !user,
});

// Run the agent to completion
logger.debug(`Starting blocking agent for chat ${chat.id}`, {
chatId: chat.id,
Expand DownExpand Up@@ -155,7 +161,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
// We'll capture the final messages and usage from the stream
let finalMessages: SBChatMessage[] = [];

await captureEvent('wa_chat_message_sent', {
chatId: chat.id,
messageCount: 1,
});

const stream = await createMessageStream({
chatId: chat.id,
messages: [userMessage],
selectedSearchScopes,
model,
Expand All@@ -180,21 +192,28 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
})

await Promise.all([
const [_, name] = await Promise.all([
// Consume the stream fully to trigger onFinish
blockStreamUntilFinish(stream),
// Generate and update the chat name
generateAndUpdateChatNameFromMessage({
chatId: chat.id,
languageModelId: languageModelConfig.model,
_generateChatNameFromMessage({
message: query,
languageModelConfig,
})
]);

// Persist the messages to the chat
await updateChatMessages({
chatId: chat.id,
messages: finalMessages,
await _updateChatMessages({ chatId: chat.id, messages: finalMessages, prisma });
Comment thread
brendan-kellam marked this conversation as resolved.

// Update the chat name
await prisma.chat.update({
where: {
id: chat.id,
orgId: org.id,
},
data: {
name: name,
},
});

// Extract the answer text from the assistant message
Expand Down
17 changes: 12 additions & 5 deletions packages/web/src/app/api/(server)/chat/route.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { sew } from "@/actions";
import { _getConfiguredLanguageModelsFull, _getAISDKLanguageModelAndOptions, updateChatMessages, _isOwnerOfChat } from "@/features/chat/actions";
import { _getConfiguredLanguageModelsFull, _getAISDKLanguageModelAndOptions, _updateChatMessages, _isOwnerOfChat } from "@/features/chat/actions";
import { createAgentStream } from "@/features/chat/agent";
import { additionalChatRequestParamsSchema, LanguageModelInfo, SBChatMessage, SearchScope } from "@/features/chat/types";
import { getAnswerPartFromAssistantMessage, getLanguageModelKey } from "@/features/chat/utils";
Expand All@@ -12,6 +12,7 @@ import { LanguageModelV2 as AISDKLanguageModelV2 } from "@ai-sdk/provider";
import * as Sentry from "@sentry/nextjs";
import { PrismaClient } from "@sourcebot/db";
import { createLogger } from "@sourcebot/shared";
import { captureEvent } from "@/lib/posthog";
import {
createUIMessageStream,
createUIMessageStreamResponse,
Expand DownExpand Up@@ -88,7 +89,13 @@ export const POST = apiHandler(async (req: NextRequest) => {

const { model, providerOptions } = await _getAISDKLanguageModelAndOptions(languageModelConfig);

await captureEvent('wa_chat_message_sent', {
chatId: id,
messageCount: messages.length,
});

const stream = await createMessageStream({
chatId: id,
messages,
selectedSearchScopes,
model,
Expand All@@ -97,10 +104,7 @@ export const POST = apiHandler(async (req: NextRequest) => {
orgId: org.id,
prisma,
onFinish: async ({ messages }) => {
await updateChatMessages({
chatId: id,
messages
});
await _updateChatMessages({ chatId: id, messages, prisma });
},
onError: (error: unknown) => {
logger.error(error);
Expand DownExpand Up@@ -146,6 +150,7 @@ const mergeStreamAsync = async (stream: StreamTextResult<any, any>, writer: UIMe
}

interface CreateMessageStreamResponseProps {
chatId: string;
messages: SBChatMessage[];
selectedSearchScopes: SearchScope[];
model: AISDKLanguageModelV2;
Expand All@@ -158,6 +163,7 @@ interface CreateMessageStreamResponseProps {
}

export const createMessageStream = async ({
chatId,
messages,
selectedSearchScopes,
model,
Expand DownExpand Up@@ -242,6 +248,7 @@ export const createMessageStream = async ({
});
},
traceId,
chatId,
});

await mergeStreamAsync(researchStream, writer, {
Expand Down
163 changes: 99 additions & 64 deletions packages/web/src/features/chat/actions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,7 @@
import { sew } from "@/actions";
import { getAuditService } from "@/ee/features/audit/factory";
import { ErrorCode } from "@/lib/errorCodes";
import { notFound, serviceErrorResponse } from "@/lib/serviceError";
import { notFound, ServiceError } from "@/lib/serviceError";
import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock';
import { AnthropicProviderOptions, createAnthropic } from '@ai-sdk/anthropic';
import { createAzure } from '@ai-sdk/azure';
Expand DownExpand Up@@ -62,7 +62,7 @@ export const _isOwnerOfChat = async (chat: Chat, user: User | undefined): Promis
/**
* Checks if a user has been explicitly shared access to a chat.
*/
export const _hasSharedAccess = async ({prisma, chatId, userId}: {prisma: PrismaClient, chatId: string, userId: string | undefined}): Promise<boolean> => {
export const _hasSharedAccess = async ({prisma, chatId, userId}: {prisma: PrismaClient, chatId: string, userId: string | undefined}): Promise<boolean> => {
if (!userId) {
return false;
}
Expand All@@ -79,6 +79,55 @@ export const _hasSharedAccess = async ({prisma, chatId, userId}: {prisma: Prisma
return share !== null;
};

export const _updateChatMessages = async ({ chatId, messages, prisma }: { chatId: string, messages: SBChatMessage[], prisma: PrismaClient }) => {
await prisma.chat.update({
where: {
id: chatId,
},
data: {
messages: messages as unknown as Prisma.InputJsonValue,
},
});

if (env.DEBUG_WRITE_CHAT_MESSAGES_TO_FILE) {
const chatDir = path.join(env.DATA_CACHE_DIR, 'chats');
if (!fs.existsSync(chatDir)) {
fs.mkdirSync(chatDir, { recursive: true });
}

const chatFile = path.join(chatDir, `${chatId}.json`);
fs.writeFileSync(chatFile, JSON.stringify(messages, null, 2));

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
This path depends on a
user-provided value
.

Copilot Autofix

AI 7 months ago

In general, to fix uncontrolled data in a path expression when only a filename is needed, validate or sanitize the user-controlled value before using it, e.g. by whitelisting allowed characters or using a library like sanitize-filename. This prevents .., path separators, or other special characters from influencing directory traversal.

For this specific case in packages/web/src/features/chat/actions.ts, the simplest safe fix without changing existing functionality is:

  • Keep chatDir as-is (path.join(env.DATA_CACHE_DIR, 'chats')).
  • Before constructing chatFile, derive a safe filename from chatId:
    • Either strictly validate that chatId matches an expected pattern (for example a UUID or alphanumeric string) and reject/short-circuit if it does not; or
    • Sanitize chatId to strip out disallowed characters and ensure it cannot contain path separators or ...
  • Then build chatFile with this safe filename and write to it.

Because we cannot assume anything about how chatId is formatted elsewhere, a robust fix inside this snippet is to sanitize / normalize chatId to an allow-listed pattern (e.g. [A-Za-z0-9_-]) and fall back to a deterministic safe name if sanitization would yield an empty string. This avoids introducing new dependencies while keeping behavior for normal IDs identical.

Concretely:

  • In _updateChatMessages, right before const chatFile = path.join(chatDir, \${chatId}.json`);, create a safeChatIdvariable that strips all characters except[A-Za-z0-9_-]`.
  • If safeChatId is empty after stripping, assign a safe placeholder like "unknown-chat".
  • Use safeChatId instead of chatId in the filename interpolation.

No new imports are required; we can do this with basic string replacement and a regular expression. The only file to modify is packages/web/src/features/chat/actions.ts, within the _updateChatMessages function around lines 92–100.

Suggested changeset 1
packages/web/src/features/chat/actions.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/packages/web/src/features/chat/actions.ts b/packages/web/src/features/chat/actions.ts
--- a/packages/web/src/features/chat/actions.ts
+++ b/packages/web/src/features/chat/actions.ts
@@ -95,7 +95,13 @@
fs.mkdirSync(chatDir, { recursive: true });
}
- const chatFile = path.join(chatDir, `${chatId}.json`);
+ // Sanitize chatId to prevent path traversal or invalid filename characters.
+ let safeChatId = chatId.replace(/[^a-zA-Z0-9_-]/g, '');
+ if (!safeChatId) {
+ safeChatId = 'unknown-chat';
+ }
+
+ const chatFile = path.join(chatDir, `${safeChatId}.json`);
fs.writeFileSync(chatFile, JSON.stringify(messages, null, 2));
}
};
EOF
@@ -95,7 +95,13 @@
fs.mkdirSync(chatDir,{recursive: true});
}

constchatFile=path.join(chatDir,`${chatId}.json`);
// Sanitize chatId to prevent path traversal or invalid filename characters.
letsafeChatId=chatId.replace(/[^a-zA-Z0-9_-]/g,'');
if(!safeChatId){
safeChatId='unknown-chat';
}

constchatFile=path.join(chatDir,`${safeChatId}.json`);
fs.writeFileSync(chatFile,JSON.stringify(messages,null,2));
}
};
Copilot is powered by AI and may make mistakes. Always verify output.
Unable to commit as this autofix suggestion is now outdated

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ignoring since a) this code is only called when a debug flag is set, and b) the chatId is a cuid.

}
Comment thread
brendan-kellam marked this conversation as resolved.
};


export const _generateChatNameFromMessage = async ({ message, languageModelConfig }: { message: string, languageModelConfig: LanguageModel }) => {
const { model } = await _getAISDKLanguageModelAndOptions(languageModelConfig);

const prompt = `Convert this question into a short topic title (max 50 characters).

Rules:
- Do NOT include question words (what, where, how, why, when, which)
- Do NOT end with a question mark
- Capitalize the first letter of the title
- Focus on the subject/topic being discussed
- Make it sound like a file name or category

Examples:
"Where is the authentication code?" → "Authentication Code"
"How to setup the database?" → "Database Setup"
"What are the API endpoints?" → "API Endpoints"

User question: ${message}`;

const result = await generateText({
model,
prompt,
});

return result.text;
}
Comment thread
brendan-kellam marked this conversation as resolved.

export const createChat = async () => sew(() =>
withOptionalAuthV2(async ({ org, user, prisma }) => {
const isGuestUser = user === undefined;
Expand DownExpand Up@@ -112,6 +161,11 @@ export const createChat = async () => sew(() =>
});
}

await captureEvent('wa_chat_thread_created', {
chatId: chat.id,
isAnonymous: isGuestUser,
});

return {
id: chat.id,
isAnonymous: isGuestUser,
Expand All@@ -133,7 +187,7 @@ export const getChatInfo = async ({ chatId }: { chatId: string }) => sew(() =>
}

const isOwner = await _isOwnerOfChat(chat, user);
const isSharedWithUser = await _hasSharedAccess({prisma, chatId, userId: user?.id});
const isSharedWithUser = await _hasSharedAccess({prisma, chatId, userId: user?.id});

// Private chats can only be viewed by the owner or users it's been shared with
if (chat.visibility === ChatVisibility.PRIVATE && !isOwner && !isSharedWithUser) {
Expand DownExpand Up@@ -170,24 +224,7 @@ export const updateChatMessages = async ({ chatId, messages }: { chatId: string,
return notFound();
}

await prisma.chat.update({
where: {
id: chatId,
},
data: {
messages: messages as unknown as Prisma.InputJsonValue,
},
});

if (env.DEBUG_WRITE_CHAT_MESSAGES_TO_FILE) {
const chatDir = path.join(env.DATA_CACHE_DIR, 'chats');
if (!fs.existsSync(chatDir)) {
fs.mkdirSync(chatDir, { recursive: true });
}

const chatFile = path.join(chatDir, `${chatId}.json`);
fs.writeFileSync(chatFile, JSON.stringify(messages, null, 2));
}
await _updateChatMessages({ chatId, messages, prisma });

return {
success: true,
Expand DownExpand Up@@ -295,54 +332,52 @@ export const updateChatVisibility = async ({ chatId, visibility }: { chatId: str
);

export const generateAndUpdateChatNameFromMessage = async ({ chatId, languageModelId, message }: { chatId: string, languageModelId: string, message: string }) => sew(() =>
withOptionalAuthV2(async () => {
// From the language model ID, attempt to find the
// corresponding config in `config.json`.
const languageModelConfig =
(await _getConfiguredLanguageModelsFull())
.find((model) => model.model === languageModelId);

if (!languageModelConfig) {
return serviceErrorResponse({
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: `Language model ${languageModelId} is not configured.`,
});
}

const { model } = await _getAISDKLanguageModelAndOptions(languageModelConfig);

const prompt = `Convert this question into a short topic title (max 50 characters).
withOptionalAuthV2(async ({ prisma, user, org }) => {
const chat = await prisma.chat.findUnique({
where: {
id: chatId,
orgId: org.id,
},
});

Rules:
- Do NOT include question words (what, where, how, why, when, which)
- Do NOT end with a question mark
- Capitalize the first letter of the title
- Focus on the subject/topic being discussed
- Make it sound like a file name or category
if (!chat) {
return notFound();
}

Examples:
"Where is the authentication code?" → "Authentication Code"
"How to setup the database?" → "Database Setup"
"What are the API endpoints?" → "API Endpoints"
const isOwner = await _isOwnerOfChat(chat, user);
if (!isOwner) {
return notFound();
}

User question: ${message}`;
const languageModelConfig =
(await _getConfiguredLanguageModelsFull())
.find((model) => model.model === languageModelId);

const result = await generateText({
model,
prompt,
});
if (!languageModelConfig) {
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: `Language model ${languageModelId} is not configured.`,
} satisfies ServiceError;
}
Comment thread
brendan-kellam marked this conversation as resolved.

await updateChatName({
chatId,
name: result.text,
});
const name = await _generateChatNameFromMessage({ message, languageModelConfig });

return {
success: true,
}
await prisma.chat.update({
where: {
id: chatId,
orgId: org.id,
},
data: {
name: name,
},
})
)

return {
success: true,
}
})
)

export const deleteChat = async ({ chatId }: { chatId: string }) => sew(() =>
withAuthV2(async ({ org, user, prisma }) => {
Expand DownExpand Up@@ -436,7 +471,7 @@ export const duplicateChat = async ({ chatId, newName }: { chatId: string, newNa

// Check if user can access the chat (owner, shared, or public)
const isOwner = await _isOwnerOfChat(originalChat, user);
const isSharedWithUser = await _hasSharedAccess({prisma, chatId, userId: user?.id});
const isSharedWithUser = await _hasSharedAccess({prisma, chatId, userId: user?.id});
if (originalChat.visibility === ChatVisibility.PRIVATE && !isOwner && !isSharedWithUser) {
return notFound();
}
Expand DownExpand Up@@ -617,7 +652,7 @@ export const submitFeedback = async ({
}

// When a chat is private, only the creator or shared users can submit feedback.
const isSharedWithUser = await _hasSharedAccess({prisma, chatId, userId: user?.id});
const isSharedWithUser = await _hasSharedAccess({prisma, chatId, userId: user?.id});
if (chat.visibility === ChatVisibility.PRIVATE && chat.createdById !== user?.id && !isSharedWithUser) {
return notFound();
}
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed
- Fixed issue where chat threads created via the `/api/chat/blocking` endpoint would not have any messages when called without authentication. [#907](https://github.com/sourcebot-dev/sourcebot/pull/907)

### Changed
- Added `chatId` to all chat related posthog events. [#907](https://github.com/sourcebot-dev/sourcebot/pull/907)
Comment on lines +13 to +14

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Minor wording and categorization nits on the chatId entry.

Three small issues on line 14:

  1. Wrong section — the verb "Added" signals a new feature/field, which belongs under ### Added rather than ### Changed. Existing entries that introduce new fields consistently live in ### Added (e.g. "Added \install_id` to PostHog event properties."` in v4.10.30).
  2. CapitalizationposthogPostHog (consistent with every other reference in the file, e.g. lines 56, 190, 531).
  3. Missing hyphenchat relatedchat-related (compound modifier before a noun; also flagged by static analysis).
✏️ Proposed fix
-### Changed-- Added `chatId` to all chat related posthog events. [`#907`](https://github.com/sourcebot-dev/sourcebot/pull/907)+### Added+- Added `chatId` to all chat-related PostHog events. [`#907`](https://github.com/sourcebot-dev/sourcebot/pull/907)
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
### Changed
- Added `chatId` to all chatrelated posthog events. [#907](https://github.com/sourcebot-dev/sourcebot/pull/907)
### Added
- Added `chatId` to all chat-related PostHog events. [`#907`](https://github.com/sourcebot-dev/sourcebot/pull/907)
🧰 Tools
🪛 LanguageTool

[grammar] ~14-~14: Use a hyphen to join words.
Context: ...### Changed - Added chatId to all chat related posthog events. [#907](https://g...

(QB_NEW_EN_HYPHEN)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@CHANGELOG.md` around lines 13 - 14, Move the `chatId` line from the "Changed"
section into the "Added" section and update its wording to read: use "PostHog"
(capital P and H) and hyphenate "chat-related", e.g. "Added `chatId` to all
chat-related PostHog event properties." so it matches existing changelog
conventions.


## [4.11.2] - 2026-02-18

### Fixed
Expand Down
35 changes: 27 additions & 8 deletions packages/web/src/app/api/(server)/chat/blocking/route.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { sew } from "@/actions";
import { _getConfiguredLanguageModelsFull, _getAISDKLanguageModelAndOptions, updateChatMessages, generateAndUpdateChatNameFromMessage } from "@/features/chat/actions";
import { _getConfiguredLanguageModelsFull, _getAISDKLanguageModelAndOptions, _updateChatMessages, generateAndUpdateChatNameFromMessage, _generateChatNameFromMessage } from "@/features/chat/actions";
import { LanguageModelInfo, languageModelInfoSchema, SBChatMessage, SearchScope } from "@/features/chat/types";
import { convertLLMOutputToPortableMarkdown, getAnswerPartFromAssistantMessage, getLanguageModelKey } from "@/features/chat/utils";
import { ErrorCode } from "@/lib/errorCodes";
Expand All@@ -15,6 +15,7 @@ import { z } from "zod";
import { createMessageStream } from "../route";
import { InferUIMessageChunk, UITools, UIDataTypes, UIMessage } from "ai";
import { apiHandler } from "@/lib/apiHandler";
import { captureEvent } from "@/lib/posthog";

const logger = createLogger('chat-blocking-api');

Expand DownExpand Up@@ -115,6 +116,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
});

await captureEvent('wa_chat_thread_created', {
chatId: chat.id,
isAnonymous: !user,
});

// Run the agent to completion
logger.debug(`Starting blocking agent for chat ${chat.id}`, {
chatId: chat.id,
Expand DownExpand Up@@ -155,7 +161,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
// We'll capture the final messages and usage from the stream
let finalMessages: SBChatMessage[] = [];

await captureEvent('wa_chat_message_sent', {
chatId: chat.id,
messageCount: 1,
});

const stream = await createMessageStream({
chatId: chat.id,
messages: [userMessage],
selectedSearchScopes,
model,
Expand All@@ -180,21 +192,28 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
})

await Promise.all([
const [_, name] = await Promise.all([
// Consume the stream fully to trigger onFinish
blockStreamUntilFinish(stream),
// Generate and update the chat name
generateAndUpdateChatNameFromMessage({
chatId: chat.id,
languageModelId: languageModelConfig.model,
_generateChatNameFromMessage({
message: query,
languageModelConfig,
})
]);

// Persist the messages to the chat
await updateChatMessages({
chatId: chat.id,
messages: finalMessages,
await _updateChatMessages({ chatId: chat.id, messages: finalMessages, prisma });
Comment thread
brendan-kellam marked this conversation as resolved.

// Update the chat name
await prisma.chat.update({
where: {
id: chat.id,
orgId: org.id,
},
data: {
name: name,
},
});

// Extract the answer text from the assistant message
Expand Down
17 changes: 12 additions & 5 deletions packages/web/src/app/api/(server)/chat/route.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import { sew } from "@/actions";
import { _getConfiguredLanguageModelsFull, _getAISDKLanguageModelAndOptions, updateChatMessages, _isOwnerOfChat } from "@/features/chat/actions";
import { _getConfiguredLanguageModelsFull, _getAISDKLanguageModelAndOptions, _updateChatMessages, _isOwnerOfChat } from "@/features/chat/actions";
import { createAgentStream } from "@/features/chat/agent";
import { additionalChatRequestParamsSchema, LanguageModelInfo, SBChatMessage, SearchScope } from "@/features/chat/types";
import { getAnswerPartFromAssistantMessage, getLanguageModelKey } from "@/features/chat/utils";
Expand All@@ -12,6 +12,7 @@ import { LanguageModelV2 as AISDKLanguageModelV2 } from "@ai-sdk/provider";
import * as Sentry from "@sentry/nextjs";
import { PrismaClient } from "@sourcebot/db";
import { createLogger } from "@sourcebot/shared";
import { captureEvent } from "@/lib/posthog";
import {
createUIMessageStream,
createUIMessageStreamResponse,
Expand DownExpand Up@@ -88,7 +89,13 @@ export const POST = apiHandler(async (req: NextRequest) => {

const { model, providerOptions } = await _getAISDKLanguageModelAndOptions(languageModelConfig);

await captureEvent('wa_chat_message_sent', {
chatId: id,
messageCount: messages.length,
});

const stream = await createMessageStream({
chatId: id,
messages,
selectedSearchScopes,
model,
Expand All@@ -97,10 +104,7 @@ export const POST = apiHandler(async (req: NextRequest) => {
orgId: org.id,
prisma,
onFinish: async ({ messages }) => {
await updateChatMessages({
chatId: id,
messages
});
await _updateChatMessages({ chatId: id, messages, prisma });
},
onError: (error: unknown) => {
logger.error(error);
Expand DownExpand Up@@ -146,6 +150,7 @@ const mergeStreamAsync = async (stream: StreamTextResult<any, any>, writer: UIMe
}

interface CreateMessageStreamResponseProps {
chatId: string;
messages: SBChatMessage[];
selectedSearchScopes: SearchScope[];
model: AISDKLanguageModelV2;
Expand All@@ -158,6 +163,7 @@ interface CreateMessageStreamResponseProps {
}

export const createMessageStream = async ({
chatId,
messages,
selectedSearchScopes,
model,
Expand DownExpand Up@@ -242,6 +248,7 @@ export const createMessageStream = async ({
});
},
traceId,
chatId,
});

await mergeStreamAsync(researchStream, writer, {
Expand Down
163 changes: 99 additions & 64 deletions packages/web/src/features/chat/actions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,7 @@
import { sew } from "@/actions";
import { getAuditService } from "@/ee/features/audit/factory";
import { ErrorCode } from "@/lib/errorCodes";
import { notFound, serviceErrorResponse } from "@/lib/serviceError";
import { notFound, ServiceError } from "@/lib/serviceError";
import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock';
import { AnthropicProviderOptions, createAnthropic } from '@ai-sdk/anthropic';
import { createAzure } from '@ai-sdk/azure';
Expand DownExpand Up@@ -62,7 +62,7 @@ export const _isOwnerOfChat = async (chat: Chat, user: User | undefined): Promis
/**
* Checks if a user has been explicitly shared access to a chat.
*/
export const _hasSharedAccess = async ({prisma, chatId, userId}: {prisma: PrismaClient, chatId: string, userId: string | undefined}): Promise<boolean> => {
export const _hasSharedAccess = async ({prisma, chatId, userId}: {prisma: PrismaClient, chatId: string, userId: string | undefined}): Promise<boolean> => {
if (!userId) {
return false;
}
Expand All@@ -79,6 +79,55 @@ export const _hasSharedAccess = async ({prisma, chatId, userId}: {prisma: Prisma
return share !== null;
};

export const _updateChatMessages = async ({ chatId, messages, prisma }: { chatId: string, messages: SBChatMessage[], prisma: PrismaClient }) => {
await prisma.chat.update({
where: {
id: chatId,
},
data: {
messages: messages as unknown as Prisma.InputJsonValue,
},
});

if (env.DEBUG_WRITE_CHAT_MESSAGES_TO_FILE) {
const chatDir = path.join(env.DATA_CACHE_DIR, 'chats');
if (!fs.existsSync(chatDir)) {
fs.mkdirSync(chatDir, { recursive: true });
}

const chatFile = path.join(chatDir, `${chatId}.json`);
fs.writeFileSync(chatFile, JSON.stringify(messages, null, 2));

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
This path depends on a
user-provided value
.

Copilot Autofix

AI 7 months ago

In general, to fix uncontrolled data in a path expression when only a filename is needed, validate or sanitize the user-controlled value before using it, e.g. by whitelisting allowed characters or using a library like sanitize-filename. This prevents .., path separators, or other special characters from influencing directory traversal.

For this specific case in packages/web/src/features/chat/actions.ts, the simplest safe fix without changing existing functionality is:

  • Keep chatDir as-is (path.join(env.DATA_CACHE_DIR, 'chats')).
  • Before constructing chatFile, derive a safe filename from chatId:
    • Either strictly validate that chatId matches an expected pattern (for example a UUID or alphanumeric string) and reject/short-circuit if it does not; or
    • Sanitize chatId to strip out disallowed characters and ensure it cannot contain path separators or ...
  • Then build chatFile with this safe filename and write to it.

Because we cannot assume anything about how chatId is formatted elsewhere, a robust fix inside this snippet is to sanitize / normalize chatId to an allow-listed pattern (e.g. [A-Za-z0-9_-]) and fall back to a deterministic safe name if sanitization would yield an empty string. This avoids introducing new dependencies while keeping behavior for normal IDs identical.

Concretely:

  • In _updateChatMessages, right before const chatFile = path.join(chatDir, \${chatId}.json`);, create a safeChatIdvariable that strips all characters except[A-Za-z0-9_-]`.
  • If safeChatId is empty after stripping, assign a safe placeholder like "unknown-chat".
  • Use safeChatId instead of chatId in the filename interpolation.

No new imports are required; we can do this with basic string replacement and a regular expression. The only file to modify is packages/web/src/features/chat/actions.ts, within the _updateChatMessages function around lines 92–100.

Suggested changeset 1
packages/web/src/features/chat/actions.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/packages/web/src/features/chat/actions.ts b/packages/web/src/features/chat/actions.ts
--- a/packages/web/src/features/chat/actions.ts
+++ b/packages/web/src/features/chat/actions.ts
@@ -95,7 +95,13 @@
fs.mkdirSync(chatDir, { recursive: true });
}
- const chatFile = path.join(chatDir, `${chatId}.json`);
+ // Sanitize chatId to prevent path traversal or invalid filename characters.
+ let safeChatId = chatId.replace(/[^a-zA-Z0-9_-]/g, '');
+ if (!safeChatId) {
+ safeChatId = 'unknown-chat';
+ }
+
+ const chatFile = path.join(chatDir, `${safeChatId}.json`);
fs.writeFileSync(chatFile, JSON.stringify(messages, null, 2));
}
};
EOF
@@ -95,7 +95,13 @@
fs.mkdirSync(chatDir,{recursive: true});
}

constchatFile=path.join(chatDir,`${chatId}.json`);
// Sanitize chatId to prevent path traversal or invalid filename characters.
letsafeChatId=chatId.replace(/[^a-zA-Z0-9_-]/g,'');
if(!safeChatId){
safeChatId='unknown-chat';
}

constchatFile=path.join(chatDir,`${safeChatId}.json`);
fs.writeFileSync(chatFile,JSON.stringify(messages,null,2));
}
};
Copilot is powered by AI and may make mistakes. Always verify output.
Unable to commit as this autofix suggestion is now outdated

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ignoring since a) this code is only called when a debug flag is set, and b) the chatId is a cuid.

}
Comment thread
brendan-kellam marked this conversation as resolved.
};


export const _generateChatNameFromMessage = async ({ message, languageModelConfig }: { message: string, languageModelConfig: LanguageModel }) => {
const { model } = await _getAISDKLanguageModelAndOptions(languageModelConfig);

const prompt = `Convert this question into a short topic title (max 50 characters).

Rules:
- Do NOT include question words (what, where, how, why, when, which)
- Do NOT end with a question mark
- Capitalize the first letter of the title
- Focus on the subject/topic being discussed
- Make it sound like a file name or category

Examples:
"Where is the authentication code?" → "Authentication Code"
"How to setup the database?" → "Database Setup"
"What are the API endpoints?" → "API Endpoints"

User question: ${message}`;

const result = await generateText({
model,
prompt,
});

return result.text;
}
Comment thread
brendan-kellam marked this conversation as resolved.

export const createChat = async () => sew(() =>
withOptionalAuthV2(async ({ org, user, prisma }) => {
const isGuestUser = user === undefined;
Expand DownExpand Up@@ -112,6 +161,11 @@ export const createChat = async () => sew(() =>
});
}

await captureEvent('wa_chat_thread_created', {
chatId: chat.id,
isAnonymous: isGuestUser,
});

return {
id: chat.id,
isAnonymous: isGuestUser,
Expand All@@ -133,7 +187,7 @@ export const getChatInfo = async ({ chatId }: { chatId: string }) => sew(() =>
}

const isOwner = await _isOwnerOfChat(chat, user);
const isSharedWithUser = await _hasSharedAccess({prisma, chatId, userId: user?.id});
const isSharedWithUser = await _hasSharedAccess({prisma, chatId, userId: user?.id});

// Private chats can only be viewed by the owner or users it's been shared with
if (chat.visibility === ChatVisibility.PRIVATE && !isOwner && !isSharedWithUser) {
Expand DownExpand Up@@ -170,24 +224,7 @@ export const updateChatMessages = async ({ chatId, messages }: { chatId: string,
return notFound();
}

await prisma.chat.update({
where: {
id: chatId,
},
data: {
messages: messages as unknown as Prisma.InputJsonValue,
},
});

if (env.DEBUG_WRITE_CHAT_MESSAGES_TO_FILE) {
const chatDir = path.join(env.DATA_CACHE_DIR, 'chats');
if (!fs.existsSync(chatDir)) {
fs.mkdirSync(chatDir, { recursive: true });
}

const chatFile = path.join(chatDir, `${chatId}.json`);
fs.writeFileSync(chatFile, JSON.stringify(messages, null, 2));
}
await _updateChatMessages({ chatId, messages, prisma });

return {
success: true,
Expand DownExpand Up@@ -295,54 +332,52 @@ export const updateChatVisibility = async ({ chatId, visibility }: { chatId: str
);

export const generateAndUpdateChatNameFromMessage = async ({ chatId, languageModelId, message }: { chatId: string, languageModelId: string, message: string }) => sew(() =>
withOptionalAuthV2(async () => {
// From the language model ID, attempt to find the
// corresponding config in `config.json`.
const languageModelConfig =
(await _getConfiguredLanguageModelsFull())
.find((model) => model.model === languageModelId);

if (!languageModelConfig) {
return serviceErrorResponse({
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: `Language model ${languageModelId} is not configured.`,
});
}

const { model } = await _getAISDKLanguageModelAndOptions(languageModelConfig);

const prompt = `Convert this question into a short topic title (max 50 characters).
withOptionalAuthV2(async ({ prisma, user, org }) => {
const chat = await prisma.chat.findUnique({
where: {
id: chatId,
orgId: org.id,
},
});

Rules:
- Do NOT include question words (what, where, how, why, when, which)
- Do NOT end with a question mark
- Capitalize the first letter of the title
- Focus on the subject/topic being discussed
- Make it sound like a file name or category
if (!chat) {
return notFound();
}

Examples:
"Where is the authentication code?" → "Authentication Code"
"How to setup the database?" → "Database Setup"
"What are the API endpoints?" → "API Endpoints"
const isOwner = await _isOwnerOfChat(chat, user);
if (!isOwner) {
return notFound();
}

User question: ${message}`;
const languageModelConfig =
(await _getConfiguredLanguageModelsFull())
.find((model) => model.model === languageModelId);

const result = await generateText({
model,
prompt,
});
if (!languageModelConfig) {
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: `Language model ${languageModelId} is not configured.`,
} satisfies ServiceError;
}
Comment thread
brendan-kellam marked this conversation as resolved.

await updateChatName({
chatId,
name: result.text,
});
const name = await _generateChatNameFromMessage({ message, languageModelConfig });

return {
success: true,
}
await prisma.chat.update({
where: {
id: chatId,
orgId: org.id,
},
data: {
name: name,
},
})
)

return {
success: true,
}
})
)

export const deleteChat = async ({ chatId }: { chatId: string }) => sew(() =>
withAuthV2(async ({ org, user, prisma }) => {
Expand DownExpand Up@@ -436,7 +471,7 @@ export const duplicateChat = async ({ chatId, newName }: { chatId: string, newNa

// Check if user can access the chat (owner, shared, or public)
const isOwner = await _isOwnerOfChat(originalChat, user);
const isSharedWithUser = await _hasSharedAccess({prisma, chatId, userId: user?.id});
const isSharedWithUser = await _hasSharedAccess({prisma, chatId, userId: user?.id});
if (originalChat.visibility === ChatVisibility.PRIVATE && !isOwner && !isSharedWithUser) {
return notFound();
}
Expand DownExpand Up@@ -617,7 +652,7 @@ export const submitFeedback = async ({
}

// When a chat is private, only the creator or shared users can submit feedback.
const isSharedWithUser = await _hasSharedAccess({prisma, chatId, userId: user?.id});
const isSharedWithUser = await _hasSharedAccess({prisma, chatId, userId: user?.id});
if (chat.visibility === ChatVisibility.PRIVATE && chat.createdById !== user?.id && !isSharedWithUser) {
return notFound();
}
Expand Down
Loading