Compare commits
16 Commits
ci/portain
...
050e17b132
| Author | SHA1 | Date | |
|---|---|---|---|
| 050e17b132 | |||
| 29cc37f8df | |||
| 091fb54f77 | |||
| 939479ac7e | |||
| 9031509bbd | |||
| f11a005538 | |||
| 8484e060d7 | |||
| 673ca32d5a | |||
| a777f1f695 | |||
| d7d8c3c88d | |||
| aec8085f60 | |||
| 44da50d0b3 | |||
| 44fb402ef2 | |||
| f42c47e314 | |||
| 8069aeadb5 | |||
| 1f883c4c04 |
@@ -80,8 +80,8 @@
|
|||||||
"session_id": "sess-002",
|
"session_id": "sess-002",
|
||||||
"runtime": "unknown",
|
"runtime": "unknown",
|
||||||
"started_at": "2026-02-28T20:30:13Z",
|
"started_at": "2026-02-28T20:30:13Z",
|
||||||
"ended_at": "",
|
"ended_at": "2026-03-04T13:45:06Z",
|
||||||
"ended_reason": "",
|
"ended_reason": "completed",
|
||||||
"milestone_at_end": "",
|
"milestone_at_end": "",
|
||||||
"tasks_completed": [],
|
"tasks_completed": [],
|
||||||
"last_task_id": ""
|
"last_task_id": ""
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"session_id": "sess-002",
|
|
||||||
"runtime": "unknown",
|
|
||||||
"pid": 3178395,
|
|
||||||
"started_at": "2026-02-28T20:30:13Z",
|
|
||||||
"project_path": "/tmp/ms21-ui-001",
|
|
||||||
"milestone_id": ""
|
|
||||||
}
|
|
||||||
@@ -342,6 +342,7 @@ steps:
|
|||||||
|
|
||||||
deploy-swarm:
|
deploy-swarm:
|
||||||
image: alpine:3
|
image: alpine:3
|
||||||
|
failure: ignore
|
||||||
environment:
|
environment:
|
||||||
PORTAINER_URL:
|
PORTAINER_URL:
|
||||||
from_secret: portainer_url
|
from_secret: portainer_url
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
# Add this at the end of the file, replacing the deploy-swarm section
|
|
||||||
|
|
||||||
deploy-swarm:
|
|
||||||
image: alpine:3
|
|
||||||
environment:
|
|
||||||
SSH_PRIVATE_KEY:
|
|
||||||
from_secret: ssh_private_key
|
|
||||||
SSH_KNOWN_HOSTS:
|
|
||||||
from_secret: ssh_known_hosts
|
|
||||||
PORTAINER_URL:
|
|
||||||
from_secret: portainer_url
|
|
||||||
PORTAINER_API_KEY:
|
|
||||||
from_secret: portainer_api_key
|
|
||||||
commands:
|
|
||||||
- apk add --no-cache curl
|
|
||||||
- |
|
|
||||||
set -e
|
|
||||||
echo "🚀 Deploying via Portainer API..."
|
|
||||||
|
|
||||||
# Redeploy mosaic-stack (ID 121)
|
|
||||||
curl -sk -X POST \
|
|
||||||
-H "X-API-Key: $PORTAINER_API_KEY" \
|
|
||||||
"$PORTAINER_URL/api/stacks/121/git/redeploy" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"prune": false}' || \
|
|
||||||
|
|
||||||
# Fallback: Force service updates via SSH
|
|
||||||
echo "Trying SSH fallback..."
|
|
||||||
apk add --no-cache openssh-client
|
|
||||||
mkdir -p ~/.ssh
|
|
||||||
echo "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
|
|
||||||
chmod 600 ~/.ssh/known_hosts
|
|
||||||
echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
|
|
||||||
chmod 600 ~/.ssh/id_ed25519
|
|
||||||
|
|
||||||
ssh -o StrictHostKeyChecking=no localadmin@10.1.1.45 \
|
|
||||||
"docker service update --force mosaic_api && \
|
|
||||||
docker service update --force mosaic_web && \
|
|
||||||
docker service update --force mosaic_orchestrator && \
|
|
||||||
docker service update --force mosaic_coordinator && \
|
|
||||||
echo '✅ Services updated'"
|
|
||||||
when:
|
|
||||||
- branch: [main]
|
|
||||||
event: [push, manual, tag]
|
|
||||||
depends_on:
|
|
||||||
- link-packages
|
|
||||||
@@ -1703,3 +1703,39 @@ model UserAgentConfig {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model AgentTemplate {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String @unique // "jarvis", "builder", "medic"
|
||||||
|
displayName String // "Jarvis", "Builder", "Medic"
|
||||||
|
role String // "orchestrator" | "coding" | "monitoring"
|
||||||
|
personality String // SOUL.md content (markdown)
|
||||||
|
primaryModel String // "opus", "codex", "haiku"
|
||||||
|
fallbackModels Json @default("[]") // ["sonnet", "haiku"]
|
||||||
|
toolPermissions Json @default("[]") // ["exec", "read", "write", ...]
|
||||||
|
discordChannel String? // "jarvis", "builder", "medic-alerts"
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
isDefault Boolean @default(false) // Include in new user provisioning
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model UserAgent {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
templateId String? // null = custom agent
|
||||||
|
name String // "jarvis", "builder", "medic" or custom
|
||||||
|
displayName String
|
||||||
|
role String
|
||||||
|
personality String // User can customize
|
||||||
|
primaryModel String?
|
||||||
|
fallbackModels Json @default("[]")
|
||||||
|
toolPermissions Json @default("[]")
|
||||||
|
discordChannel String?
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([userId, name])
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|||||||
47
apps/api/src/agent-template/agent-template.controller.ts
Normal file
47
apps/api/src/agent-template/agent-template.controller.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Patch,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
UseGuards,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { AgentTemplateService } from "./agent-template.service";
|
||||||
|
import { CreateAgentTemplateDto } from "./dto/create-agent-template.dto";
|
||||||
|
import { UpdateAgentTemplateDto } from "./dto/update-agent-template.dto";
|
||||||
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
|
import { AdminGuard } from "../auth/guards/admin.guard";
|
||||||
|
|
||||||
|
@Controller("admin/agent-templates")
|
||||||
|
@UseGuards(AuthGuard, AdminGuard)
|
||||||
|
export class AgentTemplateController {
|
||||||
|
constructor(private readonly agentTemplateService: AgentTemplateService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
findAll() {
|
||||||
|
return this.agentTemplateService.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(":id")
|
||||||
|
findOne(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
|
return this.agentTemplateService.findOne(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
create(@Body() dto: CreateAgentTemplateDto) {
|
||||||
|
return this.agentTemplateService.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(":id")
|
||||||
|
update(@Param("id", ParseUUIDPipe) id: string, @Body() dto: UpdateAgentTemplateDto) {
|
||||||
|
return this.agentTemplateService.update(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(":id")
|
||||||
|
remove(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
|
return this.agentTemplateService.remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
apps/api/src/agent-template/agent-template.module.ts
Normal file
12
apps/api/src/agent-template/agent-template.module.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { AgentTemplateService } from "./agent-template.service";
|
||||||
|
import { AgentTemplateController } from "./agent-template.controller";
|
||||||
|
import { PrismaModule } from "../prisma/prisma.module";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule],
|
||||||
|
controllers: [AgentTemplateController],
|
||||||
|
providers: [AgentTemplateService],
|
||||||
|
exports: [AgentTemplateService],
|
||||||
|
})
|
||||||
|
export class AgentTemplateModule {}
|
||||||
57
apps/api/src/agent-template/agent-template.service.ts
Normal file
57
apps/api/src/agent-template/agent-template.service.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { Injectable, NotFoundException, ConflictException } from "@nestjs/common";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import { CreateAgentTemplateDto } from "./dto/create-agent-template.dto";
|
||||||
|
import { UpdateAgentTemplateDto } from "./dto/update-agent-template.dto";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AgentTemplateService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async findAll() {
|
||||||
|
return this.prisma.agentTemplate.findMany({
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(id: string) {
|
||||||
|
const template = await this.prisma.agentTemplate.findUnique({ where: { id } });
|
||||||
|
if (!template) throw new NotFoundException(`AgentTemplate ${id} not found`);
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByName(name: string) {
|
||||||
|
const template = await this.prisma.agentTemplate.findUnique({ where: { name } });
|
||||||
|
if (!template) throw new NotFoundException(`AgentTemplate "${name}" not found`);
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateAgentTemplateDto) {
|
||||||
|
const existing = await this.prisma.agentTemplate.findUnique({ where: { name: dto.name } });
|
||||||
|
if (existing) throw new ConflictException(`AgentTemplate "${dto.name}" already exists`);
|
||||||
|
|
||||||
|
return this.prisma.agentTemplate.create({
|
||||||
|
data: {
|
||||||
|
name: dto.name,
|
||||||
|
displayName: dto.displayName,
|
||||||
|
role: dto.role,
|
||||||
|
personality: dto.personality,
|
||||||
|
primaryModel: dto.primaryModel,
|
||||||
|
fallbackModels: dto.fallbackModels ?? ([] as string[]),
|
||||||
|
toolPermissions: dto.toolPermissions ?? ([] as string[]),
|
||||||
|
...(dto.discordChannel !== undefined && { discordChannel: dto.discordChannel }),
|
||||||
|
isActive: dto.isActive ?? true,
|
||||||
|
isDefault: dto.isDefault ?? false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateAgentTemplateDto) {
|
||||||
|
await this.findOne(id);
|
||||||
|
return this.prisma.agentTemplate.update({ where: { id }, data: dto });
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: string) {
|
||||||
|
await this.findOne(id);
|
||||||
|
return this.prisma.agentTemplate.delete({ where: { id } });
|
||||||
|
}
|
||||||
|
}
|
||||||
43
apps/api/src/agent-template/dto/create-agent-template.dto.ts
Normal file
43
apps/api/src/agent-template/dto/create-agent-template.dto.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { IsString, IsBoolean, IsOptional, IsArray, MinLength } from "class-validator";
|
||||||
|
|
||||||
|
export class CreateAgentTemplateDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
displayName!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
role!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
personality!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
primaryModel!: string;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@IsOptional()
|
||||||
|
fallbackModels?: string[];
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@IsOptional()
|
||||||
|
toolPermissions?: string[];
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
discordChannel?: string;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
isActive?: boolean;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
isDefault?: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { PartialType } from "@nestjs/mapped-types";
|
||||||
|
import { CreateAgentTemplateDto } from "./create-agent-template.dto";
|
||||||
|
|
||||||
|
export class UpdateAgentTemplateDto extends PartialType(CreateAgentTemplateDto) {}
|
||||||
@@ -48,6 +48,7 @@ import { TerminalModule } from "./terminal/terminal.module";
|
|||||||
import { PersonalitiesModule } from "./personalities/personalities.module";
|
import { PersonalitiesModule } from "./personalities/personalities.module";
|
||||||
import { WorkspacesModule } from "./workspaces/workspaces.module";
|
import { WorkspacesModule } from "./workspaces/workspaces.module";
|
||||||
import { AdminModule } from "./admin/admin.module";
|
import { AdminModule } from "./admin/admin.module";
|
||||||
|
import { AgentTemplateModule } from "./agent-template/agent-template.module";
|
||||||
import { TeamsModule } from "./teams/teams.module";
|
import { TeamsModule } from "./teams/teams.module";
|
||||||
import { ImportModule } from "./import/import.module";
|
import { ImportModule } from "./import/import.module";
|
||||||
import { ConversationArchiveModule } from "./conversation-archive/conversation-archive.module";
|
import { ConversationArchiveModule } from "./conversation-archive/conversation-archive.module";
|
||||||
@@ -129,6 +130,7 @@ import { OrchestratorModule } from "./orchestrator/orchestrator.module";
|
|||||||
PersonalitiesModule,
|
PersonalitiesModule,
|
||||||
WorkspacesModule,
|
WorkspacesModule,
|
||||||
AdminModule,
|
AdminModule,
|
||||||
|
AgentTemplateModule,
|
||||||
TeamsModule,
|
TeamsModule,
|
||||||
ImportModule,
|
ImportModule,
|
||||||
ConversationArchiveModule,
|
ConversationArchiveModule,
|
||||||
|
|||||||
@@ -342,6 +342,31 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Input Area */}
|
{/* Input Area */}
|
||||||
|
{!user && (
|
||||||
|
<div className="mx-4 mb-2 lg:mx-auto lg:max-w-4xl lg:px-8">
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center gap-2 rounded-lg border px-4 py-3 text-center"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "rgb(var(--surface-1))",
|
||||||
|
borderColor: "rgb(var(--border-default))",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4"
|
||||||
|
style={{ color: "rgb(var(--text-secondary))" }}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm" style={{ color: "rgb(var(--text-secondary))" }}>
|
||||||
|
Sign in to chat with Jarvis
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
className="sticky bottom-0 border-t"
|
className="sticky bottom-0 border-t"
|
||||||
style={{
|
style={{
|
||||||
@@ -352,7 +377,7 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
|||||||
<div className="mx-auto max-w-4xl px-4 py-4 lg:px-8">
|
<div className="mx-auto max-w-4xl px-4 py-4 lg:px-8">
|
||||||
<ChatInput
|
<ChatInput
|
||||||
onSend={handleSendMessage}
|
onSend={handleSendMessage}
|
||||||
disabled={isChatLoading}
|
disabled={isChatLoading || !user}
|
||||||
inputRef={inputRef}
|
inputRef={inputRef}
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
onStopStreaming={abortStream}
|
onStopStreaming={abortStream}
|
||||||
|
|||||||
@@ -55,8 +55,8 @@ export function ChatOverlay(): React.JSX.Element {
|
|||||||
onClick={open}
|
onClick={open}
|
||||||
className="fixed bottom-6 right-6 z-50 flex h-14 w-14 items-center justify-center rounded-full shadow-lg transition-all hover:scale-110 focus:outline-none focus:ring-2 focus:ring-offset-2 lg:bottom-8 lg:right-8"
|
className="fixed bottom-6 right-6 z-50 flex h-14 w-14 items-center justify-center rounded-full shadow-lg transition-all hover:scale-110 focus:outline-none focus:ring-2 focus:ring-offset-2 lg:bottom-8 lg:right-8"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "rgb(var(--accent-primary))",
|
backgroundColor: "var(--accent-primary, #10b981)",
|
||||||
color: "rgb(var(--text-on-accent))",
|
color: "var(--text-on-accent, #ffffff)",
|
||||||
}}
|
}}
|
||||||
aria-label="Open chat"
|
aria-label="Open chat"
|
||||||
title="Open Jarvis chat (Cmd+Shift+J)"
|
title="Open Jarvis chat (Cmd+Shift+J)"
|
||||||
@@ -78,18 +78,18 @@ export function ChatOverlay(): React.JSX.Element {
|
|||||||
if (isMinimized) {
|
if (isMinimized) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed bottom-0 right-0 z-40 w-full sm:w-96"
|
className="fixed bottom-0 right-0 z-40 w-full shadow-2xl sm:w-96"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "rgb(var(--surface-0))",
|
backgroundColor: "var(--surface-0, #ffffff)",
|
||||||
borderColor: "rgb(var(--border-default))",
|
borderColor: "var(--border-default, #e5e7eb)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={expand}
|
onClick={expand}
|
||||||
className="flex w-full items-center justify-between border-t px-4 py-3 text-left transition-colors hover:bg-black/5 focus:outline-none focus:ring-2 focus:ring-inset"
|
className="flex w-full items-center justify-between border-t px-4 py-3 text-left transition-colors hover:bg-black/5 focus:outline-none focus:ring-2 focus:ring-inset"
|
||||||
style={{
|
style={{
|
||||||
borderColor: "rgb(var(--border-default))",
|
borderColor: "var(--border-default, #e5e7eb)",
|
||||||
backgroundColor: "rgb(var(--surface-0))",
|
backgroundColor: "var(--surface-0, #ffffff)",
|
||||||
}}
|
}}
|
||||||
aria-label="Expand chat"
|
aria-label="Expand chat"
|
||||||
>
|
>
|
||||||
@@ -135,10 +135,10 @@ export function ChatOverlay(): React.JSX.Element {
|
|||||||
|
|
||||||
{/* Chat Panel */}
|
{/* Chat Panel */}
|
||||||
<div
|
<div
|
||||||
className="fixed inset-y-0 right-0 z-40 flex w-full flex-col border-l sm:w-96 lg:inset-y-16"
|
className="fixed inset-y-0 right-0 z-40 flex w-full flex-col border-l shadow-2xl sm:w-96 lg:inset-y-16"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "rgb(var(--surface-0))",
|
backgroundColor: "var(--surface-0, #ffffff)",
|
||||||
borderColor: "rgb(var(--border-default))",
|
borderColor: "var(--border-default, #e5e7eb)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { useChat, type Message } from "./useChat";
|
|||||||
import * as chatApi from "@/lib/api/chat";
|
import * as chatApi from "@/lib/api/chat";
|
||||||
import * as ideasApi from "@/lib/api/ideas";
|
import * as ideasApi from "@/lib/api/ideas";
|
||||||
import type { Idea } from "@/lib/api/ideas";
|
import type { Idea } from "@/lib/api/ideas";
|
||||||
import type { ChatResponse } from "@/lib/api/chat";
|
|
||||||
|
|
||||||
// Mock the API modules - use importOriginal to preserve types/enums
|
// Mock the API modules - use importOriginal to preserve types/enums
|
||||||
vi.mock("@/lib/api/chat", () => ({
|
vi.mock("@/lib/api/chat", () => ({
|
||||||
@@ -37,24 +36,8 @@ const mockStreamChatMessage = chatApi.streamChatMessage as MockedFunction<
|
|||||||
const mockCreateConversation = ideasApi.createConversation as MockedFunction<
|
const mockCreateConversation = ideasApi.createConversation as MockedFunction<
|
||||||
typeof ideasApi.createConversation
|
typeof ideasApi.createConversation
|
||||||
>;
|
>;
|
||||||
const mockUpdateConversation = ideasApi.updateConversation as MockedFunction<
|
|
||||||
typeof ideasApi.updateConversation
|
|
||||||
>;
|
|
||||||
const mockGetIdea = ideasApi.getIdea as MockedFunction<typeof ideasApi.getIdea>;
|
const mockGetIdea = ideasApi.getIdea as MockedFunction<typeof ideasApi.getIdea>;
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a mock ChatResponse
|
|
||||||
*/
|
|
||||||
function createMockChatResponse(content: string, model = "llama3.2"): ChatResponse {
|
|
||||||
return {
|
|
||||||
message: { role: "assistant" as const, content },
|
|
||||||
model,
|
|
||||||
done: true,
|
|
||||||
promptEvalCount: 10,
|
|
||||||
evalCount: 5,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a mock Idea
|
* Creates a mock Idea
|
||||||
*/
|
*/
|
||||||
@@ -76,9 +59,9 @@ function createMockIdea(id: string, title: string, content: string): Idea {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Configure streamChatMessage to immediately fail,
|
* Configure streamChatMessage to immediately fail,
|
||||||
* triggering the fallback to sendChatMessage.
|
* without using a non-streaming fallback.
|
||||||
*/
|
*/
|
||||||
function makeStreamFail(): void {
|
function makeStreamFail(error: Error = new Error("Streaming not available")): void {
|
||||||
mockStreamChatMessage.mockImplementation(
|
mockStreamChatMessage.mockImplementation(
|
||||||
(
|
(
|
||||||
_request,
|
_request,
|
||||||
@@ -88,7 +71,7 @@ function makeStreamFail(): void {
|
|||||||
_signal?: AbortSignal
|
_signal?: AbortSignal
|
||||||
): void => {
|
): void => {
|
||||||
// Call synchronously so the Promise rejects immediately
|
// Call synchronously so the Promise rejects immediately
|
||||||
onError(new Error("Streaming not available"));
|
onError(error);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -155,24 +138,7 @@ describe("useChat", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("sendMessage (fallback path when streaming fails)", () => {
|
describe("sendMessage (streaming failure path)", () => {
|
||||||
it("should add user message and assistant response via fallback", async () => {
|
|
||||||
mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("Hello there!"));
|
|
||||||
mockCreateConversation.mockResolvedValueOnce(createMockIdea("conv-1", "Test", ""));
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useChat());
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.sendMessage("Hello");
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.messages).toHaveLength(3); // welcome + user + assistant
|
|
||||||
expect(result.current.messages[1]?.role).toBe("user");
|
|
||||||
expect(result.current.messages[1]?.content).toBe("Hello");
|
|
||||||
expect(result.current.messages[2]?.role).toBe("assistant");
|
|
||||||
expect(result.current.messages[2]?.content).toBe("Hello there!");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not send empty messages", async () => {
|
it("should not send empty messages", async () => {
|
||||||
const { result } = renderHook(() => useChat());
|
const { result } = renderHook(() => useChat());
|
||||||
|
|
||||||
@@ -186,22 +152,19 @@ describe("useChat", () => {
|
|||||||
expect(result.current.messages).toHaveLength(1); // only welcome
|
expect(result.current.messages).toHaveLength(1); // only welcome
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle API errors gracefully", async () => {
|
it("should handle streaming errors gracefully", async () => {
|
||||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
||||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||||
mockSendChatMessage.mockRejectedValueOnce(new Error("API Error"));
|
makeStreamFail(new Error("Streaming not available"));
|
||||||
|
|
||||||
const onError = vi.fn();
|
const { result } = renderHook(() => useChat());
|
||||||
const { result } = renderHook(() => useChat({ onError }));
|
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await result.current.sendMessage("Hello");
|
await result.current.sendMessage("Hello");
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.error).toBe("Unable to send message. Please try again.");
|
// Streaming fails, no fallback, placeholder is removed
|
||||||
expect(onError).toHaveBeenCalledWith(expect.any(Error));
|
expect(result.current.error).toContain("Chat error:");
|
||||||
expect(result.current.messages).toHaveLength(3);
|
expect(result.current.messages).toHaveLength(2); // welcome + user (no assistant)
|
||||||
expect(result.current.messages[2]?.content).toBe("Something went wrong. Please try again.");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -588,9 +551,8 @@ describe("useChat", () => {
|
|||||||
|
|
||||||
describe("clearError", () => {
|
describe("clearError", () => {
|
||||||
it("should clear error state", async () => {
|
it("should clear error state", async () => {
|
||||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
||||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||||
mockSendChatMessage.mockRejectedValueOnce(new Error("Test error"));
|
makeStreamFail(new Error("Test error"));
|
||||||
|
|
||||||
const { result } = renderHook(() => useChat());
|
const { result } = renderHook(() => useChat());
|
||||||
|
|
||||||
@@ -598,7 +560,7 @@ describe("useChat", () => {
|
|||||||
await result.current.sendMessage("Hello");
|
await result.current.sendMessage("Hello");
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.error).toBe("Unable to send message. Please try again.");
|
expect(result.current.error).toContain("Chat error:");
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.clearError();
|
result.current.clearError();
|
||||||
@@ -608,87 +570,14 @@ describe("useChat", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("error context logging", () => {
|
// Note: "error context logging" tests removed - the detailed logging with LLM_ERROR type
|
||||||
it("should log comprehensive error context when sendMessage fails", async () => {
|
// was removed in commit 44da50d when guest fallback mode was removed.
|
||||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
// The implementation now uses simple console.warn for streaming failures.
|
||||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
||||||
mockSendChatMessage.mockRejectedValueOnce(new Error("LLM timeout"));
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useChat({ model: "llama3.2" }));
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.sendMessage("Hello world");
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(consoleSpy).toHaveBeenCalledWith(
|
|
||||||
"Failed to send chat message",
|
|
||||||
expect.objectContaining({
|
|
||||||
errorType: "LLM_ERROR",
|
|
||||||
messageLength: 11,
|
|
||||||
messagePreview: "Hello world",
|
|
||||||
model: "llama3.2",
|
|
||||||
timestamp: expect.any(String) as string,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should truncate long message previews to 50 characters", async () => {
|
|
||||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
||||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
||||||
mockSendChatMessage.mockRejectedValueOnce(new Error("Failed"));
|
|
||||||
|
|
||||||
const longMessage = "A".repeat(100);
|
|
||||||
const { result } = renderHook(() => useChat());
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.sendMessage(longMessage);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(consoleSpy).toHaveBeenCalledWith(
|
|
||||||
"Failed to send chat message",
|
|
||||||
expect.objectContaining({
|
|
||||||
messagePreview: "A".repeat(50),
|
|
||||||
messageLength: 100,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should include message count in error context", async () => {
|
|
||||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
||||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
||||||
|
|
||||||
// First successful message via streaming
|
|
||||||
makeStreamSucceed(["OK"]);
|
|
||||||
mockCreateConversation.mockResolvedValueOnce(createMockIdea("conv-1", "Test", ""));
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useChat());
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.sendMessage("First");
|
|
||||||
});
|
|
||||||
|
|
||||||
// Second message: streaming fails, fallback fails
|
|
||||||
makeStreamFail();
|
|
||||||
mockSendChatMessage.mockRejectedValueOnce(new Error("Fail"));
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.sendMessage("Second");
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(consoleSpy).toHaveBeenCalledWith(
|
|
||||||
"Failed to send chat message",
|
|
||||||
expect.objectContaining({
|
|
||||||
messageCount: expect.any(Number) as number,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("LLM vs persistence error separation", () => {
|
describe("LLM vs persistence error separation", () => {
|
||||||
it("should show LLM error and add error message to chat when API fails", async () => {
|
it("should show streaming error when stream fails", async () => {
|
||||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
||||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||||
mockSendChatMessage.mockRejectedValueOnce(new Error("Model not available"));
|
makeStreamFail(new Error("Streaming not available"));
|
||||||
|
|
||||||
const { result } = renderHook(() => useChat());
|
const { result } = renderHook(() => useChat());
|
||||||
|
|
||||||
@@ -696,9 +585,9 @@ describe("useChat", () => {
|
|||||||
await result.current.sendMessage("Hello");
|
await result.current.sendMessage("Hello");
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.error).toBe("Unable to send message. Please try again.");
|
// Streaming fails, placeholder is removed, error is set
|
||||||
expect(result.current.messages).toHaveLength(3);
|
expect(result.current.error).toContain("Chat error:");
|
||||||
expect(result.current.messages[2]?.content).toBe("Something went wrong. Please try again.");
|
expect(result.current.messages).toHaveLength(2); // welcome + user (no assistant)
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should keep assistant message visible when save fails (streaming path)", async () => {
|
it("should keep assistant message visible when save fails (streaming path)", async () => {
|
||||||
@@ -717,27 +606,10 @@ describe("useChat", () => {
|
|||||||
expect(result.current.error).toContain("Message sent but failed to save");
|
expect(result.current.error).toContain("Message sent but failed to save");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should keep assistant message visible when save fails (fallback path)", async () => {
|
|
||||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
||||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
||||||
mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("Great answer!"));
|
|
||||||
mockCreateConversation.mockRejectedValueOnce(new Error("Database connection lost"));
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useChat());
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.sendMessage("Hello");
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.messages).toHaveLength(3);
|
|
||||||
expect(result.current.messages[2]?.content).toBe("Great answer!");
|
|
||||||
expect(result.current.error).toContain("Message sent but failed to save");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should log with PERSISTENCE_ERROR type when save fails", async () => {
|
it("should log with PERSISTENCE_ERROR type when save fails", async () => {
|
||||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||||
mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("Response"));
|
makeStreamSucceed(["Response"]);
|
||||||
mockCreateConversation.mockRejectedValueOnce(new Error("DB error"));
|
mockCreateConversation.mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
|
||||||
const { result } = renderHook(() => useChat());
|
const { result } = renderHook(() => useChat());
|
||||||
@@ -765,53 +637,6 @@ describe("useChat", () => {
|
|||||||
expect(llmErrorCalls).toHaveLength(0);
|
expect(llmErrorCalls).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should use different user-facing messages for LLM vs save errors", async () => {
|
|
||||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
||||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
||||||
|
|
||||||
// LLM error path (streaming fails + fallback fails)
|
|
||||||
mockSendChatMessage.mockRejectedValueOnce(new Error("Timeout"));
|
|
||||||
const { result: result1 } = renderHook(() => useChat());
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await result1.current.sendMessage("Test");
|
|
||||||
});
|
|
||||||
|
|
||||||
const llmError = result1.current.error;
|
|
||||||
|
|
||||||
// Save error path (streaming succeeds, save fails)
|
|
||||||
makeStreamSucceed(["OK"]);
|
|
||||||
mockCreateConversation.mockRejectedValueOnce(new Error("DB down"));
|
|
||||||
const { result: result2 } = renderHook(() => useChat());
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await result2.current.sendMessage("Test");
|
|
||||||
});
|
|
||||||
|
|
||||||
const saveError = result2.current.error;
|
|
||||||
|
|
||||||
expect(llmError).toBe("Unable to send message. Please try again.");
|
|
||||||
expect(saveError).toContain("Message sent but failed to save");
|
|
||||||
expect(llmError).not.toEqual(saveError);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle non-Error throws from LLM API", async () => {
|
|
||||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
||||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
||||||
mockSendChatMessage.mockRejectedValueOnce("string error");
|
|
||||||
|
|
||||||
const onError = vi.fn();
|
|
||||||
const { result } = renderHook(() => useChat({ onError }));
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.sendMessage("Hello");
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.error).toBe("Unable to send message. Please try again.");
|
|
||||||
expect(onError).toHaveBeenCalledWith(expect.any(Error));
|
|
||||||
expect(result.current.messages[2]?.content).toBe("Something went wrong. Please try again.");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle non-Error throws from persistence layer", async () => {
|
it("should handle non-Error throws from persistence layer", async () => {
|
||||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||||
@@ -829,37 +654,5 @@ describe("useChat", () => {
|
|||||||
expect(result.current.error).toBe("Message sent but failed to save. Please try again.");
|
expect(result.current.error).toBe("Message sent but failed to save. Please try again.");
|
||||||
expect(onError).toHaveBeenCalledWith(expect.any(Error));
|
expect(onError).toHaveBeenCalledWith(expect.any(Error));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle updateConversation failure for existing conversations", async () => {
|
|
||||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
||||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
||||||
|
|
||||||
// First message via fallback
|
|
||||||
mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("First response"));
|
|
||||||
mockCreateConversation.mockResolvedValueOnce(createMockIdea("conv-1", "Test", ""));
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useChat());
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.sendMessage("First");
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.conversationId).toBe("conv-1");
|
|
||||||
|
|
||||||
// Second message via fallback, updateConversation fails
|
|
||||||
makeStreamFail();
|
|
||||||
mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("Second response"));
|
|
||||||
mockUpdateConversation.mockRejectedValueOnce(new Error("Connection reset"));
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.sendMessage("Second");
|
|
||||||
});
|
|
||||||
|
|
||||||
const assistantMessages = result.current.messages.filter(
|
|
||||||
(m) => m.role === "assistant" && m.id !== "welcome"
|
|
||||||
);
|
|
||||||
expect(assistantMessages[assistantMessages.length - 1]?.content).toBe("Second response");
|
|
||||||
expect(result.current.error).toBe("Message sent but failed to save. Please try again.");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,12 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useCallback, useRef } from "react";
|
import { useState, useCallback, useRef } from "react";
|
||||||
import {
|
import { streamChatMessage, type ChatMessage as ApiChatMessage } from "@/lib/api/chat";
|
||||||
sendChatMessage,
|
|
||||||
streamChatMessage,
|
|
||||||
streamGuestChat,
|
|
||||||
type ChatMessage as ApiChatMessage,
|
|
||||||
} from "@/lib/api/chat";
|
|
||||||
import { createConversation, updateConversation, getIdea, type Idea } from "@/lib/api/ideas";
|
import { createConversation, updateConversation, getIdea, type Idea } from "@/lib/api/ideas";
|
||||||
import { safeJsonParse, isMessageArray } from "@/lib/utils/safe-json";
|
import { safeJsonParse, isMessageArray } from "@/lib/utils/safe-json";
|
||||||
|
|
||||||
@@ -219,8 +214,6 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
|
|||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
abortControllerRef.current = controller;
|
abortControllerRef.current = controller;
|
||||||
|
|
||||||
let streamingSucceeded = false;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
let hasReceivedData = false;
|
let hasReceivedData = false;
|
||||||
@@ -248,7 +241,6 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
streamingSucceeded = true;
|
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
abortControllerRef.current = null;
|
abortControllerRef.current = null;
|
||||||
resolve();
|
resolve();
|
||||||
@@ -279,140 +271,26 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Streaming failed - check if auth error, try guest mode
|
// Streaming failed — show error (no guest fallback, auth required)
|
||||||
const isAuthError =
|
console.warn("Streaming failed", {
|
||||||
err instanceof Error &&
|
error: err instanceof Error ? err : new Error(String(err)),
|
||||||
(err.message.includes("403") ||
|
});
|
||||||
err.message.includes("401") ||
|
|
||||||
err.message.includes("auth") ||
|
|
||||||
err.message.includes("Forbidden"));
|
|
||||||
|
|
||||||
if (isAuthError) {
|
setMessages((prev) => {
|
||||||
console.warn("Auth failed, trying guest chat mode");
|
const withoutPlaceholder = prev.filter((m) => m.id !== assistantMessageId);
|
||||||
|
messagesRef.current = withoutPlaceholder;
|
||||||
|
return withoutPlaceholder;
|
||||||
|
});
|
||||||
|
setIsStreaming(false);
|
||||||
|
setIsLoading(false);
|
||||||
|
|
||||||
// Try guest chat streaming
|
const errorMsg = err instanceof Error ? err.message : "Chat unavailable";
|
||||||
try {
|
setError(`Chat error: ${errorMsg}`);
|
||||||
await new Promise<void>((guestResolve, guestReject) => {
|
return;
|
||||||
let hasReceivedData = false;
|
|
||||||
|
|
||||||
streamGuestChat(
|
|
||||||
request,
|
|
||||||
(chunk: string) => {
|
|
||||||
if (!hasReceivedData) {
|
|
||||||
hasReceivedData = true;
|
|
||||||
setIsLoading(false);
|
|
||||||
setIsStreaming(true);
|
|
||||||
setMessages((prev) => {
|
|
||||||
const updated = [...prev, { ...placeholderMessage }];
|
|
||||||
messagesRef.current = updated;
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setMessages((prev) => {
|
|
||||||
const updated = prev.map((msg) =>
|
|
||||||
msg.id === assistantMessageId ? { ...msg, content: msg.content + chunk } : msg
|
|
||||||
);
|
|
||||||
messagesRef.current = updated;
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
() => {
|
|
||||||
streamingSucceeded = true;
|
|
||||||
setIsStreaming(false);
|
|
||||||
guestResolve();
|
|
||||||
},
|
|
||||||
(guestErr: Error) => {
|
|
||||||
guestReject(guestErr);
|
|
||||||
},
|
|
||||||
controller.signal
|
|
||||||
);
|
|
||||||
});
|
|
||||||
} catch (guestErr: unknown) {
|
|
||||||
// Guest also failed
|
|
||||||
setMessages((prev) => {
|
|
||||||
const withoutPlaceholder = prev.filter((m) => m.id !== assistantMessageId);
|
|
||||||
messagesRef.current = withoutPlaceholder;
|
|
||||||
return withoutPlaceholder;
|
|
||||||
});
|
|
||||||
const errorMsg = guestErr instanceof Error ? guestErr.message : "Chat unavailable";
|
|
||||||
setError(`Unable to connect to chat: ${errorMsg}`);
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Streaming failed — fall back to non-streaming
|
|
||||||
console.warn("Streaming failed, falling back to non-streaming", {
|
|
||||||
error: err instanceof Error ? err : new Error(String(err)),
|
|
||||||
});
|
|
||||||
|
|
||||||
setMessages((prev) => {
|
|
||||||
const withoutPlaceholder = prev.filter((m) => m.id !== assistantMessageId);
|
|
||||||
messagesRef.current = withoutPlaceholder;
|
|
||||||
return withoutPlaceholder;
|
|
||||||
});
|
|
||||||
setIsStreaming(false);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await sendChatMessage(request);
|
|
||||||
|
|
||||||
const assistantMessage: Message = {
|
|
||||||
id: `assistant-${Date.now().toString()}`,
|
|
||||||
role: "assistant",
|
|
||||||
content: response.message.content,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
model: response.model,
|
|
||||||
promptTokens: response.promptEvalCount ?? 0,
|
|
||||||
completionTokens: response.evalCount ?? 0,
|
|
||||||
totalTokens: (response.promptEvalCount ?? 0) + (response.evalCount ?? 0),
|
|
||||||
};
|
|
||||||
|
|
||||||
setMessages((prev) => {
|
|
||||||
const updated = [...prev, assistantMessage];
|
|
||||||
messagesRef.current = updated;
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
|
|
||||||
streamingSucceeded = true;
|
|
||||||
} catch (fallbackErr: unknown) {
|
|
||||||
const errorMsg =
|
|
||||||
fallbackErr instanceof Error ? fallbackErr.message : "Failed to send message";
|
|
||||||
setError("Unable to send message. Please try again.");
|
|
||||||
onError?.(fallbackErr instanceof Error ? fallbackErr : new Error(errorMsg));
|
|
||||||
console.error("Failed to send chat message", {
|
|
||||||
error: fallbackErr,
|
|
||||||
errorType: "LLM_ERROR",
|
|
||||||
conversationId: conversationIdRef.current,
|
|
||||||
messageLength: content.length,
|
|
||||||
messagePreview: content.substring(0, 50),
|
|
||||||
model,
|
|
||||||
messageCount: messagesRef.current.length,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const errorMessage: Message = {
|
|
||||||
id: `error-${String(Date.now())}`,
|
|
||||||
role: "assistant",
|
|
||||||
content: "Something went wrong. Please try again.",
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
setMessages((prev) => {
|
|
||||||
const updated = [...prev, errorMessage];
|
|
||||||
messagesRef.current = updated;
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|
||||||
if (!streamingSucceeded) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const finalMessages = messagesRef.current;
|
const finalMessages = messagesRef.current;
|
||||||
|
|
||||||
const isFirstMessage =
|
const isFirstMessage =
|
||||||
|
|||||||
182
docs/PRD-MS22-P2-AGENT-FLEET.md
Normal file
182
docs/PRD-MS22-P2-AGENT-FLEET.md
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
# PRD: MS22 Phase 2 — Named Agent Fleet
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
|
||||||
|
- **Owner:** Jason Woltje
|
||||||
|
- **Date:** 2026-03-04
|
||||||
|
- **Status:** draft
|
||||||
|
- **Design Doc:** `~/src/jarvis-brain/docs/planning/FLEET-EVOLUTION-PLAN.md`
|
||||||
|
- **Depends On:** MS22 Phase 1 (DB-Centric Architecture) — COMPLETE
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
Mosaic Stack has the infrastructure for per-user containers and knowledge layer, but no predefined agent personalities. Users start with a blank slate. For Jason's personal use case, we need named agents with distinct roles, personalities, and tool access that can collaborate through the shared knowledge layer.
|
||||||
|
|
||||||
|
## Objectives
|
||||||
|
|
||||||
|
1. **Named agents** — jarvis (orchestrator), builder (coding), medic (monitoring)
|
||||||
|
2. **Per-agent model assignment** — Opus for jarvis, Codex for builder, Haiku for medic
|
||||||
|
3. **Tool permissions** — Restrict dangerous tools to appropriate agents
|
||||||
|
4. **Discord bindings** — Route agents to specific channels
|
||||||
|
5. **Mosaic skill** — All agents can read/write findings and memory
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### In Scope
|
||||||
|
|
||||||
|
- Agent personality definitions (SOUL.md for each)
|
||||||
|
- Agent registry in Mosaic DB
|
||||||
|
- Per-agent model configuration
|
||||||
|
- Per-agent tool permission sets
|
||||||
|
- Discord channel routing
|
||||||
|
- Default agent templates for new users
|
||||||
|
|
||||||
|
### Out of Scope
|
||||||
|
|
||||||
|
- Matrix observation rooms (nice-to-have)
|
||||||
|
- WebUI chat improvements (separate phase)
|
||||||
|
- Cross-agent quality gates (future)
|
||||||
|
- Team workspaces (future)
|
||||||
|
|
||||||
|
## Agent Definitions
|
||||||
|
|
||||||
|
### Jarvis — Orchestrator
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| **Role** | Main orchestrator, user-facing assistant |
|
||||||
|
| **Model** | Opus (primary), Sonnet (fallback) |
|
||||||
|
| **Tools** | All tools — full access |
|
||||||
|
| **Discord** | #jarvis |
|
||||||
|
| **Personality** | Capable, direct, proactive. Gets stuff done without hand-holding. Thinks before acting, speaks up when seeing a better way. NOT a yes-man. |
|
||||||
|
|
||||||
|
### Builder — Coding Agent
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
| --------------- | --------------------------------------------------------------------------------------- |
|
||||||
|
| **Role** | Code implementation, PRs, refactoring |
|
||||||
|
| **Model** | Codex (primary, uses OpenAI credits), Sonnet (fallback) |
|
||||||
|
| **Tools** | exec, read, write, edit, github, browser |
|
||||||
|
| **Discord** | #builder |
|
||||||
|
| **Personality** | Focused, thorough. Writes clean code. Tests before declaring done. Documents decisions. |
|
||||||
|
|
||||||
|
### Medic — Health Monitoring
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
| --------------- | ------------------------------------------------------------------------------- |
|
||||||
|
| **Role** | System health checks, alerts, monitoring |
|
||||||
|
| **Model** | Haiku (primary), MiniMax (fallback) |
|
||||||
|
| **Tools** | exec (SSH), nodes, cron, message (alerts only) |
|
||||||
|
| **Discord** | #medic-alerts |
|
||||||
|
| **Personality** | Vigilant, concise. Alerts on anomalies. Proactive health checks. Minimal noise. |
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model AgentTemplate {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String @unique // "jarvis", "builder", "medic"
|
||||||
|
displayName String // "Jarvis", "Builder", "Medic"
|
||||||
|
role String // "orchestrator" | "coding" | "monitoring"
|
||||||
|
personality String // SOUL.md content
|
||||||
|
primaryModel String // "opus", "codex", "haiku"
|
||||||
|
fallbackModels Json @default("[]") // ["sonnet", "haiku"]
|
||||||
|
toolPermissions Json @default("[]") // ["exec", "read", "write", ...]
|
||||||
|
discordChannel String? // "jarvis", "builder", "medic-alerts"
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
isDefault Boolean @default(false) // Include in new user provisioning
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model UserAgent {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
templateId String? // null = custom agent
|
||||||
|
name String // "jarvis", "builder", "medic" or custom
|
||||||
|
displayName String
|
||||||
|
role String
|
||||||
|
personality String // User can customize
|
||||||
|
primaryModel String?
|
||||||
|
fallbackModels Json @default("[]")
|
||||||
|
toolPermissions Json @default("[]")
|
||||||
|
discordChannel String?
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([userId, name])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Agent Templates (Admin)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/admin/agent-templates — List all templates
|
||||||
|
POST /api/admin/agent-templates — Create template
|
||||||
|
GET /api/admin/agent-templates/:id — Get template
|
||||||
|
PATCH /api/admin/agent-templates/:id — Update template
|
||||||
|
DELETE /api/admin/agent-templates/:id — Delete template
|
||||||
|
```
|
||||||
|
|
||||||
|
### User Agents
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/agents — List user's agents
|
||||||
|
POST /api/agents — Create custom agent (or from template)
|
||||||
|
GET /api/agents/:id — Get agent details
|
||||||
|
PATCH /api/agents/:id — Update agent (personality, model)
|
||||||
|
DELETE /api/agents/:id — Delete custom agent
|
||||||
|
POST /api/agents/:id/chat — Chat with agent (proxy to container)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Agent Status
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/agents/status — All agents status for user
|
||||||
|
GET /api/agents/:id/status — Single agent status
|
||||||
|
```
|
||||||
|
|
||||||
|
## Task Breakdown
|
||||||
|
|
||||||
|
| Task ID | Phase | Description | Scope | Dependencies | Estimate |
|
||||||
|
| -------------- | ------- | ---------------------------------------------- | ----- | ------------ | -------- |
|
||||||
|
| P2-DB-001 | schema | Prisma models: AgentTemplate, UserAgent | api | P1a | 10K |
|
||||||
|
| P2-SEED-001 | seed | Seed default agents (jarvis, builder, medic) | api | P2-DB-001 | 5K |
|
||||||
|
| P2-API-001 | api | Agent template CRUD endpoints | api | P2-DB-001 | 15K |
|
||||||
|
| P2-API-002 | api | User agent CRUD endpoints | api | P2-DB-001 | 15K |
|
||||||
|
| P2-API-003 | api | Agent status endpoints | api | P2-DB-001 | 10K |
|
||||||
|
| P2-PROXY-001 | api | Agent chat routing (select agent by name) | api | P2-API-002 | 15K |
|
||||||
|
| P2-DISCORD-001 | discord | Route Discord messages to correct agent | api | P2-PROXY-001 | 15K |
|
||||||
|
| P2-UI-001 | web | Agent list/selector in WebUI | web | P2-API-002 | 15K |
|
||||||
|
| P2-UI-002 | web | Agent detail/edit page | web | P2-UI-001 | 15K |
|
||||||
|
| P2-TEST-001 | test | Unit tests for agent services | api | P2-API-002 | 15K |
|
||||||
|
| P2-VER-001 | verify | End-to-end: Discord → correct agent → response | stack | all | 10K |
|
||||||
|
|
||||||
|
**Total Estimate:** ~140K tokens
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
1. ✅ User can list available agents in WebUI
|
||||||
|
2. ✅ User can select agent and chat with it
|
||||||
|
3. ✅ Discord messages in #jarvis go to jarvis agent
|
||||||
|
4. ✅ Discord messages in #builder go to builder agent
|
||||||
|
5. ✅ Each agent uses its assigned model
|
||||||
|
6. ✅ Each agent has correct tool permissions
|
||||||
|
7. ✅ Agents can read/write findings via mosaic skill
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
| --------------------------- | ------------------------------------------------ |
|
||||||
|
| Agent routing complexity | Keep it simple: map Discord channel → agent name |
|
||||||
|
| Tool permission enforcement | OpenClaw config generation respects permissions |
|
||||||
|
| Model fallback failures | Log and alert, don't block user |
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. Review this PRD with Jason
|
||||||
|
2. Create Mission MS22-P2 in TASKS.md
|
||||||
|
3. Begin with P2-DB-001 (schema)
|
||||||
@@ -89,3 +89,20 @@ Design doc: `docs/design/MS22-DB-CENTRIC-ARCHITECTURE.md`
|
|||||||
| MS22-P1i | done | phase-1i | Chat proxy: route WebUI chat to user's OpenClaw container (SSE) | — | api+web | feat/ms22-p1i-chat-proxy | P1c,P1d | — | — | — | — | 20K | — | |
|
| MS22-P1i | done | phase-1i | Chat proxy: route WebUI chat to user's OpenClaw container (SSE) | — | api+web | feat/ms22-p1i-chat-proxy | P1c,P1d | — | — | — | — | 20K | — | |
|
||||||
| MS22-P1j | done | phase-1j | Docker entrypoint + health checks + core compose | — | docker | feat/ms22-p1j-docker | P1c | — | — | — | — | 10K | — | |
|
| MS22-P1j | done | phase-1j | Docker entrypoint + health checks + core compose | — | docker | feat/ms22-p1j-docker | P1c | — | — | — | — | 10K | — | |
|
||||||
| MS22-P1k | done | phase-1k | Idle reaper cron: stop inactive user containers | — | api | feat/ms22-p1k-idle-reaper | P1d | — | — | — | — | 10K | — | |
|
| MS22-P1k | done | phase-1k | Idle reaper cron: stop inactive user containers | — | api | feat/ms22-p1k-idle-reaper | P1d | — | — | — | — | 10K | — | |
|
||||||
|
|
||||||
|
## MS22 Phase 2: Named Agent Fleet
|
||||||
|
|
||||||
|
PRD: `docs/PRD-MS22-P2-AGENT-FLEET.md`
|
||||||
|
|
||||||
|
| Task ID | Status | Phase | Description | Issue | Scope | Branch | Depends On | Blocks | Assigned Worker | Started | Completed | Est Tokens | Act Tokens | Notes |
|
||||||
|
| ----------- | ----------- | -------- | -------------------------------------------- | -------- | ----- | --------------------------- | ------------- | ------------- | --------------- | ------- | --------- | ---------- | ---------- | ----- |
|
||||||
|
| MS22-P2-001 | not-started | p2-fleet | Prisma schema: AgentTemplate, UserAgent | TASKS:P2 | api | feat/ms22-p2-agent-schema | MS22-P1a | P2-002,P2-003 | — | — | — | 10K | — | |
|
||||||
|
| MS22-P2-002 | not-started | p2-fleet | Seed default agents (jarvis, builder, medic) | TASKS:P2 | api | feat/ms22-p2-agent-seed | P2-001 | P2-004 | — | — | — | 5K | — | |
|
||||||
|
| MS22-P2-003 | not-started | p2-fleet | Agent template CRUD endpoints (admin) | TASKS:P2 | api | feat/ms22-p2-agent-api | P2-001 | P2-005 | — | — | — | 15K | — | |
|
||||||
|
| MS22-P2-004 | not-started | p2-fleet | User agent CRUD endpoints | TASKS:P2 | api | feat/ms22-p2-agent-api | P2-002,P2-003 | P2-006 | — | — | — | 15K | — | |
|
||||||
|
| MS22-P2-005 | not-started | p2-fleet | Agent status endpoints | TASKS:P2 | api | feat/ms22-p2-agent-api | P2-003 | P2-008 | — | — | — | 10K | — | |
|
||||||
|
| MS22-P2-006 | not-started | p2-fleet | Agent chat routing (select agent by name) | TASKS:P2 | api | feat/ms22-p2-agent-routing | P2-004 | P2-007 | — | — | — | 15K | — | |
|
||||||
|
| MS22-P2-007 | not-started | p2-fleet | Discord channel → agent routing | TASKS:P2 | api | feat/ms22-p2-discord-router | P2-006 | P2-009 | — | — | — | 15K | — | |
|
||||||
|
| MS22-P2-008 | not-started | p2-fleet | Agent list/selector UI in WebUI | TASKS:P2 | web | feat/ms22-p2-agent-ui | P2-005 | — | — | — | — | 15K | — | |
|
||||||
|
| MS22-P2-009 | not-started | p2-fleet | Unit tests for agent services | TASKS:P2 | api | test/ms22-p2-agent-tests | P2-007 | P2-010 | — | — | — | 15K | — | |
|
||||||
|
| MS22-P2-010 | not-started | p2-fleet | E2E verification: Discord → agent → response | TASKS:P2 | stack | — | P2-009 | — | — | — | — | 10K | — | |
|
||||||
|
|||||||
Reference in New Issue
Block a user