♻️ feat: implement session management for PTY sessions in the server
- Add SessionManager class to handle PTY sessions with WebSocket connections. - Implement methods for creating, retrieving, and destroying sessions. - Handle PTY output and WebSocket messages for terminal interaction. - Ensure graceful session destruction and cleanup. feat: initialize web application with Next.js and Tailwind CSS - Create initial Next.js application structure with TypeScript support. - Set up Tailwind CSS for styling with custom theme configurations. - Add ESLint configuration for code quality and consistency. feat: implement chat API and UI components - Create chat API route to handle chat requests and responses. - Develop chat layout with sidebar, header, chat window, and input components. - Integrate Zustand for state management of conversations and messages. - Add utility functions for formatting dates and managing class names. chore: add environment variables and configuration files - Create .env.example for environment variable setup. - Add configuration files for PostCSS, Tailwind CSS, and TypeScript. - Set up package.json with necessary dependencies and scripts for development.
This commit is contained in:
85
web/lib/api.ts
Normal file
85
web/lib/api.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { Message } from "./types";
|
||||
|
||||
const getApiUrl = () =>
|
||||
process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001";
|
||||
|
||||
export interface StreamChunk {
|
||||
type: "text" | "tool_use" | "tool_result" | "done" | "error";
|
||||
content?: string;
|
||||
tool?: {
|
||||
id: string;
|
||||
name: string;
|
||||
input?: Record<string, unknown>;
|
||||
result?: string;
|
||||
is_error?: boolean;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function* streamChat(
|
||||
messages: Pick<Message, "role" | "content">[],
|
||||
model: string,
|
||||
signal?: AbortSignal
|
||||
): AsyncGenerator<StreamChunk> {
|
||||
const response = await fetch("/api/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ messages, model, stream: true }),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.text();
|
||||
yield { type: "error", error: err };
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
yield { type: "error", error: "No response body" };
|
||||
return;
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data: ")) {
|
||||
const data = line.slice(6).trim();
|
||||
if (data === "[DONE]") {
|
||||
yield { type: "done" };
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const chunk = JSON.parse(data) as StreamChunk;
|
||||
yield chunk;
|
||||
} catch {
|
||||
// skip malformed chunks
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
yield { type: "done" };
|
||||
}
|
||||
|
||||
export async function fetchHealth(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${getApiUrl()}/health`, { cache: "no-store" });
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
16
web/lib/constants.ts
Normal file
16
web/lib/constants.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export const MODELS = [
|
||||
{ id: "claude-opus-4-6", label: "Claude Opus 4.6", description: "Most capable" },
|
||||
{ id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6", description: "Balanced" },
|
||||
{ id: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5", description: "Fastest" },
|
||||
] as const;
|
||||
|
||||
export const DEFAULT_MODEL = "claude-sonnet-4-6";
|
||||
|
||||
export const API_ROUTES = {
|
||||
chat: "/api/chat",
|
||||
stream: "/api/stream",
|
||||
} as const;
|
||||
|
||||
export const MAX_MESSAGE_LENGTH = 100_000;
|
||||
|
||||
export const STREAMING_CHUNK_SIZE = 64;
|
||||
123
web/lib/store.ts
Normal file
123
web/lib/store.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import { nanoid } from "nanoid";
|
||||
import type { Conversation, Message, AppSettings } from "./types";
|
||||
import { DEFAULT_MODEL } from "./constants";
|
||||
|
||||
interface ChatState {
|
||||
conversations: Conversation[];
|
||||
activeConversationId: string | null;
|
||||
settings: AppSettings;
|
||||
|
||||
// Actions
|
||||
createConversation: () => string;
|
||||
setActiveConversation: (id: string) => void;
|
||||
deleteConversation: (id: string) => void;
|
||||
addMessage: (conversationId: string, message: Omit<Message, "id" | "createdAt">) => string;
|
||||
updateMessage: (conversationId: string, messageId: string, updates: Partial<Message>) => void;
|
||||
updateSettings: (settings: Partial<AppSettings>) => void;
|
||||
getActiveConversation: () => Conversation | null;
|
||||
}
|
||||
|
||||
export const useChatStore = create<ChatState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
conversations: [],
|
||||
activeConversationId: null,
|
||||
settings: {
|
||||
theme: "dark",
|
||||
model: DEFAULT_MODEL,
|
||||
apiUrl: process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001",
|
||||
streamingEnabled: true,
|
||||
},
|
||||
|
||||
createConversation: () => {
|
||||
const id = nanoid();
|
||||
const now = Date.now();
|
||||
const conversation: Conversation = {
|
||||
id,
|
||||
title: "New conversation",
|
||||
messages: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
model: get().settings.model,
|
||||
};
|
||||
set((state) => ({
|
||||
conversations: [conversation, ...state.conversations],
|
||||
activeConversationId: id,
|
||||
}));
|
||||
return id;
|
||||
},
|
||||
|
||||
setActiveConversation: (id) => {
|
||||
set({ activeConversationId: id });
|
||||
},
|
||||
|
||||
deleteConversation: (id) => {
|
||||
set((state) => {
|
||||
const remaining = state.conversations.filter((c) => c.id !== id);
|
||||
const nextActive =
|
||||
state.activeConversationId === id
|
||||
? (remaining[0]?.id ?? null)
|
||||
: state.activeConversationId;
|
||||
return { conversations: remaining, activeConversationId: nextActive };
|
||||
});
|
||||
},
|
||||
|
||||
addMessage: (conversationId, message) => {
|
||||
const id = nanoid();
|
||||
const now = Date.now();
|
||||
set((state) => ({
|
||||
conversations: state.conversations.map((c) =>
|
||||
c.id === conversationId
|
||||
? {
|
||||
...c,
|
||||
messages: [...c.messages, { ...message, id, createdAt: now }],
|
||||
updatedAt: now,
|
||||
}
|
||||
: c
|
||||
),
|
||||
}));
|
||||
return id;
|
||||
},
|
||||
|
||||
updateMessage: (conversationId, messageId, updates) => {
|
||||
set((state) => ({
|
||||
conversations: state.conversations.map((c) =>
|
||||
c.id === conversationId
|
||||
? {
|
||||
...c,
|
||||
messages: c.messages.map((m) =>
|
||||
m.id === messageId ? { ...m, ...updates } : m
|
||||
),
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
: c
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
updateSettings: (settings) => {
|
||||
set((state) => ({
|
||||
settings: { ...state.settings, ...settings },
|
||||
}));
|
||||
},
|
||||
|
||||
getActiveConversation: () => {
|
||||
const state = get();
|
||||
return (
|
||||
state.conversations.find((c) => c.id === state.activeConversationId) ??
|
||||
null
|
||||
);
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "claude-code-chat",
|
||||
partialize: (state) => ({
|
||||
conversations: state.conversations,
|
||||
activeConversationId: state.activeConversationId,
|
||||
settings: state.settings,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
59
web/lib/types.ts
Normal file
59
web/lib/types.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
export type MessageRole = "user" | "assistant" | "system" | "tool";
|
||||
|
||||
export type MessageStatus = "pending" | "streaming" | "complete" | "error";
|
||||
|
||||
export interface TextContent {
|
||||
type: "text";
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface ToolUseContent {
|
||||
type: "tool_use";
|
||||
id: string;
|
||||
name: string;
|
||||
input: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ToolResultContent {
|
||||
type: "tool_result";
|
||||
tool_use_id: string;
|
||||
content: string | ContentBlock[];
|
||||
is_error?: boolean;
|
||||
}
|
||||
|
||||
export type ContentBlock = TextContent | ToolUseContent | ToolResultContent;
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
role: MessageRole;
|
||||
content: ContentBlock[] | string;
|
||||
status: MessageStatus;
|
||||
createdAt: number;
|
||||
model?: string;
|
||||
usage?: {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Conversation {
|
||||
id: string;
|
||||
title: string;
|
||||
messages: Message[];
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export interface ToolDefinition {
|
||||
name: string;
|
||||
description: string;
|
||||
input_schema: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
theme: "light" | "dark" | "system";
|
||||
model: string;
|
||||
apiUrl: string;
|
||||
streamingEnabled: boolean;
|
||||
}
|
||||
42
web/lib/utils.ts
Normal file
42
web/lib/utils.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function formatDate(timestamp: number): string {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return "just now";
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export function truncate(str: string, maxLength: number): string {
|
||||
if (str.length <= maxLength) return str;
|
||||
return str.slice(0, maxLength - 3) + "...";
|
||||
}
|
||||
|
||||
export function extractTextContent(content: unknown): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.filter((b): b is { type: "text"; text: string } => b?.type === "text")
|
||||
.map((b) => b.text)
|
||||
.join("");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
Reference in New Issue
Block a user