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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions config-noauth.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ NEXT_PUBLIC_COMPANION_URL="http://localhost:3020"
NEXT_PUBLIC_GOPIE_API_URL="http://localhost:8000"
NEXT_PUBLIC_ENABLE_AUTH="false"
GOPIE_API_URL="http://gopie-server:8000"
S3_ENDPOINT=http://localhost:9000
S3_REGION=us-east-1
S3_BUCKET=gopie
S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin

# ==================================
# Chat Server General Configuration
Expand Down
4 changes: 3 additions & 1 deletion docker-compose-noauth.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,9 @@ services:
/usr/bin/mc alias set myminio http://minio:9000 minioadmin minioadmin &&
/usr/bin/mc mb -p myminio/gopie &&
/usr/bin/mc mb -p myminio/downloads &&
/usr/bin/mc anonymous set public myminio/gopie/visualizations
/usr/bin/mc anonymous set private myminio/gopie &&
/usr/bin/mc anonymous set private myminio/gopie/visualizations &&
/usr/bin/mc anonymous set private myminio/downloads
"

include:
Expand Down
7 changes: 5 additions & 2 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -189,10 +189,13 @@ services:
- gopie
entrypoint: >
/bin/sh -c "
echo 'Minio is up. Creating the bucket!!' &&
echo 'Minio is up. Creating required buckets...' &&
/usr/bin/mc alias set myminio http://minio:9000 minioadmin minioadmin &&
/usr/bin/mc mb -p myminio/gopie &&
/usr/bin/mc anonymous set public myminio/gopie
/usr/bin/mc mb -p myminio/downloads &&
/usr/bin/mc anonymous set private myminio/gopie &&
/usr/bin/mc anonymous set private myminio/gopie/visualizations &&
/usr/bin/mc anonymous set private myminio/downloads
"

gopie-reindex:
Expand Down
212 changes: 212 additions & 0 deletions web/bun.lock

Large diffs are not rendered by default.

