Skip to content

Commit ddd0b1b

Browse files
fix(ui): improve vector store and citation workflows (#6325)
## What changed - Add an uploaded-file dropdown when attaching files to a vector store, showing only unattached files. - Refresh the vector-store list after creating a store and improve attach failure handling. - Add model allowlisting via `NEXT_PUBLIC_OGX_UI_ALLOWED_MODELS`, with the first visible model selected by default. - Redirect citation links for vector-store-only files to their vector-store file details page. - Add regression and utility tests for these UI flows. ## Root cause Citation links can reference files that are present in a vector store but are not present in the global Files API. The generic file details route now falls back to locating the file across vector stores before showing an error. ## Validation - `npx jest --runInBand app/chat-playground/page.test.tsx components/files/file-detail.test.tsx components/vector-stores/available-files.test.ts components/vector-stores/model-list.test.ts components/vector-stores/vector-store-params.test.ts lib/model-filter.test.ts` - 6 suites passed, 18 tests passed. - ESLint and Prettier checks passed for the changed file-detail files. - Live browser verification completed for the citation redirect. --------- Signed-off-by: Francisco Javier Arceo <farceo@redhat.com>
1 parent 370337b commit ddd0b1b

17 files changed

Lines changed: 461 additions & 61 deletions

src/ogx_ui/README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,17 @@ bun dev
2323
```
2424

2525
Open [http://localhost:8322](http://localhost:8322) with your browser to see the result.
26+
27+
## Model filtering
28+
29+
The Chat Playground shows all available LLM models by default. To limit the
30+
model dropdown, set `NEXT_PUBLIC_OGX_UI_ALLOWED_MODELS` to a comma-separated
31+
list of model IDs:
32+
33+
```bash
34+
NEXT_PUBLIC_OGX_UI_ALLOWED_MODELS=openai/gpt-4.1-mini,anthropic/claude-sonnet-4-6
35+
```
36+
37+
When configured, only matching models are shown in the configured order, and
38+
the first matching model is selected by default. If the variable is unset, the
39+
first model in the existing sorted list is selected by default.

src/ogx_ui/app/chat-playground/page.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ describe("ChatPlaygroundPage", () => {
114114
});
115115

116116
expect(screen.getAllByRole("combobox")).toHaveLength(1);
117+
expect(screen.getByRole("combobox")).toHaveTextContent("test-model-1");
117118
});
118119

119120
test("shows settings panel", async () => {

src/ogx_ui/app/chat-playground/page.tsx

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ import {
2626
removeConversation,
2727
updateConversation,
2828
} from "@/lib/conversation-history";
29+
import { filterModels, parseModelAllowlist } from "@/lib/model-filter";
30+
31+
const configuredModelIds = parseModelAllowlist(
32+
process.env.NEXT_PUBLIC_OGX_UI_ALLOWED_MODELS
33+
);
2934

3035
type ModelWithMeta = Model & {
3136
custom_metadata?: Record<string, unknown>;
@@ -140,7 +145,17 @@ function ChatPlaygroundContent() {
140145
);
141146

142147
llmModels.sort((a, b) => a.id.localeCompare(b.id));
143-
setModels(llmModels);
148+
const visibleModels = filterModels(llmModels, configuredModelIds);
149+
setModels(visibleModels);
150+
setSelectedModel(currentModel => {
151+
if (
152+
currentModel &&
153+
visibleModels.some(model => model.id === currentModel)
154+
) {
155+
return currentModel;
156+
}
157+
return visibleModels[0]?.id ?? "";
158+
});
144159
} catch (err) {
145160
console.error("Error fetching models:", err);
146161
setModelsError("Failed to load models");
@@ -714,6 +729,13 @@ function ChatPlaygroundContent() {
714729
{modelsError && (
715730
<p className="text-destructive text-xs mt-1">{modelsError}</p>
716731
)}
732+
{!isModelsLoading && !modelsError && models.length === 0 && (
733+
<p className="text-muted-foreground text-xs mt-1">
734+
{configuredModelIds.length > 0
735+
? "No models matched the configured allowlist."
736+
: "No models available."}
737+
</p>
738+
)}
717739
</div>
718740

719741
<div>

src/ogx_ui/app/logs/vector-stores/page.tsx

Lines changed: 5 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
VectorStoreEditor,
2626
VectorStoreFormData,
2727
} from "@/components/vector-stores/vector-store-editor";
28+
import { buildVectorStoreParams } from "@/components/vector-stores/vector-store-params";
2829

2930
export default function VectorStoresPage() {
3031
const router = useRouter();
@@ -41,6 +42,7 @@ export default function VectorStoresPage() {
4142
hasMore,
4243
error,
4344
loadMore,
45+
refetch,
4446
} = usePagination<VectorStore>({
4547
limit: 20,
4648
order: "desc",
@@ -130,27 +132,12 @@ export default function VectorStoresPage() {
130132
return;
131133
}
132134

133-
const createParams: Record<string, unknown> = {
134-
name: formData.name || undefined,
135-
};
136-
137-
if (formData.provider_id) {
138-
createParams.provider_id = formData.provider_id;
139-
}
140-
if (formData.embedding_model) {
141-
createParams.embedding_model = formData.embedding_model;
142-
}
143-
if (formData.embedding_dimension) {
144-
createParams.embedding_dimension = formData.embedding_dimension;
145-
}
146-
147-
await client.vectorStores.create(createParams);
135+
await client.vectorStores.create(buildVectorStoreParams(formData));
136+
refetch();
148137

149138
// Show success state with close button
150139
setShowSuccessState(true);
151-
setModalError(
152-
"✅ Vector store created successfully! You can close this modal and refresh the page to see changes."
153-
);
140+
setModalError("✅ Vector store created successfully!");
154141
} catch (err: unknown) {
155142
console.error("Failed to create vector store:", err);
156143
const errorMessage =
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import React from "react";
2+
import { render, waitFor } from "@testing-library/react";
3+
import "@testing-library/jest-dom";
4+
import { FileDetail } from "./file-detail";
5+
6+
const mockReplace = jest.fn();
7+
const mockRouter = { replace: mockReplace };
8+
const mockClient = {
9+
files: {
10+
retrieve: jest.fn(),
11+
},
12+
vectorStores: {
13+
list: jest.fn(),
14+
files: {
15+
list: jest.fn(),
16+
},
17+
},
18+
};
19+
20+
jest.mock("@/hooks/use-auth-client", () => ({
21+
useAuthClient: () => mockClient,
22+
}));
23+
24+
jest.mock("next/navigation", () => ({
25+
useParams: () => ({ id: "file-orphaned" }),
26+
useRouter: () => mockRouter,
27+
}));
28+
29+
describe("FileDetail", () => {
30+
beforeEach(() => {
31+
jest.clearAllMocks();
32+
});
33+
34+
it("does not log a Files API error when vector-store fallback succeeds", async () => {
35+
mockClient.files.retrieve.mockRejectedValue(new Error("404"));
36+
mockClient.vectorStores.list.mockResolvedValue({
37+
data: [{ id: "vs_123" }],
38+
});
39+
mockClient.vectorStores.files.list.mockResolvedValue({
40+
data: [{ id: "file-orphaned" }],
41+
});
42+
const consoleError = jest.spyOn(console, "error").mockImplementation();
43+
44+
render(<FileDetail />);
45+
46+
await waitFor(() => {
47+
expect(mockReplace).toHaveBeenCalledWith(
48+
"/logs/vector-stores/vs_123/files/file-orphaned"
49+
);
50+
});
51+
expect(consoleError).not.toHaveBeenCalled();
52+
consoleError.mockRestore();
53+
});
54+
});

src/ogx_ui/components/files/file-detail.tsx

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,17 +57,45 @@ export function FileDetail() {
5757
const response = await client.files.retrieve(fileId);
5858
setFile(response as FileResource);
5959
} catch (err) {
60-
console.error("Failed to fetch file:", err);
61-
setError(
62-
err instanceof Error ? err : new Error("Failed to fetch file")
63-
);
60+
try {
61+
const storesResponse = await client.vectorStores.list({
62+
limit: 100,
63+
order: "desc",
64+
});
65+
const stores =
66+
(storesResponse as { data?: { id?: string }[] }).data ?? [];
67+
const matchingStore = await Promise.any(
68+
stores
69+
.filter((store): store is { id: string } => Boolean(store.id))
70+
.map(async store => {
71+
const filesResponse = await client.vectorStores.files.list(
72+
store.id
73+
);
74+
const files =
75+
(filesResponse as { data?: { id?: string }[] }).data ?? [];
76+
if (files.some(file => file.id === fileId)) {
77+
return store.id;
78+
}
79+
throw new Error("File is not in this vector store");
80+
})
81+
);
82+
router.replace(
83+
`/logs/vector-stores/${matchingStore}/files/${fileId}`
84+
);
85+
return;
86+
} catch {
87+
console.error("Failed to fetch file details:", err);
88+
setError(
89+
err instanceof Error ? err : new Error("Failed to fetch file")
90+
);
91+
}
6492
} finally {
6593
setLoading(false);
6694
}
6795
};
6896

6997
fetchFile();
70-
}, [fileId, client]);
98+
}, [fileId, client, router]);
7199

72100
// Cleanup blob URL when component unmounts or content changes
73101
useEffect(() => {
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import type { VectorStoreFile } from "ogx-client/resources/vector-stores/files";
2+
import type { File } from "ogx-client/resources/files";
3+
import { getAvailableFiles } from "./available-files";
4+
5+
const uploadedFile = (id: string): File =>
6+
({ id, filename: `${id}.txt` }) as File;
7+
8+
const attachedFile = (id: string): VectorStoreFile => ({
9+
id,
10+
created_at: 0,
11+
status: "completed",
12+
usage_bytes: 0,
13+
vector_store_id: "vs_test",
14+
chunking_strategy: { type: "auto" },
15+
});
16+
17+
describe("getAvailableFiles", () => {
18+
test("filters out files already attached to the vector store", () => {
19+
expect(
20+
getAvailableFiles(
21+
[uploadedFile("file_1"), uploadedFile("file_2")],
22+
[attachedFile("file_1")]
23+
).map(file => file.id)
24+
).toEqual(["file_2"]);
25+
});
26+
27+
test("returns no files when every uploaded file is attached", () => {
28+
expect(
29+
getAvailableFiles([uploadedFile("file_1")], [attachedFile("file_1")])
30+
).toEqual([]);
31+
});
32+
});
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import type { File } from "ogx-client/resources/files";
2+
import type { VectorStoreFile } from "ogx-client/resources/vector-stores/files";
3+
4+
export function getAvailableFiles(
5+
uploadedFiles: File[],
6+
attachedFiles: VectorStoreFile[]
7+
): File[] {
8+
const attachedFileIds = new Set(attachedFiles.map(file => file.id));
9+
return uploadedFiles.filter(file => !attachedFileIds.has(file.id));
10+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type { Model } from "ogx-client/resources/models";
2+
import { normalizeModelList } from "./model-list";
3+
4+
const embeddingModel = { id: "embedding-model" } as Model;
5+
6+
describe("normalizeModelList", () => {
7+
test("unwraps the OpenAI-style data response", () => {
8+
expect(normalizeModelList({ data: [embeddingModel] })).toEqual([
9+
embeddingModel,
10+
]);
11+
});
12+
13+
test("preserves array responses", () => {
14+
expect(normalizeModelList([embeddingModel])).toEqual([embeddingModel]);
15+
});
16+
17+
test("returns an empty list for an invalid response", () => {
18+
expect(normalizeModelList({ models: [embeddingModel] })).toEqual([]);
19+
});
20+
});
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import type { Model } from "ogx-client/resources/models";
2+
3+
export function normalizeModelList(result: unknown): Model[] {
4+
if (Array.isArray(result)) {
5+
return result as Model[];
6+
}
7+
8+
if (result && typeof result === "object" && "data" in result) {
9+
const data = (result as { data?: unknown }).data;
10+
return Array.isArray(data) ? (data as Model[]) : [];
11+
}
12+
13+
return [];
14+
}

0 commit comments

Comments
 (0)