From 925e8f2746970839f220641728f6614ec48c7921 Mon Sep 17 00:00:00 2001 From: Oliver Bryan Date: Sat, 31 Jan 2026 10:13:18 +0000 Subject: [PATCH 1/7] opencode chat backend --- packages/backend/export.json | 452 ++++++++++++++++++ packages/backend/src/index.ts | 2 + packages/backend/src/routes/ai/chat.ts | 26 + .../backend/src/routes/ai/context-builders.ts | 98 ++++ packages/backend/src/routes/ai/opencode.ts | 65 +++ .../backend/src/routes/ai/system-prompt.xml | 124 +++++ packages/backend/src/routes/index.ts | 3 + packages/shared/src/api-schemas.ts | 6 + packages/shared/src/index.ts | 1 + 9 files changed, 777 insertions(+) create mode 100644 packages/backend/export.json create mode 100644 packages/backend/src/routes/ai/chat.ts create mode 100644 packages/backend/src/routes/ai/context-builders.ts create mode 100644 packages/backend/src/routes/ai/opencode.ts create mode 100644 packages/backend/src/routes/ai/system-prompt.xml diff --git a/packages/backend/export.json b/packages/backend/export.json new file mode 100644 index 0000000..728fa9c --- /dev/null +++ b/packages/backend/export.json @@ -0,0 +1,452 @@ +{ + "organisation": { + "id": 5, + "name": "OB", + "description": "For personal use", + "slug": "ob", + "iconURL": "https://images.sprintpm.org/org-icons/cda1e49c-486a-4a40-ace6-d63eaba79cd9.png", + "statuses": { + "TO DO": "#fafafa", + "IN PROGRESS": "#f97316", + "REVIEW": "#8952bc", + "DONE": "#22c55e", + "REJECTED": "#ef4444", + "ARCHIVED": "#a1a1a1", + "MERGED": "#a1a1a1" + }, + "issueTypes": { + "Task": { + "icon": "checkBox", + "color": "#e4bd47" + }, + "Bug": { + "icon": "bug", + "color": "#ef4444" + }, + "Ideation": { + "icon": "edit", + "color": "#c955c5" + }, + "Quality of Life": { + "icon": "moon", + "color": "#656ad3" + }, + "Performance": { + "icon": "loader", + "color": "#5ed36c" + } + }, + "features": { + "userAvatars": true, + "issueTypes": true, + "issueStatus": true, + "issueDescriptions": true, + "issueTimeTracking": true, + "issueAssignees": true, + "issueAssigneesShownInTable": true, + "issueCreator": true, + "issueComments": true, + "sprints": true + }, + "createdAt": "2026-01-29T19:24:07.015Z", + "updatedAt": "2026-01-29T19:24:07.015Z" + }, + "members": [ + { + "id": 9, + "organisationId": 5, + "userId": 9, + "role": "owner", + "createdAt": "2026-01-29T19:24:07.015Z" + } + ], + "projects": [ + { + "id": 9, + "key": "SPNT", + "name": "SPRINT", + "organisationId": 5, + "creatorId": 9 + } + ], + "sprints": [ + { + "id": 17, + "projectId": 9, + "name": "Development 1", + "color": "#63d379", + "startDate": "2026-01-29T00:00:00.000Z", + "endDate": "2026-02-12T23:59:00.000Z", + "createdAt": "2026-01-29T20:43:29.814Z" + } + ], + "issues": [ + { + "id": 80, + "projectId": 9, + "number": 12, + "type": "Task", + "status": "IN PROGRESS", + "title": "Assignee notes", + "description": "Users should be able to add assignee notes to represent an assignee's role in an issue.", + "creatorId": 9, + "sprintId": 17 + }, + { + "id": 74, + "projectId": 9, + "number": 6, + "type": "Ideation", + "status": "TO DO", + "title": "Subscriptions should be organisation based", + "description": "They shouldn't be user based. A subscription should be for an organisation. Any user should be able to have unlimited organisations, but they are all limited, and charged for separately.", + "creatorId": 9, + "sprintId": 17 + }, + { + "id": 73, + "projectId": 9, + "number": 5, + "type": "Ideation", + "status": "TO DO", + "title": "Rethink \"seats\" payment idea", + "description": "Maybe user pays for different tiers? \nSpaces:\n- Free tier: 3\n- Small Team: 10\n- Moderate Team: 50\n- Big Team: Contact us for a price", + "creatorId": 9, + "sprintId": 17 + }, + { + "id": 70, + "projectId": 9, + "number": 2, + "type": "Quality of Life", + "status": "TO DO", + "title": "Streamline issue/create route", + "description": "Currently each field is parsed individually (adding a new field requires much more work than it should)", + "creatorId": 9, + "sprintId": 17 + }, + { + "id": 71, + "projectId": 9, + "number": 3, + "type": "Task", + "status": "TO DO", + "title": "Automatically create org + project for new users", + "description": "\"ob's organisation\" and \"ob's project\" should be created upon account creation.", + "creatorId": 9, + "sprintId": 17 + }, + { + "id": 86, + "projectId": 9, + "number": 18, + "type": "Task", + "status": "TO DO", + "title": "Share filters button", + "description": "Copies a url with organisation, project and all issue filters for reuse or use with team members.", + "creatorId": 9, + "sprintId": null + }, + { + "id": 79, + "projectId": 9, + "number": 11, + "type": "Bug", + "status": "DONE", + "title": "Filters should persist across refresh", + "description": "Use localStorage for this - even switching to the timeline tab and back resets filters, very annoying.", + "creatorId": 9, + "sprintId": null + }, + { + "id": 76, + "projectId": 9, + "number": 8, + "type": "Task", + "status": "TO DO", + "title": "Issue attachments", + "description": "Users should be able to attach files and images to descriptions and comments. (restrict to images at first - resize with sharp)", + "creatorId": 9, + "sprintId": 17 + }, + { + "id": 75, + "projectId": 9, + "number": 7, + "type": "Task", + "status": "DONE", + "title": "Export organisation contents as JSON", + "description": "Organisation owner/admins should be able to export all of the data in an organisation as a JSON file. This can then be used to \"Create [an org] from JSON\"", + "creatorId": 9, + "sprintId": 17 + }, + { + "id": 78, + "projectId": 9, + "number": 10, + "type": "Task", + "status": "TO DO", + "title": "Standalone time tracking", + "description": "User should be able to create time tracking entries that are not related to an issue. For example: ideation, sprint planning, meetings.", + "creatorId": 9, + "sprintId": 17 + }, + { + "id": 72, + "projectId": 9, + "number": 4, + "type": "Bug", + "status": "MERGED", + "title": "Org avatar scaling weirdly", + "description": "In organisations.tsx info tab, the org avatar is shrunk horizontally to fit the text. height is correct though.\n\nThis only occurs when there is no organisation icon.", + "creatorId": 9, + "sprintId": 17 + }, + { + "id": 69, + "projectId": 9, + "number": 1, + "type": "Bug", + "status": "MERGED", + "title": "Unable to create an issue with type", + "description": "It chooses the first type available instead of the one chosen by the user", + "creatorId": 9, + "sprintId": 17 + }, + { + "id": 81, + "projectId": 9, + "number": 13, + "type": "Task", + "status": "TO DO", + "title": "User defined colour scheme", + "description": "On the backburner for now", + "creatorId": 9, + "sprintId": null + }, + { + "id": 83, + "projectId": 9, + "number": 15, + "type": "Task", + "status": "TO DO", + "title": "Post-sub email", + "description": "Thank you for subscribing", + "creatorId": 9, + "sprintId": null + }, + { + "id": 84, + "projectId": 9, + "number": 16, + "type": "Task", + "status": "TO DO", + "title": "Trial end warning email", + "description": "Your trial is coming to an end. Manage your subscription to renew", + "creatorId": 9, + "sprintId": null + }, + { + "id": 82, + "projectId": 9, + "number": 14, + "type": "Task", + "status": "TO DO", + "title": "Welcome email", + "description": "Welcome to sprint, this is what you can do: ...", + "creatorId": 9, + "sprintId": null + }, + { + "id": 85, + "projectId": 9, + "number": 17, + "type": "Task", + "status": "TO DO", + "title": "Trial system", + "description": "14 day trial of Team plan, any elevated features must be locked away again after trial ends.\n\nIf the organisation has more members than free plan allows, the members who were added after will be barred from accessing the organisation. Additional projects, issues and sprints will be locked away.", + "creatorId": 9, + "sprintId": null + }, + { + "id": 77, + "projectId": 9, + "number": 9, + "type": "Task", + "status": "DONE", + "title": "Add \"assign to me by default\"", + "description": "", + "creatorId": 9, + "sprintId": 17 + } + ], + "issueAssignees": [ + { + "id": 73, + "issueId": 69, + "userId": 9, + "assignedAt": "2026-01-29T20:33:42.827Z" + }, + { + "id": 74, + "issueId": 70, + "userId": 9, + "assignedAt": "2026-01-29T20:44:51.258Z" + }, + { + "id": 75, + "issueId": 71, + "userId": 9, + "assignedAt": "2026-01-29T20:46:34.959Z" + }, + { + "id": 76, + "issueId": 72, + "userId": 9, + "assignedAt": "2026-01-29T20:49:12.879Z" + }, + { + "id": 77, + "issueId": 73, + "userId": 9, + "assignedAt": "2026-01-29T21:21:14.628Z" + }, + { + "id": 78, + "issueId": 77, + "userId": 9, + "assignedAt": "2026-01-29T21:39:58.232Z" + }, + { + "id": 79, + "issueId": 75, + "userId": 9, + "assignedAt": "2026-01-29T23:09:09.537Z" + }, + { + "id": 80, + "issueId": 79, + "userId": 9, + "assignedAt": "2026-01-29T23:09:12.591Z" + }, + { + "id": 81, + "issueId": 80, + "userId": 9, + "assignedAt": "2026-01-29T23:09:15.541Z" + }, + { + "id": 82, + "issueId": 86, + "userId": 9, + "assignedAt": "2026-01-29T23:19:57.419Z" + } + ], + "issueComments": [ + { + "id": 70, + "issueId": 69, + "userId": 9, + "body": "The backend wasn't reading the type field. We need to streamline that route.", + "createdAt": "2026-01-29T20:44:16.540Z", + "updatedAt": "2026-01-29T20:44:16.540Z" + }, + { + "id": 71, + "issueId": 69, + "userId": 9, + "body": "Created https://sprintpm.org/issues?o=org&p=spnt&i=2&modal=true for the previously mentioned issue", + "createdAt": "2026-01-29T20:45:23.285Z", + "updatedAt": "2026-01-29T20:45:23.285Z" + }, + { + "id": 72, + "issueId": 69, + "userId": 9, + "body": "SPNT-002", + "createdAt": "2026-01-29T20:45:38.161Z", + "updatedAt": "2026-01-29T20:45:38.161Z" + }, + { + "id": 73, + "issueId": 80, + "userId": 9, + "body": "ugh", + "createdAt": "2026-01-30T00:34:45.745Z", + "updatedAt": "2026-01-30T00:34:45.745Z" + } + ], + "timedSessions": [ + { + "id": 1, + "userId": 9, + "issueId": 69, + "timestamps": [ + "2026-01-29T20:37:38.485Z", + "2026-01-29T20:42:58.198Z" + ], + "endedAt": "2026-01-29T20:42:59.566Z", + "createdAt": "2026-01-29T20:37:38.486Z" + }, + { + "id": 2, + "userId": 9, + "issueId": 72, + "timestamps": [ + "2026-01-29T20:49:45.879Z", + "2026-01-29T21:16:34.178Z" + ], + "endedAt": "2026-01-29T21:16:34.178Z", + "createdAt": "2026-01-29T20:49:45.879Z" + }, + { + "id": 3, + "userId": 9, + "issueId": 77, + "timestamps": [ + "2026-01-29T21:40:01.096Z", + "2026-01-29T23:09:19.693Z" + ], + "endedAt": "2026-01-29T23:09:19.693Z", + "createdAt": "2026-01-29T21:40:01.096Z" + }, + { + "id": 6, + "userId": 9, + "issueId": 80, + "timestamps": [ + "2026-01-29T23:09:16.096Z", + "2026-01-30T00:34:30.575Z" + ], + "endedAt": "2026-01-30T00:34:30.575Z", + "createdAt": "2026-01-29T23:09:16.096Z" + }, + { + "id": 5, + "userId": 9, + "issueId": 79, + "timestamps": [ + "2026-01-29T23:09:13.291Z", + "2026-01-30T00:34:30.923Z" + ], + "endedAt": "2026-01-30T00:34:30.923Z", + "createdAt": "2026-01-29T23:09:13.291Z" + }, + { + "id": 4, + "userId": 9, + "issueId": 75, + "timestamps": [ + "2026-01-29T23:09:10.375Z", + "2026-01-30T00:34:31.391Z" + ], + "endedAt": "2026-01-30T00:34:31.391Z", + "createdAt": "2026-01-29T23:09:10.376Z" + } + ], + "_metadata": { + "exportedAt": "2026-01-30T00:42:40.425Z", + "exportedBy": 9, + "version": "1.0" + } +} \ No newline at end of file diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 755395e..f2ef600 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -36,6 +36,8 @@ const main = async () => { "/": withGlobal(() => new Response(`title: tnirps\ndev-mode: ${DEV}\nport: ${PORT}`)), "/health": withGlobal(() => new Response("OK")), + "/ai/chat": withGlobalAuthed(withAuth(routes.aiChat)), + // routes that modify state require withCSRF middleware "/auth/register": withGlobal(routes.authRegister), "/auth/login": withGlobal(routes.authLogin), diff --git a/packages/backend/src/routes/ai/chat.ts b/packages/backend/src/routes/ai/chat.ts new file mode 100644 index 0000000..be9b2e3 --- /dev/null +++ b/packages/backend/src/routes/ai/chat.ts @@ -0,0 +1,26 @@ +import { ChatRequestSchema } from "@sprint/shared"; +import type { AuthedRequest } from "../../auth/middleware"; +import { getUserById } from "../../db/queries"; +import { errorResponse, parseQueryParams } from "../../validation"; +import { buildContext, SYSTEM_PROMPT } from "./context-builders"; +import { callAI } from "./opencode"; + +export default async function aiChat(req: AuthedRequest) { + const url = new URL(req.url); + const parsed = parseQueryParams(url, ChatRequestSchema); + if ("error" in parsed) return parsed.error; + + const { orgId, projectId, message } = parsed.data; + + const user = await getUserById(req.userId); + if (!user) { + return errorResponse("user not found", "USER_NOT_FOUND", 404); + } + + const context = await buildContext(orgId, projectId, user); + + const fullPrompt = `${SYSTEM_PROMPT}\n\n${context}\n\n${message}`; + const response = await callAI(fullPrompt); + + return Response.json(response); +} diff --git a/packages/backend/src/routes/ai/context-builders.ts b/packages/backend/src/routes/ai/context-builders.ts new file mode 100644 index 0000000..9001b1d --- /dev/null +++ b/packages/backend/src/routes/ai/context-builders.ts @@ -0,0 +1,98 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { UserRecord } from "@sprint/shared"; +import { + getIssuesWithUsersByProject, + getOrganisationById, + getProjectByID, + getProjectsByOrganisationId, + getSprintsByProject, +} from "../../db/queries"; + +export const SYSTEM_PROMPT = fs.readFileSync(path.join(import.meta.dir, "system-prompt.xml"), "utf-8"); + +export const buildContext = async (orgId: number, projectId: number, user: UserRecord) => { + // fetch organisation, projects, sprints, issues, and issue assignees + // db queries + const organisation = await getOrganisationById(orgId); + if (!organisation) { + return ""; + } + const projects = await getProjectsByOrganisationId(orgId); + const project = await getProjectByID(projectId); + if (!project) { + return ""; + } + const issues = await getIssuesWithUsersByProject(projectId); + const sprints = await getSprintsByProject(projectId); + + const assignedIssues = issues.filter((i) => i.Assignees.some((a) => a.id === user.id)); + + const byStatus = (status: string) => assignedIssues.filter((i) => i.Issue.status === status); + + return ` + + + + + +${Object.entries(organisation.statuses) + .map(([name, color]) => ` `) + .join("\n")} + + + + +${projects.map((p) => ` `).join("\n")} + + + +${sprints.map((s) => ` `).join("\n")} + + + +${assignedIssues.map((i) => ` `).join("\n")} + + + + +${byStatus("TO DO") + .map( + (i) => + ` `, + ) + .join("\n")} + + +${byStatus("IN PROGRESS") + .map( + (i) => + ` `, + ) + .join("\n")} + + +${byStatus("DONE") + .map( + (i) => + ` `, + ) + .join("\n")} + + + + +${assignedIssues + .map( + (i) => ` + ${i.Issue.title.replace(/"/g, """)} + ${(i.Issue.description || "").replace(/"/g, """)} + ${i.Issue.status} + ${i.Issue.type} + ${sprints.find((s) => s.id === i.Issue.sprintId)?.name || "None"} + `, + ) + .join("\n")} + +`; +}; diff --git a/packages/backend/src/routes/ai/opencode.ts b/packages/backend/src/routes/ai/opencode.ts new file mode 100644 index 0000000..cb4b8fe --- /dev/null +++ b/packages/backend/src/routes/ai/opencode.ts @@ -0,0 +1,65 @@ +export type AIResponse = { + text: string; + highlighted_issues: number[]; + suggested_actions: string[] | null; + raw: string; +}; + +export const callAI = async (prompt: string): Promise => { + const result = Bun.spawn(["opencode", "run", prompt, "--model", "opencode/kimi-k2.5-free"], { + stdout: "pipe", + stderr: "pipe", + }); + + // Collect all output + let rawOutput = ""; + for await (const chunk of result.stdout) { + rawOutput += new TextDecoder().decode(chunk); + } + + let stderrOutput = ""; + for await (const chunk of result.stderr) { + stderrOutput += new TextDecoder().decode(chunk); + } + + await result.exited; + + try { + const jsonMatch = rawOutput.match(/\{[\s\S]*\}/); + const jsonStr = jsonMatch ? jsonMatch[0] : rawOutput; + + const response = JSON.parse(jsonStr); + + if (!response.text || !Array.isArray(response.highlighted_issues)) { + throw new Error("Invalid response structure"); + } + + const output = { + text: response.text, + highlighted_issues: response.highlighted_issues, + suggested_actions: response.suggested_actions || [], + raw: rawOutput, + }; + + return output; + } catch (e) { + console.log( + JSON.stringify( + { + error: "Failed to parse AI response as JSON", + parse_error: e instanceof Error ? e.message : String(e), + raw: rawOutput, + stderr: stderrOutput, + }, + null, + 2, + ), + ); + return { + text: "Sorry, an error occurred while processing the AI response.", + highlighted_issues: [], + suggested_actions: [], + raw: rawOutput, + }; + } +}; diff --git a/packages/backend/src/routes/ai/system-prompt.xml b/packages/backend/src/routes/ai/system-prompt.xml new file mode 100644 index 0000000..7165ddc --- /dev/null +++ b/packages/backend/src/routes/ai/system-prompt.xml @@ -0,0 +1,124 @@ + + + + Sprinter + AI assistant in a project management tool + + + + + never reference + organisation name + URL-friendly identifier + maps status names to colors + maps types to icons/colors + + + + never reference + short project key + project name + + + + never reference + sprint name + start date + end date + + + + use ONLY for highlight array, never in text + use #number format in text + Task, Bug, Feature, etc. + TO DO, IN PROGRESS, DONE, etc. + issue title + detailed description + never reference + + + + never reference + maps to issue.id + maps to user + + + + + OUTPUT MUST BE VALID JSON ONLY - no markdown, no explanations before/after + NEVER output internal IDs (id, userId, creatorId, organisationId, projectId, sprintId) in the text field + ALWAYS use #<number> format when referring to issues in the text field + Every issue mentioned in text MUST have its id in the highlighted_issues array + + + + Respond with a single JSON object. No other text. + +{ + "text": "Your response text here. Use #number format for issues.", + "highlighted_issues": [71, 84, 93], + "suggested_actions": [] +} + + + The response shown to the user. Be concise. Never use "You have" or "There are". Just state facts. Use #number format for issues. + Array of issue IDs mentioned in text. Include every issue referenced. Empty array if no issues mentioned. + Array of suggested actions (empty for now). Future: navigation suggestions, filter suggestions. + + + + + + show me my done issues + { + "text": "4 DONE issues:\n\n#11 \"Filters should persist across refresh\" - DONE\n#7 \"Export organisation contents as JSON\" - DONE\n#9 \"Add assign to me by default\" - DONE\n#18 \"Share filters button\" - DONE", + "highlighted_issues": [71, 84, 93, 105], + "suggested_actions": [] +} + + + + how many issues do i have + { + "text": "12 issues total:\n- 5 TO DO\n- 4 IN PROGRESS\n- 3 DONE", + "highlighted_issues": [], + "suggested_actions": [] +} + + + + tell me about issue 12 + { + "text": "#12 \"Assignee notes\" - IN PROGRESS\n\nUsers should be able to add assignee notes to represent an assignee's role in an issue.", + "highlighted_issues": [76], + "suggested_actions": [] +} + + + + what's the login feature status + { + "text": "I don't know.", + "highlighted_issues": [], + "suggested_actions": [] +} + + + + what should i work on today + { + "text": "3 IN PROGRESS issues:\n\n#12 \"Assignee notes\" - IN PROGRESS\nAdd functionality for assignees to write notes on issues.\n\n#8 \"Dark mode toggle\" - IN PROGRESS\nImplement system-wide dark mode.\n\n#15 \"API rate limiting\" - IN PROGRESS\nAdd rate limiting to public endpoints.", + "highlighted_issues": [76, 55, 89], + "suggested_actions": [] +} + + + + + Never start responses with "You have" or "There are" + Never use phrases like "based on", "I can see", "according to" + Never ask follow-up questions + Use numerals only (5, not five) + Be concise - under 200 words + + diff --git a/packages/backend/src/routes/index.ts b/packages/backend/src/routes/index.ts index 7ba6e1e..b2144b3 100644 --- a/packages/backend/src/routes/index.ts +++ b/packages/backend/src/routes/index.ts @@ -1,3 +1,4 @@ +import aiChat from "./ai/chat"; import authLogin from "./auth/login"; import authLogout from "./auth/logout"; import authMe from "./auth/me"; @@ -56,6 +57,8 @@ import userUpdate from "./user/update"; import userUploadAvatar from "./user/upload-avatar"; export const routes = { + aiChat, + authRegister, authLogin, authLogout, diff --git a/packages/shared/src/api-schemas.ts b/packages/shared/src/api-schemas.ts index b16c42c..ca05624 100644 --- a/packages/shared/src/api-schemas.ts +++ b/packages/shared/src/api-schemas.ts @@ -666,3 +666,9 @@ export const CancelSubscriptionResponseSchema = z.object({ }); export type CancelSubscriptionResponse = z.infer; + +export const ChatRequestSchema = z.object({ + orgId: z.coerce.number().int().positive("orgId must be a positive integer"), + projectId: z.coerce.number().int().positive("projectId must be a positive integer"), + message: z.string().min(1, "Message is required"), +}); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index ef65a17..428bdd2 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -135,6 +135,7 @@ export { UserResponseSchema, UserUpdateRequestSchema, VerifyEmailRequestSchema, + ChatRequestSchema, } from "./api-schemas"; export { ISSUE_COMMENT_MAX_LENGTH, From 76e71d1f8a8dd1b94c738d0ac0d5c4e42929f8d1 Mon Sep 17 00:00:00 2001 From: Oliver Bryan Date: Sat, 31 Jan 2026 11:01:30 +0000 Subject: [PATCH 2/7] opencode chat frontend implementation --- .../backend/src/routes/ai/context-builders.ts | 5 + packages/backend/src/routes/ai/opencode.ts | 10 +- packages/frontend/src/components/chat.tsx | 102 ++++++++++++++++++ packages/frontend/src/components/ui/field.tsx | 3 + .../src/components/ui/icon-button.tsx | 5 + packages/frontend/src/components/ui/icon.tsx | 4 + packages/frontend/src/lib/query/hooks/chat.ts | 15 +++ .../frontend/src/lib/query/hooks/index.ts | 1 + packages/frontend/src/pages/Issues.tsx | 3 + packages/shared/src/api-schemas.ts | 11 ++ packages/shared/src/contract.ts | 13 +++ packages/shared/src/index.ts | 5 +- 12 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 packages/frontend/src/components/chat.tsx create mode 100644 packages/frontend/src/lib/query/hooks/chat.ts diff --git a/packages/backend/src/routes/ai/context-builders.ts b/packages/backend/src/routes/ai/context-builders.ts index 9001b1d..0e9e7f9 100644 --- a/packages/backend/src/routes/ai/context-builders.ts +++ b/packages/backend/src/routes/ai/context-builders.ts @@ -50,6 +50,11 @@ ${projects.map((p) => ` `).join("\n")} + +${issues.map((i) => ` `).join("\n")} + + + ${assignedIssues.map((i) => ` `).join("\n")} diff --git a/packages/backend/src/routes/ai/opencode.ts b/packages/backend/src/routes/ai/opencode.ts index cb4b8fe..79eac82 100644 --- a/packages/backend/src/routes/ai/opencode.ts +++ b/packages/backend/src/routes/ai/opencode.ts @@ -6,7 +6,15 @@ export type AIResponse = { }; export const callAI = async (prompt: string): Promise => { - const result = Bun.spawn(["opencode", "run", prompt, "--model", "opencode/kimi-k2.5-free"], { + const models = [ + "opencode/glm-4.7-free", + "opencode/kimi-k2.5-free", + "opencode/minimax-m2.1-free", + "opencode/trinity-large-preview-free", + ]; + const model = models[3]!; + + const result = Bun.spawn(["opencode", "run", prompt, "--model", model], { stdout: "pipe", stderr: "pipe", }); diff --git a/packages/frontend/src/components/chat.tsx b/packages/frontend/src/components/chat.tsx new file mode 100644 index 0000000..0d2d2c3 --- /dev/null +++ b/packages/frontend/src/components/chat.tsx @@ -0,0 +1,102 @@ +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import Icon from "@/components/ui/icon"; +import { useChatMutation, useSelectedOrganisation, useSelectedProject } from "@/lib/query/hooks"; +import { parseError } from "@/lib/server"; +import { IconButton } from "./ui/icon-button"; +import { Input } from "./ui/input"; + +export function Chat() { + const selectedOrganisation = useSelectedOrganisation(); + const selectedProject = useSelectedProject(); + const chatMutation = useChatMutation(); + + const [isOpen, setIsOpen] = useState(false); + const [message, setMessage] = useState(""); + const [response, setResponse] = useState(""); + const [error, setError] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!message.trim()) return; + if (!selectedOrganisation || !selectedProject) { + setError("Please select an organisation and project first"); + return; + } + + setError(null); + setResponse(""); + + try { + const data = await chatMutation.mutateAsync({ + orgId: selectedOrganisation.Organisation.id, + projectId: selectedProject.Project.id, + message: message.trim(), + }); + setResponse(data.text); + setMessage(""); + } catch (err) { + const errorMessage = parseError(err as Error); + setError(errorMessage); + } + }; + + return ( + <> + setIsOpen(!isOpen)} + className="fixed bottom-4 left-1/2 -translate-x-1/2 z-50 rounded-full" + size="lg" + variant="outline" + > + + + + {isOpen && ( +
+
+
+ {response && ( +
+

{response}

+
+ )} + + {chatMutation.isPending && ( +
+ +
+ )} + +
+ ) => setMessage(e.target.value)} + placeholder={`Ask me anything about the ${selectedProject?.Project.name || "..."} project...`} + disabled={chatMutation.isPending} + showCounter={false} + /> + + +
+
+ + {error && ( +
+

{error}

+
+ )} +
+
+ )} + + ); +} diff --git a/packages/frontend/src/components/ui/field.tsx b/packages/frontend/src/components/ui/field.tsx index d30b1ca..4deb8b0 100644 --- a/packages/frontend/src/components/ui/field.tsx +++ b/packages/frontend/src/components/ui/field.tsx @@ -15,6 +15,7 @@ export function Field({ spellcheck, maxLength, showCounter = true, + disabled = false, }: { label: string; value?: string; @@ -28,6 +29,7 @@ export function Field({ spellcheck?: boolean; maxLength?: number; showCounter?: boolean; + disabled?: boolean; }) { const [internalTouched, setInternalTouched] = useState(false); const isTouched = submitAttempted || internalTouched; @@ -62,6 +64,7 @@ export function Field({ spellCheck={spellcheck} maxLength={maxLength} showCounter={showCounter} + disabled={disabled} />
{error || invalidMessage !== "" ? ( diff --git a/packages/frontend/src/components/ui/icon-button.tsx b/packages/frontend/src/components/ui/icon-button.tsx index cd11f81..a116f9a 100644 --- a/packages/frontend/src/components/ui/icon-button.tsx +++ b/packages/frontend/src/components/ui/icon-button.tsx @@ -22,6 +22,11 @@ const iconButtonVariants = cva( sm: "w-5 h-5", md: "w-9 h-9", lg: "w-10 h-10", + xl: "w-12 h-12", + "2xl": "w-14 h-14", + "3xl": "w-16 h-16", + "4xl": "w-18 h-18", + "5xl": "w-20 h-20", }, }, defaultVariants: { diff --git a/packages/frontend/src/components/ui/icon.tsx b/packages/frontend/src/components/ui/icon.tsx index f659017..7040e66 100644 --- a/packages/frontend/src/components/ui/icon.tsx +++ b/packages/frontend/src/components/ui/icon.tsx @@ -20,6 +20,7 @@ import { Edit as PixelEdit, EyeClosed as PixelEyeClosed, AddGrid as PixelGridAdd, + HumanHandsup as PixelHandsUp, Home as PixelHome, InfoBox as PixelInfo, Link as PixelLink, @@ -61,6 +62,7 @@ import { DotsThreeVerticalIcon as PhosphorDotsThreeVertical, PencilSimpleIcon as PhosphorEdit, EyeClosedIcon as PhosphorEyeClosed, + PersonArmsSpreadIcon as PhosphorHandsUp, HashIcon as PhosphorHash, HashStraightIcon as PhosphorHashStraight, HouseIcon as PhosphorHome, @@ -123,6 +125,7 @@ import { Moon, OctagonXIcon, Pause, + PersonStanding, Play, Plus, Rocket, @@ -181,6 +184,7 @@ const icons = { phosphor: PhosphorDotsSixVertical, }, hash: { lucide: Hash, pixel: PhosphorHashStraight, phosphor: PhosphorHash }, + handsUp: { lucide: PersonStanding, pixel: PixelHandsUp, phosphor: PhosphorHandsUp }, home: { lucide: LucideHome, pixel: PixelHome, phosphor: PhosphorHome }, info: { lucide: InfoIcon, pixel: PixelInfo, phosphor: PhosphorInfo }, layoutDashboard: { lucide: LayoutDashboard, pixel: PixelDashboard, phosphor: PhosphorLayout }, diff --git a/packages/frontend/src/lib/query/hooks/chat.ts b/packages/frontend/src/lib/query/hooks/chat.ts new file mode 100644 index 0000000..78f19bc --- /dev/null +++ b/packages/frontend/src/lib/query/hooks/chat.ts @@ -0,0 +1,15 @@ +import type { ChatRequest, ChatResponse } from "@sprint/shared"; +import { useMutation } from "@tanstack/react-query"; +import { apiClient } from "@/lib/server"; + +export function useChatMutation() { + return useMutation({ + mutationKey: ["ai", "chat"], + mutationFn: async (input) => { + const { data, error } = await apiClient.aiChat({ query: input }); + if (error) throw new Error(error); + if (!data) throw new Error("failed to get chat response"); + return data as ChatResponse; + }, + }); +} diff --git a/packages/frontend/src/lib/query/hooks/index.ts b/packages/frontend/src/lib/query/hooks/index.ts index 7c0564f..942d4d0 100644 --- a/packages/frontend/src/lib/query/hooks/index.ts +++ b/packages/frontend/src/lib/query/hooks/index.ts @@ -1,3 +1,4 @@ +export * from "@/lib/query/hooks/chat"; export * from "@/lib/query/hooks/derived"; export * from "@/lib/query/hooks/issue-comments"; export * from "@/lib/query/hooks/issues"; diff --git a/packages/frontend/src/pages/Issues.tsx b/packages/frontend/src/pages/Issues.tsx index e9834a0..47cdfe0 100644 --- a/packages/frontend/src/pages/Issues.tsx +++ b/packages/frontend/src/pages/Issues.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useLocation } from "react-router-dom"; import Avatar from "@/components/avatar"; +import { Chat } from "@/components/chat"; import { IssueDetailPane } from "@/components/issue-detail-pane"; import { IssueModal } from "@/components/issue-modal"; import { defaultIssuesTableFilters, IssuesTable, type IssuesTableFilters } from "@/components/issues-table"; @@ -694,6 +695,8 @@ export default function Issues() { }} /> )} + + ); } diff --git a/packages/shared/src/api-schemas.ts b/packages/shared/src/api-schemas.ts index ca05624..18b4042 100644 --- a/packages/shared/src/api-schemas.ts +++ b/packages/shared/src/api-schemas.ts @@ -672,3 +672,14 @@ export const ChatRequestSchema = z.object({ projectId: z.coerce.number().int().positive("projectId must be a positive integer"), message: z.string().min(1, "Message is required"), }); + +export type ChatRequest = z.infer; + +export const ChatResponseSchema = z.object({ + text: z.string(), + highlighted_issues: z.array(z.number()), + suggested_actions: z.array(z.string()).nullable(), + raw: z.string(), +}); + +export type ChatResponse = z.infer; diff --git a/packages/shared/src/contract.ts b/packages/shared/src/contract.ts index 460fb70..05f3efa 100644 --- a/packages/shared/src/contract.ts +++ b/packages/shared/src/contract.ts @@ -4,6 +4,8 @@ import { ApiErrorSchema, AuthResponseSchema, CancelSubscriptionResponseSchema, + ChatRequestSchema, + ChatResponseSchema, CreateCheckoutSessionRequestSchema, CreateCheckoutSessionResponseSchema, CreatePortalSessionResponseSchema, @@ -683,6 +685,17 @@ export const apiContract = c.router({ }, headers: csrfHeaderSchema, }, + + aiChat: { + method: "GET", + path: "/ai/chat", + query: ChatRequestSchema, + responses: { + 200: ChatResponseSchema, + 400: ApiErrorSchema, + 404: ApiErrorSchema, + }, + }, }); export type ApiContract = typeof apiContract; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 428bdd2..78d0824 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2,6 +2,8 @@ export type { ApiError, AuthResponse, CancelSubscriptionResponse, + ChatRequest, + ChatResponse, CreateCheckoutSessionRequest, CreateCheckoutSessionResponse, CreatePortalSessionResponse, @@ -70,6 +72,8 @@ export { ApiErrorSchema, AuthResponseSchema, CancelSubscriptionResponseSchema, + ChatRequestSchema, + ChatResponseSchema, CreateCheckoutSessionRequestSchema, CreateCheckoutSessionResponseSchema, CreatePortalSessionResponseSchema, @@ -135,7 +139,6 @@ export { UserResponseSchema, UserUpdateRequestSchema, VerifyEmailRequestSchema, - ChatRequestSchema, } from "./api-schemas"; export { ISSUE_COMMENT_MAX_LENGTH, From 8196fb0bf6cf95f004fe8fd36d2c10fc369afacb Mon Sep 17 00:00:00 2001 From: Oliver Bryan Date: Sat, 31 Jan 2026 14:15:29 +0000 Subject: [PATCH 3/7] get opencode free models --- packages/backend/src/index.ts | 2 + packages/backend/src/routes/ai/opencode.ts | 185 ++++++++++++++++++++- 2 files changed, 178 insertions(+), 9 deletions(-) diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index f2ef600..61dc158 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -4,6 +4,7 @@ import { testDB } from "./db/client"; import { cleanupExpiredSessions } from "./db/queries"; import { withAuthedLogging, withLogging } from "./logger"; import { routes } from "./routes"; +import { initializeFreeModelsCache } from "./routes/ai/opencode"; const DEV = process.argv.find((arg) => ["--dev", "--developer", "-d"].includes(arg.toLowerCase())) != null; const PORT = process.argv.find((arg) => arg.toLowerCase().startsWith("--port="))?.split("=")[1] || 0; @@ -121,6 +122,7 @@ const main = async () => { console.log(`tnirps (sprint server) listening on ${server.url}`); await testDB(); + await initializeFreeModelsCache(); startSessionCleanup(); }; diff --git a/packages/backend/src/routes/ai/opencode.ts b/packages/backend/src/routes/ai/opencode.ts index 79eac82..7187c7c 100644 --- a/packages/backend/src/routes/ai/opencode.ts +++ b/packages/backend/src/routes/ai/opencode.ts @@ -5,16 +5,183 @@ export type AIResponse = { raw: string; }; -export const callAI = async (prompt: string): Promise => { - const models = [ - "opencode/glm-4.7-free", - "opencode/kimi-k2.5-free", - "opencode/minimax-m2.1-free", - "opencode/trinity-large-preview-free", - ]; - const model = models[3]!; +export interface OpencodeModel { + id: string; + providerID: string; + name: string; + family: string; + api: { + id: string; + url: string; + npm: string; + }; + status: string; + headers: Record; + options: Record; + cost: { + input: number; + output: number; + cache: { + read: number; + write: number; + }; + }; + limit: { + context: number; + output: number; + input?: number; + }; + capabilities: { + temperature: boolean; + reasoning: boolean; + attachment: boolean; + toolcall: boolean; + input: { + text: boolean; + audio: boolean; + image: boolean; + video: boolean; + pdf: boolean; + }; + output: { + text: boolean; + audio: boolean; + image: boolean; + video: boolean; + pdf: boolean; + }; + interleaved: boolean | { field: string }; + }; + release_date: string; + variants: Record; +} - const result = Bun.spawn(["opencode", "run", prompt, "--model", model], { +export interface FreeModel { + name: string; + id: string; +} + +const ignore = ["gpt-5-nano"]; + +function parseOpencodeModelsOutput(output: string): OpencodeModel[] { + let models: OpencodeModel[] = []; + const lines = output.split("\n"); + let currentModelId: string | null = null; + let jsonBuffer: string[] = []; + + for (const line of lines) { + const trimmed = line.trim(); + + // Check if line starts with "opencode/" (model ID header) + if (trimmed.startsWith("opencode/")) { + // Save previous model if exists + if (currentModelId && jsonBuffer.length > 0) { + try { + const model = JSON.parse(jsonBuffer.join("\n")) as OpencodeModel; + models.push(model); + } catch { + // skip invalid JSON + } + } + currentModelId = trimmed; + jsonBuffer = []; + } else if (trimmed.startsWith("{")) { + jsonBuffer.push(trimmed); + } else if (jsonBuffer.length > 0 && trimmed) { + // Continue accumulating JSON lines + jsonBuffer.push(trimmed); + } + } + + // Don't forget the last model + if (currentModelId && jsonBuffer.length > 0) { + try { + const model = JSON.parse(jsonBuffer.join("\n")) as OpencodeModel; + models.push(model); + } catch { + // skip invalid JSON + } + } + + models = models.filter((model) => !ignore.includes(model.id)); + + return models; +} + +// cached models storage +let cachedFreeModels: FreeModel[] | null = null; + +// fallback models when opencode CLI fails +const FALLBACK_MODELS: FreeModel[] = [ + { name: "GLM 4.7 Free", id: "glm-4.7-free" }, + { name: "Kimi K2.5 Free", id: "kimi-k2.5-free" }, + { name: "MiniMax M2.1 Free", id: "minimax-m2.1-free" }, + { name: "Trinity Large", id: "trinity-large-preview-free" }, +]; + +// initialize the cache by fetching from opencode CLI +export async function initializeFreeModelsCache(): Promise { + try { + const models = await fetchFreeOpencodeModels(); + cachedFreeModels = models; + console.log(`loaded ${models.length} free opencode models`); + } catch (error) { + console.error("failed to initialize free models cache:", error); + cachedFreeModels = FALLBACK_MODELS; + } +} + +// refresh the cached models +export async function refreshFreeModelsCache(): Promise { + try { + const models = await fetchFreeOpencodeModels(); + cachedFreeModels = models; + console.log(`refreshed ${models.length} free opencode models`); + return models; + } catch (error) { + console.error("failed to refresh free models cache:", error); + // keep existing cache if refresh fails + return cachedFreeModels ?? FALLBACK_MODELS; + } +} + +// get cached models (returns fallback if not initialized) +export function getCachedFreeModels(): FreeModel[] { + return cachedFreeModels ?? FALLBACK_MODELS; +} + +// internal function to actually fetch from CLI +async function fetchFreeOpencodeModels(): Promise { + const proc = Bun.spawn({ + cmd: ["opencode", "models", "opencode", "--verbose"], + stdout: "pipe", + stderr: "pipe", + }); + + const output = await new Response(proc.stdout).text(); + const exitCode = await proc.exited; + + if (exitCode !== 0) { + const error = await new Response(proc.stderr).text(); + console.error("opencode models command failed:", error); + throw new Error("Failed to fetch opencode models"); + } + + const allModels = parseOpencodeModelsOutput(output); + + // filter to free models only (cost.input === 0 && cost.output === 0) + const freeModels = allModels.filter((model) => model.cost.input === 0 && model.cost.output === 0); + + // map to the expected format { name, id } + return freeModels.map((model) => ({ + name: model.name, + id: model.id, + })); +} + +export const callAI = async (prompt: string, model: string): Promise => { + if (!model.includes("/")) model = `opencode/${model}`; + const result = Bun.spawn(["opencode", "run", prompt, "--model", model, "--title", "SPRINT_AUTOMATED"], { stdout: "pipe", stderr: "pipe", }); From 95beddaa6ce6578c88300140c32fe066bf6cea7d Mon Sep 17 00:00:00 2001 From: Oliver Bryan Date: Sat, 31 Jan 2026 14:16:12 +0000 Subject: [PATCH 4/7] /ai/models route --- packages/backend/src/index.ts | 2 ++ packages/backend/src/routes/ai/models.ts | 8 ++++++++ packages/backend/src/routes/index.ts | 2 ++ packages/frontend/src/lib/query/hooks/chat.ts | 14 +++++++++++++- packages/shared/src/api-schemas.ts | 8 ++++++++ packages/shared/src/contract.ts | 10 ++++++++++ packages/shared/src/index.ts | 2 ++ 7 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 packages/backend/src/routes/ai/models.ts diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 61dc158..63a37a7 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -33,11 +33,13 @@ const withGlobalAuthed = (handler: RouteHandler) => const main = async () => { const server = Bun.serve({ port: Number(PORT), + idleTimeout: 60, // 1 minute for AI chat responses routes: { "/": withGlobal(() => new Response(`title: tnirps\ndev-mode: ${DEV}\nport: ${PORT}`)), "/health": withGlobal(() => new Response("OK")), "/ai/chat": withGlobalAuthed(withAuth(routes.aiChat)), + "/ai/models": withGlobalAuthed(withAuth(routes.aiModels)), // routes that modify state require withCSRF middleware "/auth/register": withGlobal(routes.authRegister), diff --git a/packages/backend/src/routes/ai/models.ts b/packages/backend/src/routes/ai/models.ts new file mode 100644 index 0000000..9c1e067 --- /dev/null +++ b/packages/backend/src/routes/ai/models.ts @@ -0,0 +1,8 @@ +import type { AuthedRequest } from "../../auth/middleware"; +import { getCachedFreeModels } from "./opencode"; + +// GET /ai/models - returns cached free models +export default function aiModels(_req: AuthedRequest) { + const models = getCachedFreeModels(); + return Response.json(models); +} diff --git a/packages/backend/src/routes/index.ts b/packages/backend/src/routes/index.ts index b2144b3..edebc36 100644 --- a/packages/backend/src/routes/index.ts +++ b/packages/backend/src/routes/index.ts @@ -1,4 +1,5 @@ import aiChat from "./ai/chat"; +import aiModels from "./ai/models"; import authLogin from "./auth/login"; import authLogout from "./auth/logout"; import authMe from "./auth/me"; @@ -58,6 +59,7 @@ import userUploadAvatar from "./user/upload-avatar"; export const routes = { aiChat, + aiModels, authRegister, authLogin, diff --git a/packages/frontend/src/lib/query/hooks/chat.ts b/packages/frontend/src/lib/query/hooks/chat.ts index 78f19bc..4bb162c 100644 --- a/packages/frontend/src/lib/query/hooks/chat.ts +++ b/packages/frontend/src/lib/query/hooks/chat.ts @@ -1,4 +1,4 @@ -import type { ChatRequest, ChatResponse } from "@sprint/shared"; +import type { ChatRequest, ChatResponse, ModelsResponse } from "@sprint/shared"; import { useMutation } from "@tanstack/react-query"; import { apiClient } from "@/lib/server"; @@ -13,3 +13,15 @@ export function useChatMutation() { }, }); } + +export function useModels() { + return useMutation({ + mutationKey: ["ai", "models"], + mutationFn: async () => { + const { data, error } = await apiClient.aiModels(); + if (error) throw new Error(error); + if (!data) throw new Error("failed to get models"); + return data as ModelsResponse; + }, + }); +} diff --git a/packages/shared/src/api-schemas.ts b/packages/shared/src/api-schemas.ts index 18b4042..6c37e6f 100644 --- a/packages/shared/src/api-schemas.ts +++ b/packages/shared/src/api-schemas.ts @@ -683,3 +683,11 @@ export const ChatResponseSchema = z.object({ }); export type ChatResponse = z.infer; + +export const ModelsResponseSchema = z.array( + z.object({ + name: z.string(), + id: z.string(), + }), +); +export type ModelsResponse = z.infer; diff --git a/packages/shared/src/contract.ts b/packages/shared/src/contract.ts index 05f3efa..9794f4c 100644 --- a/packages/shared/src/contract.ts +++ b/packages/shared/src/contract.ts @@ -27,6 +27,7 @@ import { IssuesTypeCountQuerySchema, IssueUpdateRequestSchema, LoginRequestSchema, + ModelsResponseSchema, OrgAddMemberRequestSchema, OrganisationMemberRecordSchema, OrganisationMemberResponseSchema, @@ -696,6 +697,15 @@ export const apiContract = c.router({ 404: ApiErrorSchema, }, }, + aiModels: { + method: "GET", + path: "/ai/models", + responses: { + 200: ModelsResponseSchema, + 400: ApiErrorSchema, + 404: ApiErrorSchema, + }, + }, }); export type ApiContract = typeof apiContract; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 78d0824..8a756dc 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -23,6 +23,7 @@ export type { IssuesTypeCountQuery, IssueUpdateRequest, LoginRequest, + ModelsResponse, OrgAddMemberRequest, OrganisationMemberRecordType, OrganisationMemberResponse, @@ -95,6 +96,7 @@ export { IssuesTypeCountQuerySchema, IssueUpdateRequestSchema, LoginRequestSchema, + ModelsResponseSchema, OrgAddMemberRequestSchema, OrganisationMemberRecordSchema, OrganisationMemberResponseSchema, From f6d74927d63544e17fb601e78ace8bafb4548bb6 Mon Sep 17 00:00:00 2001 From: Oliver Bryan Date: Sat, 31 Jan 2026 14:16:29 +0000 Subject: [PATCH 5/7] take model for /ai/chat --- packages/backend/src/routes/ai/chat.ts | 4 ++-- packages/frontend/src/lib/query/hooks/chat.ts | 2 +- packages/shared/src/api-schemas.ts | 1 + 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/backend/src/routes/ai/chat.ts b/packages/backend/src/routes/ai/chat.ts index be9b2e3..2df3241 100644 --- a/packages/backend/src/routes/ai/chat.ts +++ b/packages/backend/src/routes/ai/chat.ts @@ -10,7 +10,7 @@ export default async function aiChat(req: AuthedRequest) { const parsed = parseQueryParams(url, ChatRequestSchema); if ("error" in parsed) return parsed.error; - const { orgId, projectId, message } = parsed.data; + const { orgId, projectId, message, model } = parsed.data; const user = await getUserById(req.userId); if (!user) { @@ -20,7 +20,7 @@ export default async function aiChat(req: AuthedRequest) { const context = await buildContext(orgId, projectId, user); const fullPrompt = `${SYSTEM_PROMPT}\n\n${context}\n\n${message}`; - const response = await callAI(fullPrompt); + const response = await callAI(fullPrompt, model); return Response.json(response); } diff --git a/packages/frontend/src/lib/query/hooks/chat.ts b/packages/frontend/src/lib/query/hooks/chat.ts index 4bb162c..83eaa93 100644 --- a/packages/frontend/src/lib/query/hooks/chat.ts +++ b/packages/frontend/src/lib/query/hooks/chat.ts @@ -2,7 +2,7 @@ import type { ChatRequest, ChatResponse, ModelsResponse } from "@sprint/shared"; import { useMutation } from "@tanstack/react-query"; import { apiClient } from "@/lib/server"; -export function useChatMutation() { +export function useChat() { return useMutation({ mutationKey: ["ai", "chat"], mutationFn: async (input) => { diff --git a/packages/shared/src/api-schemas.ts b/packages/shared/src/api-schemas.ts index 6c37e6f..a2c7e39 100644 --- a/packages/shared/src/api-schemas.ts +++ b/packages/shared/src/api-schemas.ts @@ -671,6 +671,7 @@ export const ChatRequestSchema = z.object({ orgId: z.coerce.number().int().positive("orgId must be a positive integer"), projectId: z.coerce.number().int().positive("projectId must be a positive integer"), message: z.string().min(1, "Message is required"), + model: z.string().min(1, "Model is required"), }); export type ChatRequest = z.infer; From 71a4e685200267d7cc95df3a539aed94142b5c37 Mon Sep 17 00:00:00 2001 From: Oliver Bryan Date: Sat, 31 Jan 2026 14:16:54 +0000 Subject: [PATCH 6/7] model selection and highlighting logic --- packages/frontend/src/components/chat.tsx | 105 ++++++++++++++++++---- 1 file changed, 89 insertions(+), 16 deletions(-) diff --git a/packages/frontend/src/components/chat.tsx b/packages/frontend/src/components/chat.tsx index 0d2d2c3..9221e7b 100644 --- a/packages/frontend/src/components/chat.tsx +++ b/packages/frontend/src/components/chat.tsx @@ -1,22 +1,41 @@ -import { useState } from "react"; +import { Fragment, type SubmitEvent, useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; import Icon from "@/components/ui/icon"; -import { useChatMutation, useSelectedOrganisation, useSelectedProject } from "@/lib/query/hooks"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { useChat, useModels, useSelectedOrganisation, useSelectedProject } from "@/lib/query/hooks"; import { parseError } from "@/lib/server"; +import Avatar from "./avatar"; +import { useAuthenticatedSession } from "./session-provider"; import { IconButton } from "./ui/icon-button"; import { Input } from "./ui/input"; -export function Chat() { +export function Chat({ setHighlighted }: { setHighlighted: (ids: number[]) => void }) { + const { user } = useAuthenticatedSession(); const selectedOrganisation = useSelectedOrganisation(); const selectedProject = useSelectedProject(); - const chatMutation = useChatMutation(); + const chat = useChat(); + const models = useModels(); const [isOpen, setIsOpen] = useState(false); const [message, setMessage] = useState(""); const [response, setResponse] = useState(""); + const [lastUserMessage, setLastUserMessage] = useState(""); const [error, setError] = useState(null); + const [selectedModel, setSelectedModel] = useState(""); - const handleSubmit = async (e: React.FormEvent) => { + useEffect(() => { + if (isOpen && !models.data) { + models.mutate(); + } + }, [isOpen, models]); + + useEffect(() => { + if (models.data && models.data.length > 0 && !selectedModel) { + setSelectedModel(models.data[0].id); + } + }, [models.data, selectedModel]); + + const handleSubmit = async (e: SubmitEvent) => { e.preventDefault(); if (!message.trim()) return; @@ -27,14 +46,18 @@ export function Chat() { setError(null); setResponse(""); + setHighlighted([]); + setLastUserMessage(message.trim()); try { - const data = await chatMutation.mutateAsync({ + const data = await chat.mutateAsync({ orgId: selectedOrganisation.Organisation.id, projectId: selectedProject.Project.id, message: message.trim(), + model: selectedModel || "trinity-large-preview-free", }); setResponse(data.text); + setHighlighted(data.highlighted_issues); setMessage(""); } catch (err) { const errorMessage = parseError(err as Error); @@ -54,43 +77,93 @@ export function Chat() { {isOpen && ( -
-
+
+
+ {lastUserMessage && ( +
+ +

{lastUserMessage}

+
+ )} {response && ( -
-

{response}

+
+

+ {response.split("\n").map((line, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: <> + + {line} +
+
+ ))} +

)} - {chatMutation.isPending && ( + {chat.isPending && (
- +
)}
+ {models.data && models.data.length > 0 && ( + + )} ) => setMessage(e.target.value)} placeholder={`Ask me anything about the ${selectedProject?.Project.name || "..."} project...`} - disabled={chatMutation.isPending} + disabled={chat.isPending} showCounter={false} />
+ {} {error && ( -
+

{error}

)} From ab98e0ed803d8daf6d4383f52980b4977bdd640e Mon Sep 17 00:00:00 2001 From: Oliver Bryan Date: Sat, 31 Jan 2026 14:17:01 +0000 Subject: [PATCH 7/7] highlighting implementation --- packages/frontend/src/components/issues-table.tsx | 10 ++++++---- packages/frontend/src/pages/Issues.tsx | 11 +++++++++-- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/frontend/src/components/issues-table.tsx b/packages/frontend/src/components/issues-table.tsx index 1a162c0..65cde89 100644 --- a/packages/frontend/src/components/issues-table.tsx +++ b/packages/frontend/src/components/issues-table.tsx @@ -29,10 +29,12 @@ export function IssuesTable({ columns = {}, className, filters, + highlighted, }: { columns?: { id?: boolean; title?: boolean; description?: boolean; status?: boolean; assignee?: boolean }; className: string; filters?: IssuesTableFilters; + highlighted?: number[]; }) { const { selectedProjectId, selectedIssueId, selectIssue } = useSelection(); const { data: issuesData = [] } = useIssues(selectedProjectId); @@ -188,7 +190,7 @@ export function IssuesTable({ @@ -205,7 +207,7 @@ export function IssuesTable({ @@ -237,7 +239,7 @@ export function IssuesTable({ @@ -254,7 +256,7 @@ export function IssuesTable({ diff --git a/packages/frontend/src/pages/Issues.tsx b/packages/frontend/src/pages/Issues.tsx index 47cdfe0..7645a4a 100644 --- a/packages/frontend/src/pages/Issues.tsx +++ b/packages/frontend/src/pages/Issues.tsx @@ -167,6 +167,8 @@ export default function Issues() { } = useSelection(); const location = useLocation(); + const [highlighted, setHighlighted] = useState([]); + const deepLinkParams = useMemo(() => { const params = new URLSearchParams(location.search); const orgSlug = params.get("o")?.trim().toLowerCase() ?? ""; @@ -667,7 +669,12 @@ export default function Issues() {
- +
@@ -696,7 +703,7 @@ export default function Issues() { /> )} - + ); }