opencode chat frontend implementation

This commit is contained in:
2026-01-31 11:01:30 +00:00
parent 925e8f2746
commit 76e71d1f8a
12 changed files with 175 additions and 2 deletions

View File

@@ -50,6 +50,11 @@ ${projects.map((p) => ` <project key="${p.Project.key}" name="${p.Project.nam
${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.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> </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}"> <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")} ${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> </my_issues>

View File

@@ -6,7 +6,15 @@ export type AIResponse = {
}; };
export const callAI = async (prompt: string): Promise<AIResponse> => { export const callAI = async (prompt: string): Promise<AIResponse> => {
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", stdout: "pipe",
stderr: "pipe", stderr: "pipe",
}); });

View File

@@ -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<string>("");
const [error, setError] = useState<string | null>(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 (
<>
<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-20 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">
<form onSubmit={handleSubmit} className="flex flex-col gap-2">
{response && (
<div className="p-4 border max-h-60 overflow-y-auto">
<p className="text-sm whitespace-pre-wrap">{response}</p>
</div>
)}
{chatMutation.isPending && (
<div className="flex justify-center py-4">
<Icon icon="loader" size={32} className="animate-spin" />
</div>
)}
<div className="flex items-center gap-2">
<Input
value={message}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMessage(e.target.value)}
placeholder={`Ask me anything about the ${selectedProject?.Project.name || "..."} project...`}
disabled={chatMutation.isPending}
showCounter={false}
/>
<Button
type="submit"
disabled={
chatMutation.isPending || !message.trim() || !selectedOrganisation || !selectedProject
}
>
{chatMutation.isPending ? "Sending..." : "Send"}
</Button>
</div>
</form>
{error && (
<div className="p-4 bg-destructive/10 border border-destructive">
<p className="text-destructive text-sm">{error}</p>
</div>
)}
</div>
</div>
)}
</>
);
}

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,15 @@
import type { ChatRequest, ChatResponse } from "@sprint/shared";
import { useMutation } from "@tanstack/react-query";
import { apiClient } from "@/lib/server";
export function useChatMutation() {
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;
},
});
}

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";
@@ -694,6 +695,8 @@ export default function Issues() {
}} }}
/> />
)} )}
<Chat />
</main> </main>
); );
} }

View File

@@ -672,3 +672,14 @@ export const ChatRequestSchema = z.object({
projectId: z.coerce.number().int().positive("projectId 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"), message: z.string().min(1, "Message 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>;

View File

@@ -4,6 +4,8 @@ import {
ApiErrorSchema, ApiErrorSchema,
AuthResponseSchema, AuthResponseSchema,
CancelSubscriptionResponseSchema, CancelSubscriptionResponseSchema,
ChatRequestSchema,
ChatResponseSchema,
CreateCheckoutSessionRequestSchema, CreateCheckoutSessionRequestSchema,
CreateCheckoutSessionResponseSchema, CreateCheckoutSessionResponseSchema,
CreatePortalSessionResponseSchema, CreatePortalSessionResponseSchema,
@@ -683,6 +685,17 @@ export const apiContract = c.router({
}, },
headers: csrfHeaderSchema, headers: csrfHeaderSchema,
}, },
aiChat: {
method: "GET",
path: "/ai/chat",
query: ChatRequestSchema,
responses: {
200: ChatResponseSchema,
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,
@@ -70,6 +72,8 @@ export {
ApiErrorSchema, ApiErrorSchema,
AuthResponseSchema, AuthResponseSchema,
CancelSubscriptionResponseSchema, CancelSubscriptionResponseSchema,
ChatRequestSchema,
ChatResponseSchema,
CreateCheckoutSessionRequestSchema, CreateCheckoutSessionRequestSchema,
CreateCheckoutSessionResponseSchema, CreateCheckoutSessionResponseSchema,
CreatePortalSessionResponseSchema, CreatePortalSessionResponseSchema,
@@ -135,7 +139,6 @@ export {
UserResponseSchema, UserResponseSchema,
UserUpdateRequestSchema, UserUpdateRequestSchema,
VerifyEmailRequestSchema, VerifyEmailRequestSchema,
ChatRequestSchema,
} from "./api-schemas"; } from "./api-schemas";
export { export {
ISSUE_COMMENT_MAX_LENGTH, ISSUE_COMMENT_MAX_LENGTH,