Binary file added web/bun.lockb
Binary file not shown.
2 changes: 2 additions & 0 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
"dependencies": {
"@ai-sdk/openai": "^2.0.27",
"@ai-sdk/react": "^2.0.39",
"@aws-sdk/client-s3": "^3.933.0",
"@aws-sdk/s3-request-presigner": "^3.933.0",
"@duckdb/duckdb-wasm": "^1.29.1-dev132.0",
"@duckdb/duckdb-wasm-shell": "^1.29.1-dev132.0",
"@hookform/resolvers": "^3.9.1",
Expand Down
107 changes: 107 additions & 0 deletions web/src/app/api/chat/[chatId]/messages/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { signUrl } from "@/lib/s3/signer";
import { NextRequest, NextResponse } from "next/server";

export async function GET(
req: NextRequest,
context: { params: Promise<{ chatId: string }> }
) {
try {
const { chatId } = await context.params;


const upstreamHeaders: Record<string, string> = {};

req.headers.forEach((value, key) => {
if (key.toLowerCase() === "host") return;
upstreamHeaders[key] = value;
});

// Ensure JSON if not provided
if (!upstreamHeaders["content-type"]) {
upstreamHeaders["content-type"] = "application/json";
}

const queryString = req.nextUrl.search;

// Forward request to Go API
const gopieRes = await fetch(
`${process.env.GOPIE_API_URL}/v1/api/chat/${chatId}/messages${queryString}`,
{
method: "GET",
headers: upstreamHeaders,
cache: "no-store",
}
);

if (!gopieRes.ok) {
const errorText = await gopieRes.text();
console.error("GOPIE error:", errorText);
return NextResponse.json(
{ error: "Upstream error" },
{ status: gopieRes.status }
);
}

const json = await gopieRes.json();
const data = json.data || [];

// ---- Mutate ONLY URLs (signing step) ----
for (const msg of data) {
// upstream messages have msg.choices[].delta.tool_calls[]
if (!Array.isArray(msg.choices)) continue;

for (const choice of msg.choices) {
const delta = choice.delta;
if (!delta?.tool_calls) continue;

for (const tc of delta.tool_calls) {
// Only process function calls
if (!tc.function) continue;

const fn = tc.function;

// Parse args safely
let args;
try {
args = JSON.parse(fn.arguments || "{}");
} catch {
continue;
}

// ---- visualization_result ----
if (fn.name === "visualization_result") {
const arr = args.visualization_json_paths;
if (Array.isArray(arr)) {
for (const entry of arr) {
if (entry.json_path) {
entry.json_path = await signUrl(entry.json_path);
}
}
}

fn.arguments = JSON.stringify(args);
}

// ---- visualization_paths ----
if (fn.name === "visualization_paths") {
const arr = args.paths;
if (Array.isArray(arr)) {
args.paths = await Promise.all(arr.map((p) => signUrl(p)));
}

fn.arguments = JSON.stringify(args);
}
}
}
}


return NextResponse.json(json);
} catch (err) {
console.error("Proxy error:", err);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
24 changes: 16 additions & 8 deletions web/src/app/api/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
import type { GoPieUIMessage } from "@/types/chat-message";
import { LRUCache } from "@/lib/utils/lru-cache";
import { transformUIMessagesToBackend } from "@/lib/utils/message-transformation";

import { signUrl } from "@/lib/s3/signer";
// Allow streaming responses up to 30 seconds
export const maxDuration = 30;

Expand Down Expand Up @@ -282,17 +282,25 @@ export async function POST(req: Request) {

case "visualization_paths":
case "visualization_result":
const paths = args.paths ||
(args.visualization_json_paths?.map((v: { json_path?: string }) => v.json_path)) ||
[];
if (paths.length > 0) {
const rawPaths =
args.paths ||
args.visualization_json_paths?.map(
(v: { json_path?: string }) => v.json_path
) ||
[];

if (rawPaths.length > 0) {
const signedPaths = await Promise.all(
rawPaths.map((p: string) => signUrl(p))
);

writer.write({
type: 'data-visualization',
type: "data-visualization",
id: `viz-${toolCall.id || Date.now()}`,
data: {
id: `viz-${toolCall.id || Date.now()}`,
paths,
status: 'ready',
paths: signedPaths,
status: "ready",
},
});
}
Expand Down
2 changes: 1 addition & 1 deletion web/src/components/chat/message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -809,7 +809,7 @@ export function ChatMessage({
Visualization {index + 1}
</p>
<p className="text-xs text-emerald-700 dark:text-emerald-300 truncate font-mono">
{path.split("/").pop() || path}
{path.split("/").pop()?.split("?")[0] || path}
</p>
</div>
</div>
Expand Down
16 changes: 12 additions & 4 deletions web/src/components/chat/visualization-results.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@ import { BarChart3, Download, ExternalLink } from "lucide-react";
import vegaEmbed from "vega-embed";

interface VegaSpec {
title?: string;
title?: string | {
text?: string;
fontSize?: number;
anchor?: string;
[key: string]: unknown;
};

[key: string]: unknown;
}

Expand Down Expand Up @@ -94,8 +100,8 @@ export function VisualizationResults({
},
renderer: "svg" as const,
};

vegaEmbed(vizRefs.current[index]!, updatedSpec, embedOptions).catch(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vegaEmbed(vizRefs.current[index]!, updatedSpec as any, embedOptions).catch(
(error) => {
console.error("Error rendering visualization:", error);
setVisualizations((prev) =>
Expand Down Expand Up @@ -149,7 +155,9 @@ export function VisualizationResults({
>
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium">
{viz.spec?.title || `Visualization ${index + 1}`}
{typeof viz.spec?.title === "string"
? viz.spec.title
: viz.spec?.title?.text || `Visualization ${index + 1}`}
</h4>
<div className="flex gap-1">
{viz.spec && (
Expand Down
33 changes: 33 additions & 0 deletions web/src/lib/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,39 @@ export const apiClient = ky.create({
},
});

export const nextApiClient = ky.create({
headers: {
"Content-Type": "application/json",
},
timeout: false,
hooks: {
beforeRequest: [
(request) => {
const isAuthEnabled = String(process.env.NEXT_PUBLIC_ENABLE_AUTH).trim() === "true";

if (!isAuthEnabled) {
if (!request.headers.get("x-user-id")) {
request.headers.set("x-user-id", "system");
}
if (!request.headers.get("x-organization-id")) {
request.headers.set("x-organization-id", "system");
}
return;
}

const token = getGlobalAccessToken();
if (token && !request.headers.get("Authorization")) {
request.headers.set("Authorization", `Bearer ${token}`);
}
const orgId = getGlobalOrganizationId()
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

Add missing semicolon for consistency.

Line 110 is missing a semicolon after the function call, which is inconsistent with the rest of the codebase style.

Apply this diff:

-        const orgId = getGlobalOrganizationId()
+        const orgId = getGlobalOrganizationId();
📝 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
const orgId = getGlobalOrganizationId()
const orgId = getGlobalOrganizationId();
🤖 Prompt for AI Agents
In web/src/lib/api-client.ts around line 110, the statement "const orgId =
getGlobalOrganizationId()" is missing a trailing semicolon; add the semicolon to
make it "const orgId = getGlobalOrganizationId();" to match project style and
run the formatter/linter to ensure consistent spacing and line ending.

if (orgId && !request.headers.get("x-organization-id")) {
request.headers.set("x-organization-id", orgId);
}
},
],
},
});

// Project Types
export interface ProjectInput {
name: string;
Expand Down
6 changes: 3 additions & 3 deletions web/src/lib/queries/chat/get-messages.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { apiClient } from "@/lib/api-client";
import { nextApiClient } from "@/lib/api-client";
import { UIMessage } from "ai";
import { createInfiniteQuery } from "react-query-kit";

Expand Down Expand Up @@ -233,8 +233,8 @@ async function fetchMessages(
page: context.pageParam.toString(),
});

const response = await apiClient.get(
`v1/api/chat/${chatId}/messages?${searchParams}`
const response = await nextApiClient.get(
`api/chat/${chatId}/messages?${searchParams}`
);

const messagesResponse: MessagesResponse = await response.json();
Expand Down
57 changes: 57 additions & 0 deletions web/src/lib/s3/signer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// lib/s3-signer.ts
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";


const s3 = new S3Client({
region: process.env.S3_REGION || "us-east-1",
endpoint: process.env.S3_ENDPOINT,
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY!,
secretAccessKey: process.env.S3_SECRET_KEY!,
},
forcePathStyle: true, // for MinIO
});
Comment on lines +6 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 | 🔴 Critical

Validate required environment variables before use.

The non-null assertions on process.env.S3_ACCESS_KEY! and process.env.S3_SECRET_KEY! will pass undefined to the S3Client if these environment variables are not set, leading to authentication failures at runtime. Additionally, S3_ENDPOINT is required when using MinIO but is currently optional.

Apply this diff to add validation:

+// Validate required environment variables
+const requiredEnvVars = {
+  S3_ACCESS_KEY: process.env.S3_ACCESS_KEY,
+  S3_SECRET_KEY: process.env.S3_SECRET_KEY,
+  S3_ENDPOINT: process.env.S3_ENDPOINT,
+};
+
+for (const [key, value] of Object.entries(requiredEnvVars)) {
+  if (!value) {
+    throw new Error(`Missing required environment variable: ${key}`);
+  }
+}
+
 const s3 = new S3Client({
   region: process.env.S3_REGION || "us-east-1",
-  endpoint: process.env.S3_ENDPOINT,         
+  endpoint: process.env.S3_ENDPOINT!,
   credentials: {
     accessKeyId: process.env.S3_ACCESS_KEY!,
     secretAccessKey: process.env.S3_SECRET_KEY!,
   },
   forcePathStyle: true,                      // for MinIO
 });
📝 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
const s3 = new S3Client({
region: process.env.S3_REGION || "us-east-1",
endpoint: process.env.S3_ENDPOINT,
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY!,
secretAccessKey: process.env.S3_SECRET_KEY!,
},
forcePathStyle: true, // for MinIO
});
// Validate required environment variables
const requiredEnvVars = {
S3_ACCESS_KEY: process.env.S3_ACCESS_KEY,
S3_SECRET_KEY: process.env.S3_SECRET_KEY,
S3_ENDPOINT: process.env.S3_ENDPOINT,
};
for (const [key, value] of Object.entries(requiredEnvVars)) {
if (!value) {
throw new Error(`Missing required environment variable: ${key}`);
}
}
const s3 = new S3Client({
region: process.env.S3_REGION || "us-east-1",
endpoint: process.env.S3_ENDPOINT!,
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY!,
secretAccessKey: process.env.S3_SECRET_KEY!,
},
forcePathStyle: true, // for MinIO
});
🤖 Prompt for AI Agents
In web/src/lib/s3/signer.ts around lines 6 to 14, the code uses non-null
assertions for S3 credentials and treats S3_ENDPOINT as optional which can yield
undefined values at runtime; validate process.env.S3_ACCESS_KEY and
process.env.S3_SECRET_KEY at module init and throw a clear error if missing,
require process.env.S3_ENDPOINT when using MinIO (forcePathStyle true) and throw
if missing, then use the validated values (no ! assertions) to construct the
S3Client so the client always receives defined credentials and endpoint.


function normalizePath(raw: string): string {
// If it's a full URL, extract only the path
try {
const url = new URL(raw);
return url.pathname; // e.g. "/gopie/visualizations/file.json"
} catch {
// Not a URL — return as-is
return raw;
}
}

// ---- Signs a single path (e.g. `/mybucket/reports/viz.png`) ----
export async function signUrl(rawPath: string): Promise<string> {
try {
if (!rawPath) return rawPath;

// Strip host if present
const normalized = normalizePath(rawPath);

// Ensure no leading slash
const cleaned = normalized.startsWith("/") ? normalized.slice(1) : normalized;

const slashIndex = cleaned.indexOf("/");
if (slashIndex === -1) {
console.error("signUrl error: Invalid path, no bucket/key", cleaned);
return rawPath;
}

const bucket = cleaned.substring(0, slashIndex);
const key = cleaned.substring(slashIndex + 1);

const command = new GetObjectCommand({
Bucket: bucket,
Key: key,
});

return await getSignedUrl(s3, command, { expiresIn: 86400 });
} catch (err) {
console.error("signUrl error:", err);
return rawPath;
}
}
Comment on lines +28 to +57
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 | 🟠 Major

Consider throwing errors instead of silently returning the original path.

The current implementation returns the original rawPath when signing fails (lines 30, 41, 55). This silent failure pattern could mask configuration issues or signing errors in production, potentially leading to access denied errors when clients attempt to use unsigned URLs with private buckets.

Consider one of these approaches:

Option 1: Throw errors for genuine failures (recommended)

 export async function signUrl(rawPath: string): Promise<string> {
-  try {
-    if (!rawPath) return rawPath;
+  if (!rawPath) return rawPath;
 
-    // Strip host if present
-    const normalized = normalizePath(rawPath);
+  // Strip host if present
+  const normalized = normalizePath(rawPath);
 
-    // Ensure no leading slash
-    const cleaned = normalized.startsWith("/") ? normalized.slice(1) : normalized;
+  // Ensure no leading slash
+  const cleaned = normalized.startsWith("/") ? normalized.slice(1) : normalized;
 
-    const slashIndex = cleaned.indexOf("/");
-    if (slashIndex === -1) {
-      console.error("signUrl error: Invalid path, no bucket/key", cleaned);
-      return rawPath;
-    }
+  const slashIndex = cleaned.indexOf("/");
+  if (slashIndex === -1) {
+    throw new Error(`Invalid S3 path format (no bucket/key): ${cleaned}`);
+  }
 
-    const bucket = cleaned.substring(0, slashIndex);
-    const key    = cleaned.substring(slashIndex + 1);
+  const bucket = cleaned.substring(0, slashIndex);
+  const key    = cleaned.substring(slashIndex + 1);
 
+  try {
     const command = new GetObjectCommand({
       Bucket: bucket,
       Key: key,
     });
 
     return await getSignedUrl(s3, command, { expiresIn: 86400 });
   } catch (err) {
-    console.error("signUrl error:", err);
-    return rawPath;
+    console.error("Failed to sign S3 URL:", err);
+    throw new Error(`Failed to sign S3 URL for ${bucket}/${key}: ${err}`);
   }
 }

Option 2: Log warnings for debugging

If silent fallback is intentional, at least log at warning level:

   } catch (err) {
-    console.error("signUrl error:", err);
+    console.warn("Failed to sign S3 URL, returning original path:", err);
     return rawPath;
   }
📝 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
export async function signUrl(rawPath: string): Promise<string> {
try {
if (!rawPath) return rawPath;
// Strip host if present
const normalized = normalizePath(rawPath);
// Ensure no leading slash
const cleaned = normalized.startsWith("/") ? normalized.slice(1) : normalized;
const slashIndex = cleaned.indexOf("/");
if (slashIndex === -1) {
console.error("signUrl error: Invalid path, no bucket/key", cleaned);
return rawPath;
}
const bucket = cleaned.substring(0, slashIndex);
const key = cleaned.substring(slashIndex + 1);
const command = new GetObjectCommand({
Bucket: bucket,
Key: key,
});
return await getSignedUrl(s3, command, { expiresIn: 86400 });
} catch (err) {
console.error("signUrl error:", err);
return rawPath;
}
}
export async function signUrl(rawPath: string): Promise<string> {
if (!rawPath) return rawPath;
// Strip host if present
const normalized = normalizePath(rawPath);
// Ensure no leading slash
const cleaned = normalized.startsWith("/") ? normalized.slice(1) : normalized;
const slashIndex = cleaned.indexOf("/");
if (slashIndex === -1) {
throw new Error(`Invalid S3 path format (no bucket/key): ${cleaned}`);
}
const bucket = cleaned.substring(0, slashIndex);
const key = cleaned.substring(slashIndex + 1);
try {
const command = new GetObjectCommand({
Bucket: bucket,
Key: key,
});
return await getSignedUrl(s3, command, { expiresIn: 86400 });
} catch (err) {
console.error("Failed to sign S3 URL:", err);
throw new Error(`Failed to sign S3 URL for ${bucket}/${key}: ${err}`);
}
}
🤖 Prompt for AI Agents
In web/src/lib/s3/signer.ts around lines 28 to 57, replace the silent fallbacks
that return rawPath (lines ~30, ~41, ~55) with thrown errors so failures surface
to callers: validate inputs and if missing rawPath or malformed path throw a
descriptive Error including the problematic value, and when getSignedUrl or
other operations fail rethrow or throw a new Error that includes the underlying
error message; remove the console.error+return rawPath pattern so callers
receive a rejected Promise instead (alternatively, if a silent fallback is truly
required, log a warning instead of returning silently).

Loading