mirror of
https://github.com/hex248/sprint.git
synced 2026-02-08 02:33:01 +00:00
opencode chat backend
This commit is contained in:
@@ -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),
|
||||
|
||||
26
packages/backend/src/routes/ai/chat.ts
Normal file
26
packages/backend/src/routes/ai/chat.ts
Normal 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 } = 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);
|
||||
|
||||
return Response.json(response);
|
||||
}
|
||||
98
packages/backend/src/routes/ai/context-builders.ts
Normal file
98
packages/backend/src/routes/ai/context-builders.ts
Normal file
@@ -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 "<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>
|
||||
|
||||
<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, """)}" 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, """)}" />`,
|
||||
)
|
||||
.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, """)}" />`,
|
||||
)
|
||||
.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, """)}" />`,
|
||||
)
|
||||
.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, """)}</title>
|
||||
<description>${(i.Issue.description || "").replace(/"/g, """)}</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>`;
|
||||
};
|
||||
65
packages/backend/src/routes/ai/opencode.ts
Normal file
65
packages/backend/src/routes/ai/opencode.ts
Normal file
@@ -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<AIResponse> => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
};
|
||||
124
packages/backend/src/routes/ai/system-prompt.xml
Normal file
124
packages/backend/src/routes/ai/system-prompt.xml
Normal 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 #<number> 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>
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user