From 8ac611ee0418efab98912ed901b83b10d786d602 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Tue, 7 Apr 2026 14:07:42 -0500 Subject: [PATCH] Add SME chat storage and workspace UI - Add persistence for SME conversations, messages, and knowledge docs - Wire server chat service and websocket events into the app - Add the web SME chat route, sidebar entry, and supporting panels --- .../persistence/Layers/SmeConversations.ts | 143 ++++++ .../Layers/SmeKnowledgeDocuments.ts | 162 +++++++ .../src/persistence/Layers/SmeMessages.ts | 106 +++++ apps/server/src/persistence/Migrations.ts | 2 + .../Migrations/019_SmeKnowledgeBase.ts | 61 +++ .../persistence/Services/SmeConversations.ts | 56 +++ .../Services/SmeKnowledgeDocuments.ts | 60 +++ .../src/persistence/Services/SmeMessages.ts | 48 ++ apps/server/src/serverLayers.ts | 11 + .../src/sme/Layers/SmeChatServiceLive.ts | 422 ++++++++++++++++++ .../server/src/sme/Services/SmeChatService.ts | 78 ++++ apps/server/src/wsServer.ts | 64 ++- apps/web/src/components/Sidebar.tsx | 16 +- apps/web/src/components/sme/SmeChatShell.tsx | 99 ++++ .../src/components/sme/SmeChatWorkspace.tsx | 174 ++++++++ .../components/sme/SmeConversationRail.tsx | 124 +++++ .../src/components/sme/SmeKnowledgePanel.tsx | 161 +++++++ .../src/components/sme/SmeMessageBubble.tsx | 43 ++ apps/web/src/routeTree.gen.ts | 21 + apps/web/src/routes/_chat.sme-chat.tsx | 28 ++ apps/web/src/smeStore.ts | 126 ++++++ apps/web/src/wsNativeApi.ts | 31 ++ packages/contracts/src/baseSchemas.ts | 7 + packages/contracts/src/index.ts | 1 + packages/contracts/src/ipc.ts | 31 ++ packages/contracts/src/sme.ts | 172 +++++++ packages/contracts/src/ws.ts | 44 ++ 27 files changed, 2284 insertions(+), 7 deletions(-) create mode 100644 apps/server/src/persistence/Layers/SmeConversations.ts create mode 100644 apps/server/src/persistence/Layers/SmeKnowledgeDocuments.ts create mode 100644 apps/server/src/persistence/Layers/SmeMessages.ts create mode 100644 apps/server/src/persistence/Migrations/019_SmeKnowledgeBase.ts create mode 100644 apps/server/src/persistence/Services/SmeConversations.ts create mode 100644 apps/server/src/persistence/Services/SmeKnowledgeDocuments.ts create mode 100644 apps/server/src/persistence/Services/SmeMessages.ts create mode 100644 apps/server/src/sme/Layers/SmeChatServiceLive.ts create mode 100644 apps/server/src/sme/Services/SmeChatService.ts create mode 100644 apps/web/src/components/sme/SmeChatShell.tsx create mode 100644 apps/web/src/components/sme/SmeChatWorkspace.tsx create mode 100644 apps/web/src/components/sme/SmeConversationRail.tsx create mode 100644 apps/web/src/components/sme/SmeKnowledgePanel.tsx create mode 100644 apps/web/src/components/sme/SmeMessageBubble.tsx create mode 100644 apps/web/src/routes/_chat.sme-chat.tsx create mode 100644 apps/web/src/smeStore.ts create mode 100644 packages/contracts/src/sme.ts diff --git a/apps/server/src/persistence/Layers/SmeConversations.ts b/apps/server/src/persistence/Layers/SmeConversations.ts new file mode 100644 index 000000000..0fd5c5eb4 --- /dev/null +++ b/apps/server/src/persistence/Layers/SmeConversations.ts @@ -0,0 +1,143 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; +import { Effect, Layer, Option, Schema } from "effect"; + +import { toPersistenceDecodeError, toPersistenceSqlError } from "../Errors.ts"; + +import { + DeleteSmeConversationInput, + GetSmeConversationInput, + ListSmeConversationsByProjectInput, + SmeConversationRepository, + SmeConversationRow, + type SmeConversationRepositoryShape, +} from "../Services/SmeConversations.ts"; + +function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { + return (cause: unknown) => + Schema.isSchemaError(cause) + ? toPersistenceDecodeError(decodeOperation)(cause) + : toPersistenceSqlError(sqlOperation)(cause); +} + +const makeSmeConversationRepository = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertRow = SqlSchema.void({ + Request: SmeConversationRow, + execute: (row) => + sql` + INSERT INTO sme_conversations ( + conversation_id, project_id, title, model, + created_at, updated_at, deleted_at + ) + VALUES ( + ${row.conversationId}, ${row.projectId}, ${row.title}, ${row.model}, + ${row.createdAt}, ${row.updatedAt}, ${row.deletedAt} + ) + ON CONFLICT (conversation_id) + DO UPDATE SET + title = excluded.title, + model = excluded.model, + updated_at = excluded.updated_at, + deleted_at = excluded.deleted_at + `, + }); + + const getRow = SqlSchema.findOneOption({ + Request: GetSmeConversationInput, + Result: SmeConversationRow, + execute: ({ conversationId }) => + sql` + SELECT + conversation_id AS "conversationId", + project_id AS "projectId", + title, + model, + created_at AS "createdAt", + updated_at AS "updatedAt", + deleted_at AS "deletedAt" + FROM sme_conversations + WHERE conversation_id = ${conversationId} + `, + }); + + const listRows = SqlSchema.findAll({ + Request: ListSmeConversationsByProjectInput, + Result: SmeConversationRow, + execute: ({ projectId }) => + sql` + SELECT + conversation_id AS "conversationId", + project_id AS "projectId", + title, + model, + created_at AS "createdAt", + updated_at AS "updatedAt", + deleted_at AS "deletedAt" + FROM sme_conversations + WHERE project_id = ${projectId} AND deleted_at IS NULL + ORDER BY updated_at DESC + `, + }); + + const deleteRow = SqlSchema.void({ + Request: DeleteSmeConversationInput, + execute: ({ conversationId }) => + sql` + UPDATE sme_conversations + SET deleted_at = datetime('now') + WHERE conversation_id = ${conversationId} + `, + }); + + const upsert: SmeConversationRepositoryShape["upsert"] = (row) => + upsertRow(row).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "SmeConversationRepository.upsert:query", + "SmeConversationRepository.upsert:encodeRequest", + ), + ), + ); + + const getById: SmeConversationRepositoryShape["getById"] = (input) => + getRow(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "SmeConversationRepository.getById:query", + "SmeConversationRepository.getById:decodeRow", + ), + ), + Effect.flatMap((rowOption) => + Option.match(rowOption, { + onNone: () => Effect.succeed(Option.none()), + onSome: (row) => + Effect.succeed(Option.some(row as Schema.Schema.Type)), + }), + ), + ); + + const listByProjectId: SmeConversationRepositoryShape["listByProjectId"] = (input) => + listRows(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "SmeConversationRepository.listByProjectId:query", + "SmeConversationRepository.listByProjectId:decodeRows", + ), + ), + Effect.map((rows) => rows as ReadonlyArray>), + ); + + const deleteById: SmeConversationRepositoryShape["deleteById"] = (input) => + deleteRow(input).pipe( + Effect.mapError(toPersistenceSqlError("SmeConversationRepository.deleteById:query")), + ); + + return { upsert, getById, listByProjectId, deleteById } satisfies SmeConversationRepositoryShape; +}); + +export const SmeConversationRepositoryLive = Layer.effect( + SmeConversationRepository, + makeSmeConversationRepository, +); diff --git a/apps/server/src/persistence/Layers/SmeKnowledgeDocuments.ts b/apps/server/src/persistence/Layers/SmeKnowledgeDocuments.ts new file mode 100644 index 000000000..4eed810e8 --- /dev/null +++ b/apps/server/src/persistence/Layers/SmeKnowledgeDocuments.ts @@ -0,0 +1,162 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; +import { Effect, Layer, Option, Schema } from "effect"; + +import { toPersistenceDecodeError, toPersistenceSqlError } from "../Errors.ts"; + +import { + DeleteSmeDocumentInput, + GetSmeDocumentInput, + ListSmeDocumentsByProjectInput, + SmeKnowledgeDocumentRepository, + SmeKnowledgeDocumentRow, + type SmeKnowledgeDocumentRepositoryShape, +} from "../Services/SmeKnowledgeDocuments.ts"; + +function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { + return (cause: unknown) => + Schema.isSchemaError(cause) + ? toPersistenceDecodeError(decodeOperation)(cause) + : toPersistenceSqlError(sqlOperation)(cause); +} + +const makeSmeKnowledgeDocumentRepository = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertRow = SqlSchema.void({ + Request: SmeKnowledgeDocumentRow, + execute: (row) => + sql` + INSERT INTO sme_knowledge_documents ( + document_id, project_id, title, file_name, mime_type, + size_bytes, content_text, content_hash, created_at, updated_at, deleted_at + ) + VALUES ( + ${row.documentId}, ${row.projectId}, ${row.title}, ${row.fileName}, ${row.mimeType}, + ${row.sizeBytes}, ${row.contentText}, ${row.contentHash}, ${row.createdAt}, ${row.updatedAt}, ${row.deletedAt} + ) + ON CONFLICT (document_id) + DO UPDATE SET + title = excluded.title, + file_name = excluded.file_name, + mime_type = excluded.mime_type, + size_bytes = excluded.size_bytes, + content_text = excluded.content_text, + content_hash = excluded.content_hash, + updated_at = excluded.updated_at, + deleted_at = excluded.deleted_at + `, + }); + + const getRow = SqlSchema.findOneOption({ + Request: GetSmeDocumentInput, + Result: SmeKnowledgeDocumentRow, + execute: ({ documentId }) => + sql` + SELECT + document_id AS "documentId", + project_id AS "projectId", + title, + file_name AS "fileName", + mime_type AS "mimeType", + size_bytes AS "sizeBytes", + content_text AS "contentText", + content_hash AS "contentHash", + created_at AS "createdAt", + updated_at AS "updatedAt", + deleted_at AS "deletedAt" + FROM sme_knowledge_documents + WHERE document_id = ${documentId} + `, + }); + + const listRows = SqlSchema.findAll({ + Request: ListSmeDocumentsByProjectInput, + Result: SmeKnowledgeDocumentRow, + execute: ({ projectId }) => + sql` + SELECT + document_id AS "documentId", + project_id AS "projectId", + title, + file_name AS "fileName", + mime_type AS "mimeType", + size_bytes AS "sizeBytes", + content_text AS "contentText", + content_hash AS "contentHash", + created_at AS "createdAt", + updated_at AS "updatedAt", + deleted_at AS "deletedAt" + FROM sme_knowledge_documents + WHERE project_id = ${projectId} AND deleted_at IS NULL + ORDER BY created_at ASC + `, + }); + + const deleteRow = SqlSchema.void({ + Request: DeleteSmeDocumentInput, + execute: ({ documentId }) => + sql` + UPDATE sme_knowledge_documents + SET deleted_at = datetime('now') + WHERE document_id = ${documentId} + `, + }); + + const upsert: SmeKnowledgeDocumentRepositoryShape["upsert"] = (row) => + upsertRow(row).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "SmeKnowledgeDocumentRepository.upsert:query", + "SmeKnowledgeDocumentRepository.upsert:encodeRequest", + ), + ), + ); + + const getById: SmeKnowledgeDocumentRepositoryShape["getById"] = (input) => + getRow(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "SmeKnowledgeDocumentRepository.getById:query", + "SmeKnowledgeDocumentRepository.getById:decodeRow", + ), + ), + Effect.flatMap((rowOption) => + Option.match(rowOption, { + onNone: () => Effect.succeed(Option.none()), + onSome: (row) => + Effect.succeed(Option.some(row as Schema.Schema.Type)), + }), + ), + ); + + const listByProjectId: SmeKnowledgeDocumentRepositoryShape["listByProjectId"] = (input) => + listRows(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "SmeKnowledgeDocumentRepository.listByProjectId:query", + "SmeKnowledgeDocumentRepository.listByProjectId:decodeRows", + ), + ), + Effect.map( + (rows) => rows as ReadonlyArray>, + ), + ); + + const deleteById: SmeKnowledgeDocumentRepositoryShape["deleteById"] = (input) => + deleteRow(input).pipe( + Effect.mapError(toPersistenceSqlError("SmeKnowledgeDocumentRepository.deleteById:query")), + ); + + return { + upsert, + getById, + listByProjectId, + deleteById, + } satisfies SmeKnowledgeDocumentRepositoryShape; +}); + +export const SmeKnowledgeDocumentRepositoryLive = Layer.effect( + SmeKnowledgeDocumentRepository, + makeSmeKnowledgeDocumentRepository, +); diff --git a/apps/server/src/persistence/Layers/SmeMessages.ts b/apps/server/src/persistence/Layers/SmeMessages.ts new file mode 100644 index 000000000..67d039147 --- /dev/null +++ b/apps/server/src/persistence/Layers/SmeMessages.ts @@ -0,0 +1,106 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; +import { Effect, Layer, Schema } from "effect"; + +import { toPersistenceDecodeError, toPersistenceSqlError } from "../Errors.ts"; + +import { + DeleteSmeMessagesByConversationInput, + ListSmeMessagesByConversationInput, + SmeMessageRepository, + SmeMessageRow, + type SmeMessageRepositoryShape, +} from "../Services/SmeMessages.ts"; + +function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { + return (cause: unknown) => + Schema.isSchemaError(cause) + ? toPersistenceDecodeError(decodeOperation)(cause) + : toPersistenceSqlError(sqlOperation)(cause); +} + +/** + * DB row schema: isStreaming stored as INTEGER 0/1, mapped to/from boolean. + */ +const SmeMessageDbRow = Schema.Struct({ + messageId: SmeMessageRow.fields.messageId, + conversationId: SmeMessageRow.fields.conversationId, + role: SmeMessageRow.fields.role, + text: SmeMessageRow.fields.text, + isStreaming: Schema.Number, + createdAt: SmeMessageRow.fields.createdAt, + updatedAt: SmeMessageRow.fields.updatedAt, +}); + +const makeSmeMessageRepository = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsert: SmeMessageRepositoryShape["upsert"] = (row) => + Effect.gen(function* () { + const isStreamingInt = row.isStreaming ? 1 : 0; + yield* sql` + INSERT INTO sme_messages ( + message_id, conversation_id, role, text, + is_streaming, created_at, updated_at + ) + VALUES ( + ${row.messageId}, ${row.conversationId}, ${row.role}, ${row.text}, + ${isStreamingInt}, ${row.createdAt}, ${row.updatedAt} + ) + ON CONFLICT (message_id) + DO UPDATE SET + text = excluded.text, + is_streaming = excluded.is_streaming, + updated_at = excluded.updated_at + `; + }).pipe(Effect.mapError(toPersistenceSqlError("SmeMessageRepository.upsert:query"))); + + const listByConversationId: SmeMessageRepositoryShape["listByConversationId"] = (input) => + Effect.gen(function* () { + const rows = yield* sql` + SELECT + message_id AS "messageId", + conversation_id AS "conversationId", + role, + text, + is_streaming AS "isStreaming", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM sme_messages + WHERE conversation_id = ${input.conversationId} + ORDER BY created_at ASC + `; + return rows.map((r: any) => ({ + messageId: r.messageId, + conversationId: r.conversationId, + role: r.role, + text: r.text, + isStreaming: r.isStreaming !== 0, + createdAt: r.createdAt, + updatedAt: r.updatedAt, + })) as any; + }).pipe( + Effect.mapError(toPersistenceSqlError("SmeMessageRepository.listByConversationId:query")), + ); + + const deleteByConversationId: SmeMessageRepositoryShape["deleteByConversationId"] = (input) => + Effect.gen(function* () { + yield* sql` + DELETE FROM sme_messages + WHERE conversation_id = ${input.conversationId} + `; + }).pipe( + Effect.mapError(toPersistenceSqlError("SmeMessageRepository.deleteByConversationId:query")), + ); + + return { + upsert, + listByConversationId, + deleteByConversationId, + } satisfies SmeMessageRepositoryShape; +}); + +export const SmeMessageRepositoryLive = Layer.effect( + SmeMessageRepository, + makeSmeMessageRepository, +); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 003477ab2..ddc301790 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -30,6 +30,7 @@ import Migration0015 from "./Migrations/015_ProjectionTurnsSourceProposedPlan.ts import Migration0016 from "./Migrations/016_ProjectionThreadsInteractionModeChatCodePlan.ts"; import Migration0017 from "./Migrations/017_EnvironmentVariables.ts"; import Migration0018 from "./Migrations/018_ProjectionThreadsGithubRef.ts"; +import Migration0019 from "./Migrations/019_SmeKnowledgeBase.ts"; import { Effect } from "effect"; /** @@ -61,6 +62,7 @@ const loader = Migrator.fromRecord({ "16_ProjectionThreadsInteractionModeChatCodePlan": Migration0016, "17_EnvironmentVariables": Migration0017, "18_ProjectionThreadsGithubRef": Migration0018, + "19_SmeKnowledgeBase": Migration0019, }); /** diff --git a/apps/server/src/persistence/Migrations/019_SmeKnowledgeBase.ts b/apps/server/src/persistence/Migrations/019_SmeKnowledgeBase.ts new file mode 100644 index 000000000..83a8f20ea --- /dev/null +++ b/apps/server/src/persistence/Migrations/019_SmeKnowledgeBase.ts @@ -0,0 +1,61 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE IF NOT EXISTS sme_knowledge_documents ( + document_id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + title TEXT NOT NULL, + file_name TEXT NOT NULL, + mime_type TEXT NOT NULL, + size_bytes INTEGER NOT NULL, + content_text TEXT NOT NULL, + content_hash TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + deleted_at TEXT + ) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_sme_knowledge_docs_project + ON sme_knowledge_documents(project_id, deleted_at) + `; + + yield* sql` + CREATE TABLE IF NOT EXISTS sme_conversations ( + conversation_id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + title TEXT NOT NULL, + model TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + deleted_at TEXT + ) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_sme_conversations_project + ON sme_conversations(project_id, deleted_at) + `; + + yield* sql` + CREATE TABLE IF NOT EXISTS sme_messages ( + message_id TEXT PRIMARY KEY, + conversation_id TEXT NOT NULL, + role TEXT NOT NULL, + text TEXT NOT NULL, + is_streaming INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_sme_messages_conversation_created + ON sme_messages(conversation_id, created_at) + `; +}); diff --git a/apps/server/src/persistence/Services/SmeConversations.ts b/apps/server/src/persistence/Services/SmeConversations.ts new file mode 100644 index 000000000..7608c721f --- /dev/null +++ b/apps/server/src/persistence/Services/SmeConversations.ts @@ -0,0 +1,56 @@ +/** + * SmeConversationRepository - Repository interface for SME conversations. + * + * Owns persistence operations for SME chat conversations scoped to a project. + * + * @module SmeConversationRepository + */ +import { IsoDateTime, ProjectId, SmeConversationId } from "@okcode/contracts"; +import { Option, Schema, ServiceMap } from "effect"; +import type { Effect } from "effect"; + +import type { ProjectionRepositoryError } from "../Errors.ts"; + +export const SmeConversationRow = Schema.Struct({ + conversationId: SmeConversationId, + projectId: ProjectId, + title: Schema.String, + model: Schema.String, + createdAt: IsoDateTime, + updatedAt: IsoDateTime, + deletedAt: Schema.NullOr(IsoDateTime), +}); +export type SmeConversationRow = typeof SmeConversationRow.Type; + +export const GetSmeConversationInput = Schema.Struct({ + conversationId: SmeConversationId, +}); +export type GetSmeConversationInput = typeof GetSmeConversationInput.Type; + +export const ListSmeConversationsByProjectInput = Schema.Struct({ + projectId: ProjectId, +}); +export type ListSmeConversationsByProjectInput = typeof ListSmeConversationsByProjectInput.Type; + +export const DeleteSmeConversationInput = Schema.Struct({ + conversationId: SmeConversationId, +}); +export type DeleteSmeConversationInput = typeof DeleteSmeConversationInput.Type; + +export interface SmeConversationRepositoryShape { + readonly upsert: (row: SmeConversationRow) => Effect.Effect; + readonly getById: ( + input: GetSmeConversationInput, + ) => Effect.Effect, ProjectionRepositoryError>; + readonly listByProjectId: ( + input: ListSmeConversationsByProjectInput, + ) => Effect.Effect, ProjectionRepositoryError>; + readonly deleteById: ( + input: DeleteSmeConversationInput, + ) => Effect.Effect; +} + +export class SmeConversationRepository extends ServiceMap.Service< + SmeConversationRepository, + SmeConversationRepositoryShape +>()("okcode/persistence/Services/SmeConversations/SmeConversationRepository") {} diff --git a/apps/server/src/persistence/Services/SmeKnowledgeDocuments.ts b/apps/server/src/persistence/Services/SmeKnowledgeDocuments.ts new file mode 100644 index 000000000..ad32f0d31 --- /dev/null +++ b/apps/server/src/persistence/Services/SmeKnowledgeDocuments.ts @@ -0,0 +1,60 @@ +/** + * SmeKnowledgeDocumentRepository - Repository interface for SME knowledge documents. + * + * Owns persistence operations for documents uploaded to a project's knowledge base. + * + * @module SmeKnowledgeDocumentRepository + */ +import { IsoDateTime, ProjectId, SmeDocumentId } from "@okcode/contracts"; +import { Option, Schema, ServiceMap } from "effect"; +import type { Effect } from "effect"; + +import type { ProjectionRepositoryError } from "../Errors.ts"; + +export const SmeKnowledgeDocumentRow = Schema.Struct({ + documentId: SmeDocumentId, + projectId: ProjectId, + title: Schema.String, + fileName: Schema.String, + mimeType: Schema.String, + sizeBytes: Schema.Number, + contentText: Schema.String, + contentHash: Schema.String, + createdAt: IsoDateTime, + updatedAt: IsoDateTime, + deletedAt: Schema.NullOr(IsoDateTime), +}); +export type SmeKnowledgeDocumentRow = typeof SmeKnowledgeDocumentRow.Type; + +export const GetSmeDocumentInput = Schema.Struct({ + documentId: SmeDocumentId, +}); +export type GetSmeDocumentInput = typeof GetSmeDocumentInput.Type; + +export const ListSmeDocumentsByProjectInput = Schema.Struct({ + projectId: ProjectId, +}); +export type ListSmeDocumentsByProjectInput = typeof ListSmeDocumentsByProjectInput.Type; + +export const DeleteSmeDocumentInput = Schema.Struct({ + documentId: SmeDocumentId, +}); +export type DeleteSmeDocumentInput = typeof DeleteSmeDocumentInput.Type; + +export interface SmeKnowledgeDocumentRepositoryShape { + readonly upsert: (row: SmeKnowledgeDocumentRow) => Effect.Effect; + readonly getById: ( + input: GetSmeDocumentInput, + ) => Effect.Effect, ProjectionRepositoryError>; + readonly listByProjectId: ( + input: ListSmeDocumentsByProjectInput, + ) => Effect.Effect, ProjectionRepositoryError>; + readonly deleteById: ( + input: DeleteSmeDocumentInput, + ) => Effect.Effect; +} + +export class SmeKnowledgeDocumentRepository extends ServiceMap.Service< + SmeKnowledgeDocumentRepository, + SmeKnowledgeDocumentRepositoryShape +>()("okcode/persistence/Services/SmeKnowledgeDocuments/SmeKnowledgeDocumentRepository") {} diff --git a/apps/server/src/persistence/Services/SmeMessages.ts b/apps/server/src/persistence/Services/SmeMessages.ts new file mode 100644 index 000000000..750aeb365 --- /dev/null +++ b/apps/server/src/persistence/Services/SmeMessages.ts @@ -0,0 +1,48 @@ +/** + * SmeMessageRepository - Repository interface for SME chat messages. + * + * Owns persistence operations for messages within SME conversations. + * + * @module SmeMessageRepository + */ +import { IsoDateTime, SmeConversationId, SmeMessageId } from "@okcode/contracts"; +import { Schema, ServiceMap } from "effect"; +import type { Effect } from "effect"; + +import type { ProjectionRepositoryError } from "../Errors.ts"; + +export const SmeMessageRow = Schema.Struct({ + messageId: SmeMessageId, + conversationId: SmeConversationId, + role: Schema.String, + text: Schema.String, + isStreaming: Schema.Boolean, + createdAt: IsoDateTime, + updatedAt: IsoDateTime, +}); +export type SmeMessageRow = typeof SmeMessageRow.Type; + +export const ListSmeMessagesByConversationInput = Schema.Struct({ + conversationId: SmeConversationId, +}); +export type ListSmeMessagesByConversationInput = typeof ListSmeMessagesByConversationInput.Type; + +export const DeleteSmeMessagesByConversationInput = Schema.Struct({ + conversationId: SmeConversationId, +}); +export type DeleteSmeMessagesByConversationInput = typeof DeleteSmeMessagesByConversationInput.Type; + +export interface SmeMessageRepositoryShape { + readonly upsert: (row: SmeMessageRow) => Effect.Effect; + readonly listByConversationId: ( + input: ListSmeMessagesByConversationInput, + ) => Effect.Effect, ProjectionRepositoryError>; + readonly deleteByConversationId: ( + input: DeleteSmeMessagesByConversationInput, + ) => Effect.Effect; +} + +export class SmeMessageRepository extends ServiceMap.Service< + SmeMessageRepository, + SmeMessageRepositoryShape +>()("okcode/persistence/Services/SmeMessages/SmeMessageRepository") {} diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index fe47eb1c6..5ff19423a 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -41,6 +41,10 @@ import { MergeConflictResolverLive } from "./prReview/Layers/MergeConflictResolv import { PrReviewLive } from "./prReview/Layers/PrReview"; import { GitHubLive } from "./github/Layers/GitHub"; import { PtyAdapter } from "./terminal/Services/PTY"; +import { SmeKnowledgeDocumentRepositoryLive } from "./persistence/Layers/SmeKnowledgeDocuments"; +import { SmeConversationRepositoryLive } from "./persistence/Layers/SmeConversations"; +import { SmeMessageRepositoryLive } from "./persistence/Layers/SmeMessages"; +import { SmeChatServiceLive } from "./sme/Layers/SmeChatServiceLive"; type RuntimePtyAdapterLoader = { layer: Layer.Layer; @@ -160,6 +164,12 @@ export function makeServerRuntimeServicesLayer() { const githubLayer = GitHubLive.pipe(Layer.provideMerge(GitHubCliLive)); + const smeChatLayer = SmeChatServiceLive.pipe( + Layer.provide(SmeKnowledgeDocumentRepositoryLive), + Layer.provide(SmeConversationRepositoryLive), + Layer.provide(SmeMessageRepositoryLive), + ); + return Layer.mergeAll( orchestrationReactorLayer, GitCoreLive, @@ -169,5 +179,6 @@ export function makeServerRuntimeServicesLayer() { terminalLayer, KeybindingsLive, SkillServiceLive, + smeChatLayer, ).pipe(Layer.provideMerge(NodeServices.layer)); } diff --git a/apps/server/src/sme/Layers/SmeChatServiceLive.ts b/apps/server/src/sme/Layers/SmeChatServiceLive.ts new file mode 100644 index 000000000..90b83d504 --- /dev/null +++ b/apps/server/src/sme/Layers/SmeChatServiceLive.ts @@ -0,0 +1,422 @@ +/** + * SmeChatServiceLive - Live implementation for the SME chat service. + * + * Implements document management, conversation CRUD, and message sending + * using the Anthropic Messages API for streaming completions. + * + * @module SmeChatServiceLive + */ +import Anthropic from "@anthropic-ai/sdk"; +import type { + SmeConversation, + SmeKnowledgeDocument, + SmeMessage, + SmeMessageEvent, +} from "@okcode/contracts"; +import { + SME_MAX_DOCUMENT_SIZE_BYTES, + SME_MAX_DOCUMENTS_PER_PROJECT, + SME_MAX_CONVERSATIONS_PER_PROJECT, +} from "@okcode/contracts"; +import { DateTime, Effect, Fiber, Layer, Option, Random, Ref } from "effect"; +import crypto from "node:crypto"; + +import { SmeKnowledgeDocumentRepository } from "../../persistence/Services/SmeKnowledgeDocuments.ts"; +import { SmeConversationRepository } from "../../persistence/Services/SmeConversations.ts"; +import { SmeMessageRepository } from "../../persistence/Services/SmeMessages.ts"; +import { + SmeChatError, + SmeChatService, + type SmeChatServiceShape, +} from "../Services/SmeChatService.ts"; + +const makeSmeChatService = Effect.gen(function* () { + const documentRepo = yield* SmeKnowledgeDocumentRepository; + const conversationRepo = yield* SmeConversationRepository; + const messageRepo = yield* SmeMessageRepository; + + // Track active streaming fibers per conversation for interruption + const activeStreams = yield* Ref.make(new Map()); + + const generateId = () => + Effect.map( + Random.nextIntBetween(0, Number.MAX_SAFE_INTEGER), + (n) => `${Date.now().toString(36)}-${n.toString(36)}`, + ); + + const now = () => Effect.map(DateTime.now, (dt) => DateTime.formatIso(dt)); + + // ── Document Operations ───────────────────────────────────────────── + + const uploadDocument: SmeChatServiceShape["uploadDocument"] = (input) => + Effect.gen(function* () { + // Check document count limit + const existing = yield* documentRepo + .listByProjectId({ projectId: input.projectId }) + .pipe(Effect.mapError((e) => new SmeChatError("uploadDocument", e.message))); + if (existing.length >= SME_MAX_DOCUMENTS_PER_PROJECT) { + return yield* Effect.fail( + new SmeChatError( + "uploadDocument", + `Maximum ${SME_MAX_DOCUMENTS_PER_PROJECT} documents per project exceeded`, + ), + ); + } + + // Decode base64 content + const contentBuffer = Buffer.from(input.contentBase64, "base64"); + if (contentBuffer.byteLength > SME_MAX_DOCUMENT_SIZE_BYTES) { + return yield* Effect.fail( + new SmeChatError( + "uploadDocument", + `Document exceeds maximum size of ${SME_MAX_DOCUMENT_SIZE_BYTES} bytes`, + ), + ); + } + + const contentText = contentBuffer.toString("utf-8"); + const contentHash = crypto.createHash("sha256").update(contentText).digest("hex"); + + const documentId = yield* generateId(); + const timestamp = yield* now(); + + const row = { + documentId, + projectId: input.projectId, + title: input.title, + fileName: input.fileName, + mimeType: input.mimeType, + sizeBytes: contentBuffer.byteLength, + contentText, + contentHash, + createdAt: timestamp, + updatedAt: timestamp, + deletedAt: null, + }; + + yield* documentRepo + .upsert(row as any) + .pipe(Effect.mapError((e) => new SmeChatError("uploadDocument", e.message))); + + return { + documentId, + projectId: input.projectId, + title: input.title, + fileName: input.fileName, + mimeType: input.mimeType, + sizeBytes: contentBuffer.byteLength, + contentHash, + createdAt: timestamp, + updatedAt: timestamp, + deletedAt: null, + } as SmeKnowledgeDocument; + }); + + const deleteDocument: SmeChatServiceShape["deleteDocument"] = (input) => + documentRepo + .deleteById({ documentId: input.documentId }) + .pipe(Effect.mapError((e) => new SmeChatError("deleteDocument", e.message))); + + const listDocuments: SmeChatServiceShape["listDocuments"] = (input) => + documentRepo.listByProjectId({ projectId: input.projectId }).pipe( + Effect.mapError((e) => new SmeChatError("listDocuments", e.message)), + Effect.map((rows) => + rows.map( + (r) => + ({ + documentId: r.documentId, + projectId: r.projectId, + title: r.title, + fileName: r.fileName, + mimeType: r.mimeType, + sizeBytes: r.sizeBytes, + contentHash: r.contentHash, + createdAt: r.createdAt, + updatedAt: r.updatedAt, + deletedAt: r.deletedAt, + }) as SmeKnowledgeDocument, + ), + ), + ); + + // ── Conversation Operations ─────────────────────────────────────── + + const createConversation: SmeChatServiceShape["createConversation"] = (input) => + Effect.gen(function* () { + const existing = yield* conversationRepo + .listByProjectId({ projectId: input.projectId }) + .pipe(Effect.mapError((e) => new SmeChatError("createConversation", e.message))); + if (existing.length >= SME_MAX_CONVERSATIONS_PER_PROJECT) { + return yield* Effect.fail( + new SmeChatError( + "createConversation", + `Maximum ${SME_MAX_CONVERSATIONS_PER_PROJECT} conversations per project exceeded`, + ), + ); + } + + const conversationId = yield* generateId(); + const timestamp = yield* now(); + + const row = { + conversationId, + projectId: input.projectId, + title: input.title, + model: input.model, + createdAt: timestamp, + updatedAt: timestamp, + deletedAt: null, + }; + + yield* conversationRepo + .upsert(row as any) + .pipe(Effect.mapError((e) => new SmeChatError("createConversation", e.message))); + + return row as SmeConversation; + }); + + const deleteConversation: SmeChatServiceShape["deleteConversation"] = (input) => + Effect.gen(function* () { + yield* messageRepo + .deleteByConversationId({ conversationId: input.conversationId }) + .pipe(Effect.mapError((e) => new SmeChatError("deleteConversation", e.message))); + yield* conversationRepo + .deleteById({ conversationId: input.conversationId }) + .pipe(Effect.mapError((e) => new SmeChatError("deleteConversation", e.message))); + }); + + const listConversations: SmeChatServiceShape["listConversations"] = (input) => + conversationRepo.listByProjectId({ projectId: input.projectId }).pipe( + Effect.mapError((e) => new SmeChatError("listConversations", e.message)), + Effect.map((rows) => + rows.map( + (r) => + ({ + conversationId: r.conversationId, + projectId: r.projectId, + title: r.title, + model: r.model, + createdAt: r.createdAt, + updatedAt: r.updatedAt, + deletedAt: r.deletedAt, + }) as SmeConversation, + ), + ), + ); + + const getConversation: SmeChatServiceShape["getConversation"] = (input) => + Effect.gen(function* () { + const optConv = yield* conversationRepo + .getById({ conversationId: input.conversationId }) + .pipe(Effect.mapError((e) => new SmeChatError("getConversation", e.message))); + + if (Option.isNone(optConv)) return null; + const conv = optConv.value; + + const messages = yield* messageRepo + .listByConversationId({ conversationId: input.conversationId }) + .pipe(Effect.mapError((e) => new SmeChatError("getConversation", e.message))); + + return { + conversation: { + conversationId: conv.conversationId, + projectId: conv.projectId, + title: conv.title, + model: conv.model, + createdAt: conv.createdAt, + updatedAt: conv.updatedAt, + deletedAt: conv.deletedAt, + } as SmeConversation, + messages: messages.map( + (m) => + ({ + messageId: m.messageId, + conversationId: m.conversationId, + role: m.role, + text: m.text, + isStreaming: m.isStreaming, + createdAt: m.createdAt, + updatedAt: m.updatedAt, + }) as SmeMessage, + ), + }; + }); + + // ── Message Sending ─────────────────────────────────────────────── + + const sendMessage: SmeChatServiceShape["sendMessage"] = (input, onEvent) => + Effect.gen(function* () { + // 1. Resolve conversation + const optConv = yield* conversationRepo + .getById({ conversationId: input.conversationId }) + .pipe(Effect.mapError((e) => new SmeChatError("sendMessage", e.message))); + if (Option.isNone(optConv)) { + return yield* Effect.fail(new SmeChatError("sendMessage", "Conversation not found")); + } + const conv = optConv.value; + + // 2. Load knowledge documents + const docs = yield* documentRepo + .listByProjectId({ projectId: conv.projectId }) + .pipe(Effect.mapError((e) => new SmeChatError("sendMessage", e.message))); + + // 3. Load conversation history + const existingMessages = yield* messageRepo + .listByConversationId({ conversationId: input.conversationId }) + .pipe(Effect.mapError((e) => new SmeChatError("sendMessage", e.message))); + + // 4. Persist user message + const userMessageId = yield* generateId(); + const timestamp = yield* now(); + yield* messageRepo + .upsert({ + messageId: userMessageId, + conversationId: input.conversationId, + role: "user", + text: input.text, + isStreaming: false, + createdAt: timestamp, + updatedAt: timestamp, + } as any) + .pipe(Effect.mapError((e) => new SmeChatError("sendMessage", e.message))); + + // 5. Create assistant message placeholder + const assistantMessageId = yield* generateId(); + yield* messageRepo + .upsert({ + messageId: assistantMessageId, + conversationId: input.conversationId, + role: "assistant", + text: "", + isStreaming: true, + createdAt: timestamp, + updatedAt: timestamp, + } as any) + .pipe(Effect.mapError((e) => new SmeChatError("sendMessage", e.message))); + + // 6. Build messages array for the API + const systemPrompt = buildSystemPrompt(docs); + const apiMessages: Array<{ role: "user" | "assistant"; content: string }> = []; + for (const msg of existingMessages) { + if (msg.role === "user" || msg.role === "assistant") { + apiMessages.push({ role: msg.role as "user" | "assistant", content: msg.text }); + } + } + apiMessages.push({ role: "user", content: input.text }); + + // 7. Stream completion via Anthropic Messages API + const abortController = new AbortController(); + yield* Ref.update(activeStreams, (map) => { + const newMap = new Map(map); + newMap.set(input.conversationId, abortController); + return newMap; + }); + + const fullText = yield* Effect.tryPromise({ + try: async () => { + const anthropic = new Anthropic(); + let result = ""; + const stream = anthropic.messages.stream( + { + model: conv.model, + max_tokens: 8192, + system: systemPrompt, + messages: apiMessages, + }, + { signal: abortController.signal }, + ); + + for await (const event of stream) { + if (event.type === "content_block_delta" && event.delta.type === "text_delta") { + result += event.delta.text; + onEvent?.({ + type: "sme.message.delta", + conversationId: input.conversationId, + messageId: assistantMessageId, + text: event.delta.text, + } as any); + } + } + return result; + }, + catch: (err) => new SmeChatError("sendMessage:stream", String(err), err), + }).pipe( + Effect.ensuring( + Ref.update(activeStreams, (map) => { + const newMap = new Map(map); + newMap.delete(input.conversationId); + return newMap; + }), + ), + ); + + // 8. Finalize assistant message + const finalTimestamp = yield* now(); + yield* messageRepo + .upsert({ + messageId: assistantMessageId, + conversationId: input.conversationId, + role: "assistant", + text: fullText, + isStreaming: false, + createdAt: timestamp, + updatedAt: finalTimestamp, + } as any) + .pipe(Effect.mapError((e) => new SmeChatError("sendMessage:finalize", e.message))); + + // 9. Emit completion event + onEvent?.({ + type: "sme.message.complete", + conversationId: input.conversationId, + messageId: assistantMessageId, + text: fullText, + } as any); + }); + + const interruptMessage: SmeChatServiceShape["interruptMessage"] = (input) => + Effect.gen(function* () { + const streams = yield* Ref.get(activeStreams); + const controller = streams.get(input.conversationId); + if (controller) { + controller.abort(); + } + }); + + return { + uploadDocument, + deleteDocument, + listDocuments, + createConversation, + deleteConversation, + listConversations, + getConversation, + sendMessage, + interruptMessage, + } satisfies SmeChatServiceShape; +}); + +// ── Helpers ───────────────────────────────────────────────────────────── + +function buildSystemPrompt( + docs: ReadonlyArray<{ title: string; fileName: string; contentText: string }>, +): string { + const parts = [ + "You are a knowledgeable subject matter expert assistant. Your role is to provide clear, accurate, and helpful answers based on the reference documents provided and your general knowledge.", + "Focus on explanation, analysis, and guidance. Be conversational and thorough.", + ]; + + if (docs.length > 0) { + parts.push( + "\nThe following reference documents have been provided for this project. Use them to inform your answers when relevant:\n", + ); + for (const doc of docs) { + parts.push(``); + parts.push(doc.contentText); + parts.push("\n"); + } + } + + return parts.join("\n"); +} + +export const SmeChatServiceLive = Layer.effect(SmeChatService, makeSmeChatService); diff --git a/apps/server/src/sme/Services/SmeChatService.ts b/apps/server/src/sme/Services/SmeChatService.ts new file mode 100644 index 000000000..d33f83802 --- /dev/null +++ b/apps/server/src/sme/Services/SmeChatService.ts @@ -0,0 +1,78 @@ +/** + * SmeChatService - Service interface for SME chat operations. + * + * Provides document management, conversation CRUD, and message sending + * for the subject matter expert chat feature. + * + * @module SmeChatService + */ +import type { + SmeConversation, + SmeCreateConversationInput, + SmeDeleteConversationInput, + SmeDeleteDocumentInput, + SmeGetConversationInput, + SmeInterruptMessageInput, + SmeKnowledgeDocument, + SmeListConversationsInput, + SmeListDocumentsInput, + SmeMessage, + SmeMessageEvent, + SmeSendMessageInput, + SmeUploadDocumentInput, +} from "@okcode/contracts"; +import { ServiceMap } from "effect"; +import type { Effect } from "effect"; + +export class SmeChatError extends Error { + readonly _tag = "SmeChatError"; + constructor( + readonly operation: string, + readonly detail: string, + override readonly cause?: unknown, + ) { + super(`SmeChatError in ${operation}: ${detail}`); + } +} + +export interface SmeChatServiceShape { + readonly uploadDocument: ( + input: SmeUploadDocumentInput, + ) => Effect.Effect; + + readonly deleteDocument: (input: SmeDeleteDocumentInput) => Effect.Effect; + + readonly listDocuments: ( + input: SmeListDocumentsInput, + ) => Effect.Effect, SmeChatError>; + + readonly createConversation: ( + input: SmeCreateConversationInput, + ) => Effect.Effect; + + readonly deleteConversation: ( + input: SmeDeleteConversationInput, + ) => Effect.Effect; + + readonly listConversations: ( + input: SmeListConversationsInput, + ) => Effect.Effect, SmeChatError>; + + readonly getConversation: ( + input: SmeGetConversationInput, + ) => Effect.Effect< + { conversation: SmeConversation; messages: ReadonlyArray } | null, + SmeChatError + >; + + readonly sendMessage: ( + input: SmeSendMessageInput, + onEvent?: (event: SmeMessageEvent) => void, + ) => Effect.Effect; + + readonly interruptMessage: (input: SmeInterruptMessageInput) => Effect.Effect; +} + +export class SmeChatService extends ServiceMap.Service()( + "okcode/sme/Services/SmeChatService", +) {} diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 02569322f..daecc6f6c 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -23,6 +23,7 @@ import { PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, ProjectId, ThreadId, + SME_WS_CHANNELS, WS_CHANNELS, WS_METHODS, type WebSocketError, @@ -94,6 +95,7 @@ import { GitHub } from "./github/Services/GitHub.ts"; import { GitActionExecutionError } from "./git/Errors.ts"; import { EnvironmentVariables } from "./persistence/Services/EnvironmentVariables.ts"; import { SkillService } from "./skills/SkillService.ts"; +import { SmeChatService } from "./sme/Services/SmeChatService.ts"; import { TokenManager } from "./tokenManager.ts"; import { resolveRuntimeEnvironment, RuntimeEnv } from "./runtimeEnvironment.ts"; import { version as serverVersion } from "../package.json" with { type: "json" }; @@ -154,9 +156,9 @@ function testOpenclawGateway( clearTimeout(timeout); socket.off("message", handler); const payload: { result?: unknown; error?: { code: number; message: string } } = {}; - if ("result" in msg) payload.result = msg.result; - if (msg.error !== undefined) payload.error = msg.error; - resolve(payload); + if ("result" in msg) payload.result = msg.result; + if (msg.error !== undefined) payload.error = msg.error; + resolve(payload); } } catch { // Ignore non-JSON messages @@ -320,9 +322,9 @@ function testOpenclawGateway( const sessionId = typeof result.sessionId === "string" ? result.sessionId : undefined; const version = typeof result.version === "string" ? result.version : undefined; serverInfo = { - ...(version !== undefined ? { version } : {}), - ...(sessionId !== undefined ? { sessionId } : {}), - }; + ...(version !== undefined ? { version } : {}), + ...(sessionId !== undefined ? { sessionId } : {}), + }; pushStep( "Session create", "pass", @@ -553,6 +555,7 @@ export type ServerRuntimeServices = | TerminalManager | Keybindings | SkillService + | SmeChatService | Open | EnvironmentVariables; @@ -1008,6 +1011,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const { openInEditor, openInFileManager, revealInFileManager } = yield* Open; const environmentVariables = yield* EnvironmentVariables; const skillService = yield* SkillService; + const smeChatService = yield* SmeChatService; const subscriptionsScope = yield* Scope.make("sequential"); yield* Effect.addFinalizer(() => Scope.close(subscriptionsScope, Exit.void)); @@ -1846,6 +1850,54 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return yield* skillService.search(body); } + // ── SME Chat ──────────────────────────────────────────────────── + case WS_METHODS.smeUploadDocument: { + const body = stripRequestTag(request.body); + return yield* smeChatService.uploadDocument(body); + } + + case WS_METHODS.smeDeleteDocument: { + const body = stripRequestTag(request.body); + return yield* smeChatService.deleteDocument(body); + } + + case WS_METHODS.smeListDocuments: { + const body = stripRequestTag(request.body); + return yield* smeChatService.listDocuments(body); + } + + case WS_METHODS.smeCreateConversation: { + const body = stripRequestTag(request.body); + return yield* smeChatService.createConversation(body); + } + + case WS_METHODS.smeDeleteConversation: { + const body = stripRequestTag(request.body); + return yield* smeChatService.deleteConversation(body); + } + + case WS_METHODS.smeListConversations: { + const body = stripRequestTag(request.body); + return yield* smeChatService.listConversations(body); + } + + case WS_METHODS.smeGetConversation: { + const body = stripRequestTag(request.body); + return yield* smeChatService.getConversation(body); + } + + case WS_METHODS.smeSendMessage: { + const body = stripRequestTag(request.body); + return yield* smeChatService.sendMessage(body, (event) => { + void Effect.runPromise(pushBus.publishAll(SME_WS_CHANNELS.messageEvent, event)); + }); + } + + case WS_METHODS.smeInterruptMessage: { + const body = stripRequestTag(request.body); + return yield* smeChatService.interruptMessage(body); + } + default: { const _exhaustiveCheck: never = request.body; return yield* new RouteRequestError({ diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index be6cd32a0..52bd93da6 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -28,6 +28,7 @@ import { isNonEmpty as isNonEmptyString } from "effect/String"; import { ArrowLeftIcon, ArrowUpDownIcon, + BookOpenIcon, CheckCircleIcon, ChevronsDownUpIcon, ChevronsUpDownIcon, @@ -481,7 +482,10 @@ export default function Sidebar() { const navigate = useNavigate(); const pathname = useLocation({ select: (loc) => loc.pathname }); const isOnSubPage = - pathname === "/settings" || pathname === "/pr-review" || pathname === "/merge-conflicts"; + pathname === "/settings" || + pathname === "/pr-review" || + pathname === "/merge-conflicts" || + pathname === "/sme-chat"; const { settings: appSettings, updateSettings } = useAppSettings(); const { resolvedTheme } = useTheme(); const { toggleSidebar } = useSidebar(); @@ -2080,6 +2084,16 @@ export default function Sidebar() { Open Workspace + + void navigate({ to: "/sme-chat" })} + > + + SME Chat + + {hasWorktreeCleanupCandidates ? ( void; +} + +export function SmeChatShell({ + project, + projects, + selectedProjectId, + onProjectChange, +}: SmeChatShellProps) { + const [knowledgePanelOpen, setKnowledgePanelOpen] = useState(false); + const activeConversationId = useSmeStore((s) => s.activeConversationId); + const setConversations = useSmeStore((s) => s.setConversations); + const setDocuments = useSmeStore((s) => s.setDocuments); + const setActiveConversationId = useSmeStore((s) => s.setActiveConversationId); + const appendStreamDelta = useSmeStore((s) => s.appendStreamDelta); + const completeStream = useSmeStore((s) => s.completeStream); + const setMessages = useSmeStore((s) => s.setMessages); + + // Load conversations and documents when project changes + useEffect(() => { + const api = ensureNativeApi(); + void api.sme.listConversations({ projectId: project.id }).then((convs) => { + setConversations(convs as any[]); + }); + void api.sme.listDocuments({ projectId: project.id }).then((docs) => { + setDocuments(docs as any[]); + }); + // Reset active conversation when switching projects + setActiveConversationId(null); + }, [project.id, setConversations, setDocuments, setActiveConversationId]); + + // Load messages when active conversation changes + useEffect(() => { + if (!activeConversationId) return; + const api = ensureNativeApi(); + void api.sme + .getConversation({ conversationId: activeConversationId as SmeConversationId }) + .then((result) => { + if (result) { + setMessages(activeConversationId, result.messages as any[]); + } + }); + }, [activeConversationId, setMessages]); + + // Subscribe to SME push events + useEffect(() => { + const api = ensureNativeApi(); + const unsubscribe = api.sme.onMessageEvent((event: SmeMessageEvent) => { + if (event.type === "sme.message.delta") { + appendStreamDelta(event.messageId, event.text); + } else if (event.type === "sme.message.complete") { + completeStream(event.messageId, event.text); + } + }); + return unsubscribe; + }, [appendStreamDelta, completeStream]); + + return ( +
+ {/* Left rail - conversation list */} + + + {/* Center - chat workspace */} +
+ setKnowledgePanelOpen((v) => !v)} + knowledgePanelOpen={knowledgePanelOpen} + /> +
+ + {/* Right panel - knowledge base */} + {knowledgePanelOpen ? ( + setKnowledgePanelOpen(false)} /> + ) : null} +
+ ); +} diff --git a/apps/web/src/components/sme/SmeChatWorkspace.tsx b/apps/web/src/components/sme/SmeChatWorkspace.tsx new file mode 100644 index 000000000..c0d108180 --- /dev/null +++ b/apps/web/src/components/sme/SmeChatWorkspace.tsx @@ -0,0 +1,174 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { BookOpenIcon, SendIcon } from "lucide-react"; +import type { SmeConversationId, SmeMessage, SmeMessageId } from "@okcode/contracts"; + +import type { Project } from "~/types"; +import { ensureNativeApi } from "~/nativeApi"; +import { useSmeStore } from "~/smeStore"; + +import { SmeMessageBubble } from "./SmeMessageBubble"; + +interface SmeChatWorkspaceProps { + project: Project; + conversationId: string | null; + onToggleKnowledge: () => void; + knowledgePanelOpen: boolean; +} + +export function SmeChatWorkspace({ + project, + conversationId, + onToggleKnowledge, + knowledgePanelOpen, +}: SmeChatWorkspaceProps) { + const messages = useSmeStore((s) => + conversationId ? (s.messagesByConversation[conversationId] ?? []) : [], + ); + const streamingMessageId = useSmeStore((s) => s.streamingMessageId); + const streamingText = useSmeStore((s) => s.streamingText); + const addUserMessage = useSmeStore((s) => s.addUserMessage); + const [inputText, setInputText] = useState(""); + const [sending, setSending] = useState(false); + const messagesEndRef = useRef(null); + const textareaRef = useRef(null); + + // Auto-scroll to bottom when messages change + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages, streamingText]); + + const handleSend = useCallback(async () => { + if (!conversationId || !inputText.trim() || sending) return; + + const text = inputText.trim(); + setInputText(""); + setSending(true); + + // Optimistically add user message + addUserMessage(conversationId, { + messageId: `temp-${Date.now()}` as SmeMessageId, + conversationId: conversationId as SmeConversationId, + role: "user", + text, + isStreaming: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } as SmeMessage); + + try { + const api = ensureNativeApi(); + await api.sme.sendMessage({ conversationId: conversationId as SmeConversationId, text }); + // After the send completes, re-fetch messages to get the final state + const result = await api.sme.getConversation({ + conversationId: conversationId as SmeConversationId, + }); + if (result) { + useSmeStore.getState().setMessages(conversationId, result.messages as any[]); + } + } finally { + setSending(false); + } + }, [conversationId, inputText, sending, addUserMessage]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + void handleSend(); + } + }, + [handleSend], + ); + + if (!conversationId) { + return ( +
+
+ +

+ Select a conversation or create a new one to start chatting +

+
+
+ ); + } + + return ( +
+ {/* Header */} +
+ Conversation + +
+ + {/* Messages */} +
+
+ {messages.map((msg) => ( + + ))} + {streamingText ? ( + + ) : null} + {sending && !streamingText ? ( +
+
+ + + +
+ Thinking... +
+ ) : null} +
+
+
+ + {/* Composer */} +
+
+