OpenCode chat implementation

OpenCode chat implementation
This commit is contained in:
Oliver Bryan
2026-01-31 14:26:32 +00:00
committed by GitHub
19 changed files with 1251 additions and 5 deletions

View File

@@ -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"
}
}

View File

@@ -4,6 +4,7 @@ import { testDB } from "./db/client";
import { cleanupExpiredSessions } from "./db/queries"; import { cleanupExpiredSessions } from "./db/queries";
import { withAuthedLogging, withLogging } from "./logger"; import { withAuthedLogging, withLogging } from "./logger";
import { routes } from "./routes"; import { routes } from "./routes";
import { initializeFreeModelsCache } from "./routes/ai/opencode";
const DEV = process.argv.find((arg) => ["--dev", "--developer", "-d"].includes(arg.toLowerCase())) != null; 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; const PORT = process.argv.find((arg) => arg.toLowerCase().startsWith("--port="))?.split("=")[1] || 0;
@@ -32,10 +33,14 @@ const withGlobalAuthed = <T extends BunRequest>(handler: RouteHandler<T>) =>
const main = async () => { const main = async () => {
const server = Bun.serve({ const server = Bun.serve({
port: Number(PORT), port: Number(PORT),
idleTimeout: 60, // 1 minute for AI chat responses
routes: { routes: {
"/": withGlobal(() => new Response(`title: tnirps\ndev-mode: ${DEV}\nport: ${PORT}`)), "/": withGlobal(() => new Response(`title: tnirps\ndev-mode: ${DEV}\nport: ${PORT}`)),
"/health": withGlobal(() => new Response("OK")), "/health": withGlobal(() => new Response("OK")),
"/ai/chat": withGlobalAuthed(withAuth(routes.aiChat)),
"/ai/models": withGlobalAuthed(withAuth(routes.aiModels)),
// routes that modify state require withCSRF middleware // routes that modify state require withCSRF middleware
"/auth/register": withGlobal(routes.authRegister), "/auth/register": withGlobal(routes.authRegister),
"/auth/login": withGlobal(routes.authLogin), "/auth/login": withGlobal(routes.authLogin),
@@ -119,6 +124,7 @@ const main = async () => {
console.log(`tnirps (sprint server) listening on ${server.url}`); console.log(`tnirps (sprint server) listening on ${server.url}`);
await testDB(); await testDB();
await initializeFreeModelsCache();
startSessionCleanup(); startSessionCleanup();
}; };

View File

@@ -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, model } = 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<user_query>${message}</user_query>`;
const response = await callAI(fullPrompt, model);
return Response.json(response);
}

View File

@@ -0,0 +1,103 @@
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 "<current_context></current_context>";
}
const projects = await getProjectsByOrganisationId(orgId);
const project = await getProjectByID(projectId);
if (!project) {
return "<current_context></current_context>";
}
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 `
<current_context>
<user id="${user.id}" name="${user.name}" username="${user.username}" />
<organisation name="${organisation.name}" slug="${organisation.slug}">
<statuses>
${Object.entries(organisation.statuses)
.map(([name, color]) => ` <status name="${name}" color="${color}" />`)
.join("\n")}
</statuses>
</organisation>
<projects>
${projects.map((p) => ` <project key="${p.Project.key}" name="${p.Project.name}" />`).join("\n")}
</projects>
<sprints>
${sprints.map((s) => ` <sprint id="${s.id}" name="${s.name}" start="${s.startDate.toUTCString()?.split("T")[0]}" end="${s.endDate?.toUTCString().split("T")[0]}" />`).join("\n")}
</sprints>
<all_issues count="${issues.length}">
${issues.map((i) => ` <issue id="${i.Issue.id}" number="${i.Issue.number}" type="${i.Issue.type}" status="${i.Issue.status}" title="${i.Issue.title.replace(/"/g, "&quot;")}" sprint="${sprints.find((s) => s.id === i.Issue.sprintId)?.name || "Unassigned"}" />`).join("\n")}
</all_issues>
<my_issues count="${assignedIssues.length}">
${assignedIssues.map((i) => ` <issue id="${i.Issue.id}" number="${i.Issue.number}" type="${i.Issue.type}" status="${i.Issue.status}" title="${i.Issue.title.replace(/"/g, "&quot;")}" sprint="${sprints.find((s) => s.id === i.Issue.sprintId)?.name || "Unassigned"}" />`).join("\n")}
</my_issues>
<issues_by_status>
<status name="TO DO" count="${byStatus("TO DO").length}">
${byStatus("TO DO")
.map(
(i) =>
` <issue id="${i.Issue.id}" number="${i.Issue.number}" title="${i.Issue.title.replace(/"/g, "&quot;")}" />`,
)
.join("\n")}
</status>
<status name="IN PROGRESS" count="${byStatus("IN PROGRESS").length}">
${byStatus("IN PROGRESS")
.map(
(i) =>
` <issue id="${i.Issue.id}" number="${i.Issue.number}" title="${i.Issue.title.replace(/"/g, "&quot;")}" />`,
)
.join("\n")}
</status>
<status name="DONE" count="${byStatus("DONE").length}">
${byStatus("DONE")
.map(
(i) =>
` <issue id="${i.Issue.id}" number="${i.Issue.number}" title="${i.Issue.title.replace(/"/g, "&quot;")}" />`,
)
.join("\n")}
</status>
</issues_by_status>
<issue_details>
${assignedIssues
.map(
(i) => ` <issue id="${i.Issue.id}" number="${i.Issue.number}">
<title>${i.Issue.title.replace(/"/g, "&quot;")}</title>
<description>${(i.Issue.description || "").replace(/"/g, "&quot;")}</description>
<status>${i.Issue.status}</status>
<type>${i.Issue.type}</type>
<sprint>${sprints.find((s) => s.id === i.Issue.sprintId)?.name || "None"}</sprint>
</issue>`,
)
.join("\n")}
</issue_details>
</current_context>`;
};

View File

@@ -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);
}

View File

@@ -0,0 +1,240 @@
export type AIResponse = {
text: string;
highlighted_issues: number[];
suggested_actions: string[] | null;
raw: string;
};
export interface OpencodeModel {
id: string;
providerID: string;
name: string;
family: string;
api: {
id: string;
url: string;
npm: string;
};
status: string;
headers: Record<string, string>;
options: Record<string, unknown>;
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<string, unknown>;
}
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<void> {
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<FreeModel[]> {
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<FreeModel[]> {
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<AIResponse> => {
if (!model.includes("/")) model = `opencode/${model}`;
const result = Bun.spawn(["opencode", "run", prompt, "--model", model, "--title", "SPRINT_AUTOMATED"], {
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,
};
}
};

View File

@@ -0,0 +1,124 @@
<?xml version="1.0" encoding="UTF-8"?>
<system_prompt>
<identity>
<name>Sprinter</name>
<role>AI assistant in a project management tool</role>
</identity>
<data_schema>
<entity name="organisation">
<field name="id" type="number" internal="true">never reference</field>
<field name="name" type="string">organisation name</field>
<field name="slug" type="string">URL-friendly identifier</field>
<field name="statuses" type="object">maps status names to colors</field>
<field name="issueTypes" type="object">maps types to icons/colors</field>
</entity>
<entity name="projects">
<field name="id" type="number" internal="true">never reference</field>
<field name="key" type="string">short project key</field>
<field name="name" type="string">project name</field>
</entity>
<entity name="sprints">
<field name="id" type="number" internal="true">never reference</field>
<field name="name" type="string">sprint name</field>
<field name="startDate" type="timestamp">start date</field>
<field name="endDate" type="timestamp">end date</field>
</entity>
<entity name="issues">
<field name="id" type="number" internal="true">use ONLY for highlight array, never in text</field>
<field name="number" type="number">use #number format in text</field>
<field name="type" type="string">Task, Bug, Feature, etc.</field>
<field name="status" type="string">TO DO, IN PROGRESS, DONE, etc.</field>
<field name="title" type="string">issue title</field>
<field name="description" type="string">detailed description</field>
<field name="sprintId" type="number" internal="true">never reference</field>
</entity>
<entity name="issueAssignees">
<field name="id" type="number" internal="true">never reference</field>
<field name="issueId" type="number">maps to issue.id</field>
<field name="userId" type="number">maps to user</field>
</entity>
</data_schema>
<critical_rules>
<rule>OUTPUT MUST BE VALID JSON ONLY - no markdown, no explanations before/after</rule>
<rule>NEVER output internal IDs (id, userId, creatorId, organisationId, projectId, sprintId) in the text field</rule>
<rule>ALWAYS use #&lt;number&gt; format when referring to issues in the text field</rule>
<rule>Every issue mentioned in text MUST have its id in the highlighted_issues array</rule>
</critical_rules>
<output_format>
<description>Respond with a single JSON object. No other text.</description>
<schema>
{
"text": "Your response text here. Use #number format for issues.",
"highlighted_issues": [71, 84, 93],
"suggested_actions": []
}
</schema>
<fields>
<field name="text" required="true">The response shown to the user. Be concise. Never use "You have" or "There are". Just state facts. Use #number format for issues.</field>
<field name="highlighted_issues" required="true">Array of issue IDs mentioned in text. Include every issue referenced. Empty array if no issues mentioned.</field>
<field name="suggested_actions" required="true">Array of suggested actions (empty for now). Future: navigation suggestions, filter suggestions.</field>
</fields>
</output_format>
<examples>
<example>
<user_query>show me my done issues</user_query>
<output>{
"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": []
}</output>
</example>
<example>
<user_query>how many issues do i have</user_query>
<output>{
"text": "12 issues total:\n- 5 TO DO\n- 4 IN PROGRESS\n- 3 DONE",
"highlighted_issues": [],
"suggested_actions": []
}</output>
</example>
<example>
<user_query>tell me about issue 12</user_query>
<output>{
"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": []
}</output>
</example>
<example>
<user_query>what's the login feature status</user_query>
<output>{
"text": "I don't know.",
"highlighted_issues": [],
"suggested_actions": []
}</output>
</example>
<example>
<user_query>what should i work on today</user_query>
<output>{
"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": []
}</output>
</example>
</examples>
<style_rules>
<rule>Never start responses with "You have" or "There are"</rule>
<rule>Never use phrases like "based on", "I can see", "according to"</rule>
<rule>Never ask follow-up questions</rule>
<rule>Use numerals only (5, not five)</rule>
<rule>Be concise - under 200 words</rule>
</style_rules>
</system_prompt>

View File

@@ -1,3 +1,5 @@
import aiChat from "./ai/chat";
import aiModels from "./ai/models";
import authLogin from "./auth/login"; import authLogin from "./auth/login";
import authLogout from "./auth/logout"; import authLogout from "./auth/logout";
import authMe from "./auth/me"; import authMe from "./auth/me";
@@ -56,6 +58,9 @@ import userUpdate from "./user/update";
import userUploadAvatar from "./user/upload-avatar"; import userUploadAvatar from "./user/upload-avatar";
export const routes = { export const routes = {
aiChat,
aiModels,
authRegister, authRegister,
authLogin, authLogin,
authLogout, authLogout,

View File

@@ -0,0 +1,175 @@
import { Fragment, type SubmitEvent, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import Icon from "@/components/ui/icon";
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({ setHighlighted }: { setHighlighted: (ids: number[]) => void }) {
const { user } = useAuthenticatedSession();
const selectedOrganisation = useSelectedOrganisation();
const selectedProject = useSelectedProject();
const chat = useChat();
const models = useModels();
const [isOpen, setIsOpen] = useState(false);
const [message, setMessage] = useState("");
const [response, setResponse] = useState<string>("");
const [lastUserMessage, setLastUserMessage] = useState<string>("");
const [error, setError] = useState<string | null>(null);
const [selectedModel, setSelectedModel] = useState<string>("");
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;
if (!selectedOrganisation || !selectedProject) {
setError("Please select an organisation and project first");
return;
}
setError(null);
setResponse("");
setHighlighted([]);
setLastUserMessage(message.trim());
try {
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);
setError(errorMessage);
}
};
return (
<>
<IconButton
onClick={() => setIsOpen(!isOpen)}
className="fixed bottom-4 left-1/2 -translate-x-1/2 z-50 rounded-full"
size="lg"
variant="outline"
>
<Icon icon={isOpen ? "x" : "handsUp"} className="size-5" />
</IconButton>
{isOpen && (
<div className="fixed bottom-18 left-1/2 -translate-x-1/2 z-40 w-full max-w-2xl mx-4 bg-background border shadow-xl">
<div className="flex flex-col p-2 gap-2">
<form onSubmit={handleSubmit} className="flex flex-col gap-2">
{lastUserMessage && (
<div className="p-2 border flex items-center gap-2 text-sm">
<Avatar
name={user.name}
username={user.username}
avatarURL={user.avatarURL}
size={6}
textClass={"text-md"}
strong
/>
<p className="whitespace-pre-wrap">{lastUserMessage}</p>
</div>
)}
{response && (
<div className="p-2 border flex items-center gap-2 text-sm">
<p className="whitespace-pre-wrap">
{response.split("\n").map((line, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: <>
<Fragment key={index}>
{line}
<br />
</Fragment>
))}
</p>
</div>
)}
{chat.isPending && (
<div className="flex justify-center py-4">
<Icon
icon="loader"
size={24}
className="animate-[spin_3s_linear_infinite]"
color={"var(--personality"}
/>
</div>
)}
<div className="flex items-center gap-2">
{models.data && models.data.length > 0 && (
<Select value={selectedModel} onValueChange={setSelectedModel}>
<SelectTrigger className="w-fit text-[12px]" chevronClassName="hidden">
<SelectValue placeholder="Select model" />
</SelectTrigger>
<SelectContent
side="top"
position="popper"
align="start"
className="data-[side=top]:translate-x-0"
>
{models.data.map((model) => (
<SelectItem key={model.id} value={model.id} className="text-[12px]">
{model.name.replace(" Free", "").replace(" Preview", "")}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<Input
value={message}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMessage(e.target.value)}
placeholder={`Ask me anything about the ${selectedProject?.Project.name || "..."} project...`}
disabled={chat.isPending}
showCounter={false}
/>
<Button
type="submit"
disabled={
chat.isPending ||
!message.trim() ||
!selectedOrganisation ||
!selectedProject ||
selectedModel.length < 1
}
>
{chat.isPending ? "Sending..." : "Send"}
</Button>
</div>
</form>
{}
{error && (
<div className="">
<p className="text-destructive text-sm">{error}</p>
</div>
)}
</div>
</div>
)}
</>
);
}

View File

@@ -29,10 +29,12 @@ export function IssuesTable({
columns = {}, columns = {},
className, className,
filters, filters,
highlighted,
}: { }: {
columns?: { id?: boolean; title?: boolean; description?: boolean; status?: boolean; assignee?: boolean }; columns?: { id?: boolean; title?: boolean; description?: boolean; status?: boolean; assignee?: boolean };
className: string; className: string;
filters?: IssuesTableFilters; filters?: IssuesTableFilters;
highlighted?: number[];
}) { }) {
const { selectedProjectId, selectedIssueId, selectIssue } = useSelection(); const { selectedProjectId, selectedIssueId, selectIssue } = useSelection();
const { data: issuesData = [] } = useIssues(selectedProjectId); const { data: issuesData = [] } = useIssues(selectedProjectId);
@@ -188,7 +190,7 @@ export function IssuesTable({
<TableCell <TableCell
className={cn( className={cn(
"font-medium border-r text-right p-0", "font-medium border-r text-right p-0",
isSelected && (isSelected || highlighted?.includes(issueData.Issue.id)) &&
"shadow-[inset_1px_1px_0_0_var(--personality),inset_0_-1px_0_0_var(--personality)]", "shadow-[inset_1px_1px_0_0_var(--personality),inset_0_-1px_0_0_var(--personality)]",
)} )}
> >
@@ -205,7 +207,7 @@ export function IssuesTable({
<TableCell <TableCell
className={cn( className={cn(
"min-w-0 p-0", "min-w-0 p-0",
isSelected && (isSelected || highlighted?.includes(issueData.Issue.id)) &&
"shadow-[inset_0_1px_0_0_var(--personality),inset_0_-1px_0_0_var(--personality)]", "shadow-[inset_0_1px_0_0_var(--personality),inset_0_-1px_0_0_var(--personality)]",
)} )}
> >
@@ -237,7 +239,7 @@ export function IssuesTable({
<TableCell <TableCell
className={cn( className={cn(
"overflow-hidden p-0", "overflow-hidden p-0",
isSelected && (isSelected || highlighted?.includes(issueData.Issue.id)) &&
"shadow-[inset_0_1px_0_0_var(--personality),inset_0_-1px_0_0_var(--personality)]", "shadow-[inset_0_1px_0_0_var(--personality),inset_0_-1px_0_0_var(--personality)]",
)} )}
> >
@@ -254,7 +256,7 @@ export function IssuesTable({
<TableCell <TableCell
className={cn( className={cn(
"h-[32px] p-0", "h-[32px] p-0",
isSelected && (isSelected || highlighted?.includes(issueData.Issue.id)) &&
"shadow-[inset_0_1px_0_0_var(--personality),inset_-1px_0_0_0_var(--personality),inset_0_-1px_0_0_var(--personality)]", "shadow-[inset_0_1px_0_0_var(--personality),inset_-1px_0_0_0_var(--personality),inset_0_-1px_0_0_var(--personality)]",
)} )}
> >

View File

@@ -15,6 +15,7 @@ export function Field({
spellcheck, spellcheck,
maxLength, maxLength,
showCounter = true, showCounter = true,
disabled = false,
}: { }: {
label: string; label: string;
value?: string; value?: string;
@@ -28,6 +29,7 @@ export function Field({
spellcheck?: boolean; spellcheck?: boolean;
maxLength?: number; maxLength?: number;
showCounter?: boolean; showCounter?: boolean;
disabled?: boolean;
}) { }) {
const [internalTouched, setInternalTouched] = useState(false); const [internalTouched, setInternalTouched] = useState(false);
const isTouched = submitAttempted || internalTouched; const isTouched = submitAttempted || internalTouched;
@@ -62,6 +64,7 @@ export function Field({
spellCheck={spellcheck} spellCheck={spellcheck}
maxLength={maxLength} maxLength={maxLength}
showCounter={showCounter} showCounter={showCounter}
disabled={disabled}
/> />
<div className="flex items-end justify-end w-full text-xs mb-0 -mt-1"> <div className="flex items-end justify-end w-full text-xs mb-0 -mt-1">
{error || invalidMessage !== "" ? ( {error || invalidMessage !== "" ? (

View File

@@ -22,6 +22,11 @@ const iconButtonVariants = cva(
sm: "w-5 h-5", sm: "w-5 h-5",
md: "w-9 h-9", md: "w-9 h-9",
lg: "w-10 h-10", 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: { defaultVariants: {

View File

@@ -20,6 +20,7 @@ import {
Edit as PixelEdit, Edit as PixelEdit,
EyeClosed as PixelEyeClosed, EyeClosed as PixelEyeClosed,
AddGrid as PixelGridAdd, AddGrid as PixelGridAdd,
HumanHandsup as PixelHandsUp,
Home as PixelHome, Home as PixelHome,
InfoBox as PixelInfo, InfoBox as PixelInfo,
Link as PixelLink, Link as PixelLink,
@@ -61,6 +62,7 @@ import {
DotsThreeVerticalIcon as PhosphorDotsThreeVertical, DotsThreeVerticalIcon as PhosphorDotsThreeVertical,
PencilSimpleIcon as PhosphorEdit, PencilSimpleIcon as PhosphorEdit,
EyeClosedIcon as PhosphorEyeClosed, EyeClosedIcon as PhosphorEyeClosed,
PersonArmsSpreadIcon as PhosphorHandsUp,
HashIcon as PhosphorHash, HashIcon as PhosphorHash,
HashStraightIcon as PhosphorHashStraight, HashStraightIcon as PhosphorHashStraight,
HouseIcon as PhosphorHome, HouseIcon as PhosphorHome,
@@ -123,6 +125,7 @@ import {
Moon, Moon,
OctagonXIcon, OctagonXIcon,
Pause, Pause,
PersonStanding,
Play, Play,
Plus, Plus,
Rocket, Rocket,
@@ -181,6 +184,7 @@ const icons = {
phosphor: PhosphorDotsSixVertical, phosphor: PhosphorDotsSixVertical,
}, },
hash: { lucide: Hash, pixel: PhosphorHashStraight, phosphor: PhosphorHash }, hash: { lucide: Hash, pixel: PhosphorHashStraight, phosphor: PhosphorHash },
handsUp: { lucide: PersonStanding, pixel: PixelHandsUp, phosphor: PhosphorHandsUp },
home: { lucide: LucideHome, pixel: PixelHome, phosphor: PhosphorHome }, home: { lucide: LucideHome, pixel: PixelHome, phosphor: PhosphorHome },
info: { lucide: InfoIcon, pixel: PixelInfo, phosphor: PhosphorInfo }, info: { lucide: InfoIcon, pixel: PixelInfo, phosphor: PhosphorInfo },
layoutDashboard: { lucide: LayoutDashboard, pixel: PixelDashboard, phosphor: PhosphorLayout }, layoutDashboard: { lucide: LayoutDashboard, pixel: PixelDashboard, phosphor: PhosphorLayout },

View File

@@ -0,0 +1,27 @@
import type { ChatRequest, ChatResponse, ModelsResponse } from "@sprint/shared";
import { useMutation } from "@tanstack/react-query";
import { apiClient } from "@/lib/server";
export function useChat() {
return useMutation<ChatResponse, Error, ChatRequest>({
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;
},
});
}
export function useModels() {
return useMutation<ModelsResponse, Error>({
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;
},
});
}

View File

@@ -1,3 +1,4 @@
export * from "@/lib/query/hooks/chat";
export * from "@/lib/query/hooks/derived"; export * from "@/lib/query/hooks/derived";
export * from "@/lib/query/hooks/issue-comments"; export * from "@/lib/query/hooks/issue-comments";
export * from "@/lib/query/hooks/issues"; export * from "@/lib/query/hooks/issues";

View File

@@ -3,6 +3,7 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import Avatar from "@/components/avatar"; import Avatar from "@/components/avatar";
import { Chat } from "@/components/chat";
import { IssueDetailPane } from "@/components/issue-detail-pane"; import { IssueDetailPane } from "@/components/issue-detail-pane";
import { IssueModal } from "@/components/issue-modal"; import { IssueModal } from "@/components/issue-modal";
import { defaultIssuesTableFilters, IssuesTable, type IssuesTableFilters } from "@/components/issues-table"; import { defaultIssuesTableFilters, IssuesTable, type IssuesTableFilters } from "@/components/issues-table";
@@ -166,6 +167,8 @@ export default function Issues() {
} = useSelection(); } = useSelection();
const location = useLocation(); const location = useLocation();
const [highlighted, setHighlighted] = useState<number[]>([]);
const deepLinkParams = useMemo(() => { const deepLinkParams = useMemo(() => {
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
const orgSlug = params.get("o")?.trim().toLowerCase() ?? ""; const orgSlug = params.get("o")?.trim().toLowerCase() ?? "";
@@ -666,7 +669,12 @@ export default function Issues() {
<ResizablePanelGroup className={`flex-1`}> <ResizablePanelGroup className={`flex-1`}>
<ResizablePanel id={"left"} minSize={400}> <ResizablePanel id={"left"} minSize={400}>
<div className="border w-full flex-shrink"> <div className="border w-full flex-shrink">
<IssuesTable columns={{ description: false }} className="w-full" filters={issueFilters} /> <IssuesTable
columns={{ description: false }}
className="w-full"
filters={issueFilters}
highlighted={highlighted}
/>
</div> </div>
</ResizablePanel> </ResizablePanel>
@@ -694,6 +702,8 @@ export default function Issues() {
}} }}
/> />
)} )}
<Chat setHighlighted={setHighlighted} />
</main> </main>
); );
} }

View File

@@ -666,3 +666,29 @@ export const CancelSubscriptionResponseSchema = z.object({
}); });
export type CancelSubscriptionResponse = z.infer<typeof CancelSubscriptionResponseSchema>; export type CancelSubscriptionResponse = z.infer<typeof CancelSubscriptionResponseSchema>;
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<typeof ChatRequestSchema>;
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<typeof ChatResponseSchema>;
export const ModelsResponseSchema = z.array(
z.object({
name: z.string(),
id: z.string(),
}),
);
export type ModelsResponse = z.infer<typeof ModelsResponseSchema>;

View File

@@ -4,6 +4,8 @@ import {
ApiErrorSchema, ApiErrorSchema,
AuthResponseSchema, AuthResponseSchema,
CancelSubscriptionResponseSchema, CancelSubscriptionResponseSchema,
ChatRequestSchema,
ChatResponseSchema,
CreateCheckoutSessionRequestSchema, CreateCheckoutSessionRequestSchema,
CreateCheckoutSessionResponseSchema, CreateCheckoutSessionResponseSchema,
CreatePortalSessionResponseSchema, CreatePortalSessionResponseSchema,
@@ -25,6 +27,7 @@ import {
IssuesTypeCountQuerySchema, IssuesTypeCountQuerySchema,
IssueUpdateRequestSchema, IssueUpdateRequestSchema,
LoginRequestSchema, LoginRequestSchema,
ModelsResponseSchema,
OrgAddMemberRequestSchema, OrgAddMemberRequestSchema,
OrganisationMemberRecordSchema, OrganisationMemberRecordSchema,
OrganisationMemberResponseSchema, OrganisationMemberResponseSchema,
@@ -683,6 +686,26 @@ export const apiContract = c.router({
}, },
headers: csrfHeaderSchema, headers: csrfHeaderSchema,
}, },
aiChat: {
method: "GET",
path: "/ai/chat",
query: ChatRequestSchema,
responses: {
200: ChatResponseSchema,
400: ApiErrorSchema,
404: ApiErrorSchema,
},
},
aiModels: {
method: "GET",
path: "/ai/models",
responses: {
200: ModelsResponseSchema,
400: ApiErrorSchema,
404: ApiErrorSchema,
},
},
}); });
export type ApiContract = typeof apiContract; export type ApiContract = typeof apiContract;

View File

@@ -2,6 +2,8 @@ export type {
ApiError, ApiError,
AuthResponse, AuthResponse,
CancelSubscriptionResponse, CancelSubscriptionResponse,
ChatRequest,
ChatResponse,
CreateCheckoutSessionRequest, CreateCheckoutSessionRequest,
CreateCheckoutSessionResponse, CreateCheckoutSessionResponse,
CreatePortalSessionResponse, CreatePortalSessionResponse,
@@ -21,6 +23,7 @@ export type {
IssuesTypeCountQuery, IssuesTypeCountQuery,
IssueUpdateRequest, IssueUpdateRequest,
LoginRequest, LoginRequest,
ModelsResponse,
OrgAddMemberRequest, OrgAddMemberRequest,
OrganisationMemberRecordType, OrganisationMemberRecordType,
OrganisationMemberResponse, OrganisationMemberResponse,
@@ -70,6 +73,8 @@ export {
ApiErrorSchema, ApiErrorSchema,
AuthResponseSchema, AuthResponseSchema,
CancelSubscriptionResponseSchema, CancelSubscriptionResponseSchema,
ChatRequestSchema,
ChatResponseSchema,
CreateCheckoutSessionRequestSchema, CreateCheckoutSessionRequestSchema,
CreateCheckoutSessionResponseSchema, CreateCheckoutSessionResponseSchema,
CreatePortalSessionResponseSchema, CreatePortalSessionResponseSchema,
@@ -91,6 +96,7 @@ export {
IssuesTypeCountQuerySchema, IssuesTypeCountQuerySchema,
IssueUpdateRequestSchema, IssueUpdateRequestSchema,
LoginRequestSchema, LoginRequestSchema,
ModelsResponseSchema,
OrgAddMemberRequestSchema, OrgAddMemberRequestSchema,
OrganisationMemberRecordSchema, OrganisationMemberRecordSchema,
OrganisationMemberResponseSchema, OrganisationMemberResponseSchema,