Conversation
Co-authored-by: JonnyTran <4750391+JonnyTran@users.noreply.github.com>
Co-authored-by: JonnyTran <4750391+JonnyTran@users.noreply.github.com>
- Update `BaseSimpleTable` adapter to pass the full column configuration (including editor params) instead of just name/type. - Update `RenderTable` logic to respect custom column settings (like `editor: "list"`) by prioritizing schema config over defaults. - Add watcher to `columnsConfig` in `RenderTable` to ensure dropdown options update reactively when new files are uploaded.
- Add missing `$emit('cell-edited')` in `RenderTable`'s tabulator handler.
- Ensures parent components are notified of changes to trigger state updates like the unmapped files counter.
…irectly injecting the token
There was a problem hiding this comment.
Pull request overview
Implements backend support for routing Extralit chat requests to GitHub Copilot models via LiteLLM (including GitHub OAuth Device Flow and SSE streaming), and adds frontend import-flow enhancements for PDF-only uploads.
Changes:
- Added GitHub Device Flow auth helpers +
/auth/github/*endpoints for per-user token storage. - Added
/chatSSE streaming endpoint with optional RAG retrieval via vector search + embeddings. - Updated frontend import file-upload view model and added Jest tests; added
litellmdependency.
Reviewed changes
Copilot reviewed 10 out of 11 changed files in this pull request and generated 16 comments.
Show a summary per file
| File | Description |
|---|---|
| extralit-server/src/extralit_server/utils/litellm_context.py | Adds a context manager intended to isolate LiteLLM’s config/temp behavior per user. |
| extralit-server/src/extralit_server/utils/auth_helpers.py | Implements GitHub Device Flow initiation/polling and local token persistence. |
| extralit-server/src/extralit_server/api/routes.py | Registers the new GitHub auth + chat routers in API v1. |
| extralit-server/src/extralit_server/api/handlers/v1/github_auth.py | Adds /auth/github/status, /login, /poll, /logout endpoints. |
| extralit-server/src/extralit_server/api/handlers/v1/chat.py | Adds /chat streaming endpoint and optional RAG context retrieval. |
| extralit-server/pyproject.toml | Adds LiteLLM dependency and minor formatting cleanup. |
| extralit-frontend/components/features/import/file-upload/useImportFileUploadViewModel.ts | Adds editable-table support for PDF-only uploads and auto-sync to bibData. |
| extralit-frontend/components/features/import/file-upload/useImportFileUploadViewModel.spec.ts | Adds unit tests for the updated import file-upload view model. |
| extralit-frontend/components/features/import/file-upload/ImportFileUpload.vue | Registers BaseSimpleTable for the new editable-table UI. |
| copilot_changes.txt | Adds a file containing pasted diffs (appears to be an artifact, not app code). |
| expect(filesCol?.editorParams?.multiselect).toBe(true); | ||
| expect(filesCol?.editorParams?.values).toHaveLength(2); |
There was a problem hiding this comment.
This test treats editorParams as an object (editorParams.multiselect/values), but in the view model it’s a function returning the params at edit-time. As written, the assertions will always fail. Update the test to call editorParams with a mocked cell and assert on the returned object instead.
| expect(filesCol?.editorParams?.multiselect).toBe(true); | |
| expect(filesCol?.editorParams?.values).toHaveLength(2); | |
| const mockCell = {} as any; | |
| const editorParams = filesCol?.editorParams | |
| ? filesCol.editorParams(mockCell) | |
| : undefined; | |
| expect(editorParams).toBeDefined(); | |
| expect(editorParams?.multiselect).toBe(true); | |
| expect(editorParams?.values).toHaveLength(2); |
| def _get_config_dir(self) -> Path: | ||
| """Get the user-specific config directory for token storage.""" | ||
| config_path = Path(settings.home_path) / "data" / "users" / self.username / "config" | ||
| config_path.mkdir(parents=True, exist_ok=True) | ||
| return config_path |
There was a problem hiding this comment.
Building filesystem paths directly from username allows path traversal (e.g., usernames containing ../ or path separators) and could write tokens outside the intended per-user directory. Use a stable, sanitized identifier (e.g., user UUID) for the directory name, or strictly validate/escape usernames before using them in Path joins.
| diff --git a/extralit-frontend/components/base/base-render-table/RenderTable.vue b/extralit-frontend/components/base/base-render-table/RenderTable.vue | ||
| index ff6874489..ab73e5afb 100644 | ||
| --- a/extralit-frontend/components/base/base-render-table/RenderTable.vue | ||
| +++ b/extralit-frontend/components/base/base-render-table/RenderTable.vue | ||
| @@ -150,6 +150,7 @@ export default { | ||
| // } | ||
| // }, | ||
| // }, | ||
| + | ||
| validation: { | ||
| handler(newValidation, oldValidation) { | ||
| if (this.isLoaded) { | ||
| @@ -196,7 +197,7 @@ export default { | ||
| var configs = this.tableJSON.schema.fields.map((column: DataFrameField) => { | ||
| const commonConfig = this.generateColumnConfig(column.name); | ||
| const editableConfig = this.generateColumnEditableConfig(column.name); | ||
| - return { ...commonConfig, ...editableConfig }; | ||
| + return { ...commonConfig, ...editableConfig, ...column }; | ||
| }); | ||
|
|
||
| if (!this.editable) { | ||
| @@ -913,6 +914,7 @@ export default { | ||
|
|
||
| this.tabulator.on("cellEdited", (cell: CellComponent) => { | ||
| this.updateTableJsonData(); | ||
| + this.$emit("cell-edited", cell); | ||
| // const rowPos: number | boolean = cell.getRow().getPosition(); | ||
| // if (typeof rowPos != 'number' || rowPos < 0 || rowPos > this.tableJSON.data.length) return; | ||
| // this.$set(this.tableJSON.data[rowPos-1], cell.getColumn().getField(), cell.getValue()); | ||
| diff --git a/extralit-frontend/components/base/base-simple-table/BaseSimpleTable.vue b/extralit-frontend/components/base/base-simple-table/BaseSimpleTable.vue | ||
| index 15a62bbc4..3a7e5435a 100644 | ||
| --- a/extralit-frontend/components/base/base-simple-table/BaseSimpleTable.vue | ||
| +++ b/extralit-frontend/components/base/base-simple-table/BaseSimpleTable.vue | ||
| @@ -6,6 +6,7 @@ | ||
| :editable="editable" | ||
| :hasValidValues="hasValidValues" | ||
| :questions="questions" | ||
| + :validation="validation || validators" | ||
| @table-built="$emit('table-built')" | ||
| @row-click="(e, row) => $emit('row-click', e, row)" | ||
| @cell-edited="(cell) => $emit('cell-edited', cell)" | ||
| @@ -71,9 +72,11 @@ export default { | ||
| computed: { | ||
| // Convert simple data/columns to TableData format for RenderTable | ||
| computedTableJSON(): TableData { | ||
| + // FIX 1: Use "...col" to preserve editor config (dropdowns), validators, and freezing | ||
| const fields = this.columns.map((col: any) => ({ | ||
| name: col.field, | ||
| type: col.type || "string", | ||
| + ...col // <--- THIS IS THE MAGIC FIX | ||
| })); | ||
|
|
||
| const schema = new DataFrameSchema( | ||
| @@ -89,7 +92,14 @@ export default { | ||
| null | ||
| ); | ||
|
|
||
| - if (this.validation) { | ||
| + // FIX 2: Handle both 'validation' and 'validators' props |
There was a problem hiding this comment.
copilot_changes.txt appears to be a pasted diff of other files rather than source code used by the app. Keeping it in the repo will confuse future maintenance and inflate the codebase; it should likely be removed or moved to PR notes/attachments instead.
| diff --git a/extralit-frontend/components/base/base-render-table/RenderTable.vue b/extralit-frontend/components/base/base-render-table/RenderTable.vue | |
| index ff6874489..ab73e5afb 100644 | |
| --- a/extralit-frontend/components/base/base-render-table/RenderTable.vue | |
| +++ b/extralit-frontend/components/base/base-render-table/RenderTable.vue | |
| @@ -150,6 +150,7 @@ export default { | |
| // } | |
| // }, | |
| // }, | |
| + | |
| validation: { | |
| handler(newValidation, oldValidation) { | |
| if (this.isLoaded) { | |
| @@ -196,7 +197,7 @@ export default { | |
| var configs = this.tableJSON.schema.fields.map((column: DataFrameField) => { | |
| const commonConfig = this.generateColumnConfig(column.name); | |
| const editableConfig = this.generateColumnEditableConfig(column.name); | |
| - return { ...commonConfig, ...editableConfig }; | |
| + return { ...commonConfig, ...editableConfig, ...column }; | |
| }); | |
| if (!this.editable) { | |
| @@ -913,6 +914,7 @@ export default { | |
| this.tabulator.on("cellEdited", (cell: CellComponent) => { | |
| this.updateTableJsonData(); | |
| + this.$emit("cell-edited", cell); | |
| // const rowPos: number | boolean = cell.getRow().getPosition(); | |
| // if (typeof rowPos != 'number' || rowPos < 0 || rowPos > this.tableJSON.data.length) return; | |
| // this.$set(this.tableJSON.data[rowPos-1], cell.getColumn().getField(), cell.getValue()); | |
| diff --git a/extralit-frontend/components/base/base-simple-table/BaseSimpleTable.vue b/extralit-frontend/components/base/base-simple-table/BaseSimpleTable.vue | |
| index 15a62bbc4..3a7e5435a 100644 | |
| --- a/extralit-frontend/components/base/base-simple-table/BaseSimpleTable.vue | |
| +++ b/extralit-frontend/components/base/base-simple-table/BaseSimpleTable.vue | |
| @@ -6,6 +6,7 @@ | |
| :editable="editable" | |
| :hasValidValues="hasValidValues" | |
| :questions="questions" | |
| + :validation="validation || validators" | |
| @table-built="$emit('table-built')" | |
| @row-click="(e, row) => $emit('row-click', e, row)" | |
| @cell-edited="(cell) => $emit('cell-edited', cell)" | |
| @@ -71,9 +72,11 @@ export default { | |
| computed: { | |
| // Convert simple data/columns to TableData format for RenderTable | |
| computedTableJSON(): TableData { | |
| + // FIX 1: Use "...col" to preserve editor config (dropdowns), validators, and freezing | |
| const fields = this.columns.map((col: any) => ({ | |
| name: col.field, | |
| type: col.type || "string", | |
| + ...col // <--- THIS IS THE MAGIC FIX | |
| })); | |
| const schema = new DataFrameSchema( | |
| @@ -89,7 +92,14 @@ export default { | |
| null | |
| ); | |
| - if (this.validation) { | |
| + // FIX 2: Handle both 'validation' and 'validators' props | |
| # This file previously contained a pasted git diff of frontend changes. | |
| # It has been cleared because diffs belong in version control history and | |
| # keeping them here confuses static analysis tools and future maintainers. | |
| # | |
| # If you need to document code review notes or Copilot suggestions, | |
| # please use pull request descriptions or separate documentation files. |
| diff --git a/extralit-frontend/components/base/base-render-table/RenderTable.vue b/extralit-frontend/components/base/base-render-table/RenderTable.vue | ||
| index ff6874489..ab73e5afb 100644 | ||
| --- a/extralit-frontend/components/base/base-render-table/RenderTable.vue | ||
| +++ b/extralit-frontend/components/base/base-render-table/RenderTable.vue | ||
| @@ -150,6 +150,7 @@ export default { | ||
| // } | ||
| // }, | ||
| // }, | ||
| + | ||
| validation: { |
There was a problem hiding this comment.
The PR description is focused on GitHub Copilot/LiteLLM /chat streaming, but this change set also includes substantial, unrelated frontend import-table work (and an extra copilot_changes.txt diff artifact). Please either update the PR description/scope to cover these additions or split them into a separate PR to keep review and rollback risk manageable.
| # Poll with a short timeout (single attempt) | ||
| token_data = await github_auth.poll_for_token( | ||
| device_code=request.device_code, | ||
| interval=5, | ||
| timeout=10, # Just one quick check |
There was a problem hiding this comment.
/auth/github/poll calls poll_for_token() which contains an internal sleep/retry loop; with timeout=10 and interval=5 this endpoint can block a request for up to ~10s each poll. It’s usually better for the HTTP endpoint to do a single token check per request (no sleeps) and let the client handle polling cadence, to avoid tying up server workers.
| # Poll with a short timeout (single attempt) | |
| token_data = await github_auth.poll_for_token( | |
| device_code=request.device_code, | |
| interval=5, | |
| timeout=10, # Just one quick check | |
| # Perform a single non-blocking check for the token (no internal sleep/retry) | |
| token_data = await github_auth.poll_for_token( | |
| device_code=request.device_code, | |
| interval=0, | |
| timeout=0, |
| class DeviceFlowResponse(BaseModel): | ||
| """Response model for device flow initiation.""" | ||
|
|
||
| device_code: str # <--- ADDED THIS FIELD |
There was a problem hiding this comment.
The inline “<--- ADDED …” markers look like temporary review notes and should be removed before merging to keep the handler code clean.
| device_code: str # <--- ADDED THIS FIELD | |
| device_code: str |
| # Fetch the dataset | ||
| dataset = await Dataset.get_by(db, id=dataset_id) | ||
| if not dataset: | ||
| _LOGGER.warning(f"Dataset {dataset_id} not found") | ||
| return None | ||
|
|
||
| # Get vector settings for this dataset (use first available or "default") | ||
| vector_settings = await VectorSettings.get_by(db, dataset_id=dataset.id) | ||
| if not vector_settings: |
There was a problem hiding this comment.
RAG retrieval loads the dataset directly via Dataset.get_by but never authorizes that current_user is allowed to read/search this dataset. This can leak embeddings/context from datasets the caller shouldn’t access. Load the dataset with the standard pattern and enforce DatasetPolicy.get(dataset) (or use a context method that already performs authorization) before running similarity search / fetching records.
| @router.post("/chat") | ||
| async def chat( | ||
| request: ChatRequest, | ||
| db: Annotated[AsyncSession, Depends(get_async_db)], | ||
| search_engine: Annotated[SearchEngine, Depends(get_search_engine)], | ||
| current_user: User = Depends(auth.get_current_user), | ||
| ) -> StreamingResponse: | ||
| """ | ||
| Chat endpoint using LiteLLM with GitHub Copilot and optional RAG. |
There was a problem hiding this comment.
This PR introduces new externally-facing behavior (Copilot device flow + /chat streaming + optional RAG) but there are no backend tests added alongside it. The server repo has extensive pytest coverage; adding tests (e.g., for auth status/login/poll flows with respx, and SSE streaming framing) would help prevent regressions.
| const pdfFileOptions = allPdfFileNames.value.map(name => ({ | ||
| label: name, | ||
| value: name, | ||
| })); | ||
|
|
There was a problem hiding this comment.
pdfFileOptions is computed but never used, which adds noise and can confuse future maintenance. Either remove it or use it as the source for the files column’s editor options.
| const pdfFileOptions = allPdfFileNames.value.map(name => ({ | |
| label: name, | |
| value: name, | |
| })); |
| formatter: (cell: any) => { | ||
| const value = cell.getValue(); | ||
| if (!value || (Array.isArray(value) && value.length === 0)) { | ||
| return '<span style="color: var(--fg-tertiary);">No files</span>'; | ||
| } | ||
| const files = Array.isArray(value) ? value : [value]; | ||
| const count = files.length; | ||
| return `<span title="${files.join(', ')}">${count} file${count !== 1 ? 's' : ''}</span>`; | ||
| }, |
There was a problem hiding this comment.
The formatter builds an HTML string with unescaped file names inside an attribute (title="..."). Since file names come from user uploads, this can lead to HTML/attribute injection (XSS). Escape/encode file names before interpolating, or return a DOM element / use Tabulator’s safe text rendering APIs.
[Feature] Integrate LiteLLM and GitHub Copilot for streaming
/chatDescription
This PR implements the backend architecture to route chat requests to GitHub Copilot's models (
gpt-4o,text-embedding-3-small) using thelitellmSDK, supporting SSE streaming and isolated user authentication.Key Additions:
/chatEndpoint: Addedapi/handlers/v1/chat.pywhich accepts chat histories, handles optional RAG dataset context, and returns a Server-Sent Events (SSE) stream.api_keyexplicitly via the function call rather than mutatingos.environ, preventing cross-talk race conditions between concurrent users.GitHubDeviceFlowAuthto generate, poll, and store GitHub tokens securely in isolated user XDG directories (~/.extralit/data/users/{username}/...).LiteLLMContextto ensure any temporary files generated by the SDK are kept strictly within the authenticated user's directory.Related Tickets & Documents
Steps to QA
/auth/github/loginand/pollendpoints to generate a localgithub_token.json.POST /api/v1/chatendpoint with{"model": "copilot", "messages": [...], "stream": true}.data: ...).