ARCHITECTURE:
- Replace SSE stream (breaks on page reload) with DB-backed background sessions
- Go Gateway runs orchestrator in detached goroutine using context.Background()
(survives HTTP disconnect, page reload, and laptop sleep/shutdown)
- Every SSE event (thinking/tool_call/delta/done/error) is persisted to chatEvents table
- Session lifecycle stored in chatSessions table (running→done/error)
- Frontend polls GET /api/orchestrator/getEvents every 1.5 s until status=done
DB CHANGES:
- Migration 0005_chat_sessions.sql: chatSessions + chatEvents tables
- schema.ts: TypeScript types for chatSessions and chatEvents
- db.go: ChatSessionRow and ChatEventRow structs with proper json tags (camelCase)
- db.go: CreateSession, AppendEvent, MarkSessionDone, GetSession, GetEvents, GetRecentSessions
GO GATEWAY:
- handlers.go: StartChatSession — creates DB session, launches goroutine, returns {sessionId} immediately
- handlers.go: GetChatSession, GetChatEvents, ListChatSessions handlers
- main.go: routes POST /api/chat/session, GET /api/chat/session/{id}, GET /api/chat/session/{id}/events, GET /api/chat/sessions
- JSON tags added to ChatSessionRow/ChatEventRow so Go returns camelCase to frontend
NODE.JS SERVER:
- gateway-proxy.ts: startChatSession, getChatSession, getChatEvents, listChatSessions functions
- routers.ts: orchestrator.startSession, .getSession, .getEvents, .listSessions tRPC procedures
FRONTEND:
- chatStore.ts: completely rewritten — uses background sessions + localStorage-based polling resume
* send() calls orchestrator.startSession via tRPC (returns immediately)
* Stores sessionId in localStorage (goclaw-pending-sessions)
* Polls getEvents every 1.5 s, applies events to UI incrementally
* On page reload: _resumePendingSessions() checks pending sessions and resumes polling
* cancel() stops all active polls
- chatStore.ts: conversations persisted to localStorage (v3 key, survives page reload)
- Chat.tsx: updated status texts to 'Фоновая обработка…', 'Обработка в фоне…'
VERIFIED:
- POST /api/chat/session → {sessionId, status:'running'} in <100ms
- Poll events → thinking, delta('Привет!'), done after ~2s
- chatSessions table has rows with status=done, model, totalTokens
- Cyrillic stored correctly in UTF-8
- JSON fields are camelCase: id, sessionId, seq, eventType, content, toolName...
277 lines
12 KiB
TypeScript
277 lines
12 KiB
TypeScript
import { int, mysqlEnum, mysqlTable, text, timestamp, varchar, decimal, json, boolean, index } from "drizzle-orm/mysql-core";
|
||
|
||
/**
|
||
* Core user table backing auth flow.
|
||
* Extend this file with additional tables as your product grows.
|
||
* Columns use camelCase to match both database fields and generated types.
|
||
*/
|
||
export const users = mysqlTable("users", {
|
||
/**
|
||
* Surrogate primary key. Auto-incremented numeric value managed by the database.
|
||
* Use this for relations between tables.
|
||
*/
|
||
id: int("id").autoincrement().primaryKey(),
|
||
/** Manus OAuth identifier (openId) returned from the OAuth callback. Unique per user. */
|
||
openId: varchar("openId", { length: 64 }).notNull().unique(),
|
||
name: text("name"),
|
||
email: varchar("email", { length: 320 }),
|
||
loginMethod: varchar("loginMethod", { length: 64 }),
|
||
role: mysqlEnum("role", ["user", "admin"]).default("user").notNull(),
|
||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||
lastSignedIn: timestamp("lastSignedIn").defaultNow().notNull(),
|
||
});
|
||
|
||
export type User = typeof users.$inferSelect;
|
||
export type InsertUser = typeof users.$inferInsert;
|
||
|
||
/**
|
||
* Agents — конфигурация и управление AI-агентами
|
||
*/
|
||
export const agents = mysqlTable("agents", {
|
||
id: int("id").autoincrement().primaryKey(),
|
||
userId: int("userId").notNull(), // Владелец агента
|
||
name: varchar("name", { length: 255 }).notNull(),
|
||
description: text("description"),
|
||
role: varchar("role", { length: 100 }).notNull(), // "developer", "researcher", "executor"
|
||
|
||
// Модель LLM
|
||
model: varchar("model", { length: 100 }).notNull(),
|
||
provider: varchar("provider", { length: 50 }).notNull(),
|
||
|
||
// Параметры LLM
|
||
temperature: decimal("temperature", { precision: 3, scale: 2 }).default("0.7"),
|
||
maxTokens: int("maxTokens").default(2048),
|
||
topP: decimal("topP", { precision: 3, scale: 2 }).default("1.0"),
|
||
frequencyPenalty: decimal("frequencyPenalty", { precision: 3, scale: 2 }).default("0.0"),
|
||
presencePenalty: decimal("presencePenalty", { precision: 3, scale: 2 }).default("0.0"),
|
||
|
||
// System Prompt
|
||
systemPrompt: text("systemPrompt"),
|
||
|
||
// Доступы и разрешения
|
||
allowedTools: json("allowedTools").$type<string[]>().default([]),
|
||
allowedDomains: json("allowedDomains").$type<string[]>().default([]),
|
||
maxRequestsPerHour: int("maxRequestsPerHour").default(100),
|
||
|
||
// Статус
|
||
isActive: boolean("isActive").default(true),
|
||
isPublic: boolean("isPublic").default(false),
|
||
isSystem: boolean("isSystem").default(false), // Системный агент (нельзя удалить)
|
||
isOrchestrator: boolean("isOrchestrator").default(false), // Главный оркестратор чата
|
||
|
||
// Метаданные
|
||
tags: json("tags").$type<string[]>().default([]),
|
||
metadata: json("metadata").$type<Record<string, any>>().default({}),
|
||
|
||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||
}, (table) => ({
|
||
userIdIdx: index("agents_userId_idx").on(table.userId),
|
||
modelIdx: index("agents_model_idx").on(table.model),
|
||
}));
|
||
|
||
export type Agent = typeof agents.$inferSelect;
|
||
export type InsertAgent = typeof agents.$inferInsert;
|
||
|
||
/**
|
||
* Agent Metrics — метрики производительности агентов
|
||
*/
|
||
export const agentMetrics = mysqlTable("agentMetrics", {
|
||
id: int("id").autoincrement().primaryKey(),
|
||
agentId: int("agentId").notNull(),
|
||
|
||
// Информация о запросе
|
||
requestId: varchar("requestId", { length: 64 }).notNull().unique(),
|
||
userMessage: text("userMessage"),
|
||
agentResponse: text("agentResponse"),
|
||
|
||
// Токены
|
||
inputTokens: int("inputTokens").default(0),
|
||
outputTokens: int("outputTokens").default(0),
|
||
totalTokens: int("totalTokens").default(0),
|
||
|
||
// Время обработки
|
||
processingTimeMs: int("processingTimeMs").notNull(),
|
||
|
||
// Статус
|
||
status: mysqlEnum("status", ["success", "error", "timeout", "rate_limited"]).notNull(),
|
||
errorMessage: text("errorMessage"),
|
||
|
||
// Инструменты
|
||
toolsCalled: json("toolsCalled").$type<string[]>().default([]),
|
||
|
||
// Модель
|
||
model: varchar("model", { length: 100 }),
|
||
temperature: decimal("temperature", { precision: 3, scale: 2 }),
|
||
|
||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||
}, (table) => ({
|
||
agentIdIdx: index("agentMetrics_agentId_idx").on(table.agentId),
|
||
createdAtIdx: index("agentMetrics_createdAt_idx").on(table.createdAt),
|
||
}));
|
||
|
||
export type AgentMetric = typeof agentMetrics.$inferSelect;
|
||
export type InsertAgentMetric = typeof agentMetrics.$inferInsert;
|
||
|
||
/**
|
||
* Agent History — полная история запросов
|
||
*/
|
||
export const agentHistory = mysqlTable("agentHistory", {
|
||
id: int("id").autoincrement().primaryKey(),
|
||
agentId: int("agentId").notNull(),
|
||
|
||
userMessage: text("userMessage").notNull(),
|
||
agentResponse: text("agentResponse"),
|
||
|
||
conversationId: varchar("conversationId", { length: 64 }),
|
||
messageIndex: int("messageIndex"),
|
||
|
||
status: mysqlEnum("status", ["pending", "success", "error"]).default("pending"),
|
||
|
||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||
}, (table) => ({
|
||
agentIdIdx: index("agentHistory_agentId_idx").on(table.agentId),
|
||
}));
|
||
|
||
export type AgentHistory = typeof agentHistory.$inferSelect;
|
||
export type InsertAgentHistory = typeof agentHistory.$inferInsert;
|
||
|
||
/**
|
||
* Agent Access Control — управление доступами
|
||
*/
|
||
export const agentAccessControl = mysqlTable("agentAccessControl", {
|
||
id: int("id").autoincrement().primaryKey(),
|
||
agentId: int("agentId").notNull(),
|
||
|
||
tool: varchar("tool", { length: 50 }).notNull(),
|
||
|
||
isAllowed: boolean("isAllowed").default(true),
|
||
|
||
maxExecutionsPerHour: int("maxExecutionsPerHour").default(100),
|
||
timeoutSeconds: int("timeoutSeconds").default(30),
|
||
|
||
allowedPatterns: json("allowedPatterns").$type<string[]>().default([]),
|
||
blockedPatterns: json("blockedPatterns").$type<string[]>().default([]),
|
||
|
||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||
}, (table) => ({
|
||
agentIdToolIdx: index("agentAccessControl_agentId_tool_idx").on(table.agentId, table.tool),
|
||
}));
|
||
|
||
export type AgentAccessControl = typeof agentAccessControl.$inferSelect;
|
||
export type InsertAgentAccessControl = typeof agentAccessControl.$inferInsert;
|
||
/**
|
||
* Tool Definitions — пользовательские инструменты, созданные Tool Builder Agent
|
||
*/
|
||
export const toolDefinitions = mysqlTable("toolDefinitions", {
|
||
id: int("id").autoincrement().primaryKey(),
|
||
toolId: varchar("toolId", { length: 100 }).notNull().unique(),
|
||
name: varchar("name", { length: 255 }).notNull(),
|
||
description: text("description").notNull(),
|
||
category: varchar("category", { length: 50 }).notNull().default("custom"),
|
||
dangerous: boolean("dangerous").default(false),
|
||
parameters: json("parameters").$type<Record<string, { type: string; description: string; required?: boolean }>>(),
|
||
implementation: text("implementation").notNull(), // JS код функции
|
||
isActive: boolean("isActive").default(true),
|
||
createdBy: int("createdBy"), // agentId или null
|
||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||
});
|
||
|
||
export type ToolDefinition = typeof toolDefinitions.$inferSelect;
|
||
export type InsertToolDefinition = typeof toolDefinitions.$inferInsert;
|
||
|
||
/**
|
||
* Browser Sessions — активные сессии браузера для Browser Agent
|
||
*/
|
||
export const browserSessions = mysqlTable("browserSessions", {
|
||
id: int("id").autoincrement().primaryKey(),
|
||
sessionId: varchar("sessionId", { length: 64 }).notNull().unique(),
|
||
agentId: int("agentId").notNull(),
|
||
currentUrl: text("currentUrl"),
|
||
title: text("title"),
|
||
status: mysqlEnum("status", ["active", "idle", "closed", "error"]).default("idle"),
|
||
screenshotUrl: text("screenshotUrl"), // S3 URL последнего скриншота
|
||
lastActionAt: timestamp("lastActionAt").defaultNow(),
|
||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||
closedAt: timestamp("closedAt"),
|
||
});
|
||
|
||
export type BrowserSession = typeof browserSessions.$inferSelect;
|
||
export type InsertBrowserSession = typeof browserSessions.$inferInsert;
|
||
|
||
/**
|
||
* LLM Providers — хранение конфигурации подключений к LLM API.
|
||
* API-ключи хранятся в зашифрованном виде (AES-256-GCM через crypto.ts).
|
||
* Активный провайдер читается gateway при каждом запросе.
|
||
*/
|
||
export const llmProviders = mysqlTable("llmProviders", {
|
||
id: int("id").autoincrement().primaryKey(),
|
||
name: varchar("name", { length: 128 }).notNull(), // "Ollama Cloud", "OpenAI", etc.
|
||
baseUrl: varchar("baseUrl", { length: 512 }).notNull(), // https://ollama.com/v1
|
||
apiKeyEncrypted: text("apiKeyEncrypted"), // AES-256-GCM encrypted key
|
||
apiKeyHint: varchar("apiKeyHint", { length: 16 }), // First 8 chars for display
|
||
isActive: boolean("isActive").default(false).notNull(), // Only one can be active
|
||
isDefault: boolean("isDefault").default(false).notNull(), // Default provider for new agents
|
||
modelDefault: varchar("modelDefault", { length: 128 }), // Default model for this provider
|
||
notes: text("notes"),
|
||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||
});
|
||
|
||
export type LlmProvider = typeof llmProviders.$inferSelect;
|
||
export type InsertLlmProvider = typeof llmProviders.$inferInsert;
|
||
|
||
/**
|
||
* Chat Sessions — persistent server-side chat runs.
|
||
* Each user message creates one session. The Go gateway processes it
|
||
* and writes events to chatEvents. The frontend polls for events.
|
||
*/
|
||
export const chatSessions = mysqlTable("chatSessions", {
|
||
id: int("id").autoincrement().primaryKey(),
|
||
sessionId: varchar("sessionId", { length: 64 }).notNull().unique(),
|
||
agentId: int("agentId").notNull().default(1),
|
||
status: mysqlEnum("status", ["running", "done", "error"]).notNull().default("running"),
|
||
userMessage: text("userMessage").notNull(),
|
||
finalResponse: text("finalResponse"),
|
||
model: varchar("model", { length: 128 }),
|
||
totalTokens: int("totalTokens").default(0),
|
||
processingTimeMs: int("processingTimeMs").default(0),
|
||
errorMessage: text("errorMessage"),
|
||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||
}, (table) => ({
|
||
statusIdx: index("chatSessions_status_idx").on(table.status),
|
||
createdAtIdx: index("chatSessions_createdAt_idx").on(table.createdAt),
|
||
}));
|
||
|
||
export type ChatSession = typeof chatSessions.$inferSelect;
|
||
export type InsertChatSession = typeof chatSessions.$inferInsert;
|
||
|
||
/**
|
||
* Chat Events — individual SSE events written by Go gateway, read by frontend.
|
||
*/
|
||
export const chatEvents = mysqlTable("chatEvents", {
|
||
id: int("id").autoincrement().primaryKey(),
|
||
sessionId: varchar("sessionId", { length: 64 }).notNull(),
|
||
seq: int("seq").notNull().default(0),
|
||
eventType: mysqlEnum("eventType", ["thinking", "tool_call", "delta", "done", "error"]).notNull(),
|
||
content: text("content"),
|
||
toolName: varchar("toolName", { length: 128 }),
|
||
toolArgs: json("toolArgs"),
|
||
toolResult: text("toolResult"),
|
||
toolSuccess: boolean("toolSuccess"),
|
||
durationMs: int("durationMs"),
|
||
model: varchar("model", { length: 128 }),
|
||
usageJson: json("usageJson"),
|
||
errorMsg: text("errorMsg"),
|
||
createdAt: timestamp("createdAt", { fsp: 3 }).defaultNow().notNull(),
|
||
}, (table) => ({
|
||
sessionSeqIdx: index("chatEvents_sessionId_seq_idx").on(table.sessionId, table.seq),
|
||
}));
|
||
|
||
export type ChatEvent = typeof chatEvents.$inferSelect;
|
||
export type InsertChatEvent = typeof chatEvents.$inferInsert;
|