Compare commits
1 Commits
050e17b132
...
ci/portain
| Author | SHA1 | Date | |
|---|---|---|---|
| e593dbf662 |
@@ -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": "2026-03-04T13:45:06Z",
|
"ended_at": "",
|
||||||
"ended_reason": "completed",
|
"ended_reason": "",
|
||||||
"milestone_at_end": "",
|
"milestone_at_end": "",
|
||||||
"tasks_completed": [],
|
"tasks_completed": [],
|
||||||
"last_task_id": ""
|
"last_task_id": ""
|
||||||
|
|||||||
8
.mosaic/orchestrator/session.lock
Normal file
8
.mosaic/orchestrator/session.lock
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"session_id": "sess-002",
|
||||||
|
"runtime": "unknown",
|
||||||
|
"pid": 3178395,
|
||||||
|
"started_at": "2026-02-28T20:30:13Z",
|
||||||
|
"project_path": "/tmp/ms21-ui-001",
|
||||||
|
"milestone_id": ""
|
||||||
|
}
|
||||||
@@ -342,7 +342,6 @@ 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
|
||||||
|
|||||||
46
.woodpecker/ci.yml.new
Normal file
46
.woodpecker/ci.yml.new
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# 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,39 +1703,3 @@ 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])
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
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 {}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
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 } });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
import { PartialType } from "@nestjs/mapped-types";
|
|
||||||
import { CreateAgentTemplateDto } from "./create-agent-template.dto";
|
|
||||||
|
|
||||||
export class UpdateAgentTemplateDto extends PartialType(CreateAgentTemplateDto) {}
|
|
||||||
@@ -48,7 +48,6 @@ 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";
|
||||||
@@ -130,7 +129,6 @@ import { OrchestratorModule } from "./orchestrator/orchestrator.module";
|
|||||||
PersonalitiesModule,
|
PersonalitiesModule,
|
||||||
WorkspacesModule,
|
WorkspacesModule,
|
||||||
AdminModule,
|
AdminModule,
|
||||||
AgentTemplateModule,
|
|
||||||
TeamsModule,
|
TeamsModule,
|
||||||
ImportModule,
|
ImportModule,
|
||||||
ConversationArchiveModule,
|
ConversationArchiveModule,
|
||||||
|
|||||||
@@ -342,31 +342,6 @@ 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={{
|
||||||
@@ -377,7 +352,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 || !user}
|
disabled={isChatLoading}
|
||||||
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: "var(--accent-primary, #10b981)",
|
backgroundColor: "rgb(var(--accent-primary))",
|
||||||
color: "var(--text-on-accent, #ffffff)",
|
color: "rgb(var(--text-on-accent))",
|
||||||
}}
|
}}
|
||||||
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 shadow-2xl sm:w-96"
|
className="fixed bottom-0 right-0 z-40 w-full sm:w-96"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "var(--surface-0, #ffffff)",
|
backgroundColor: "rgb(var(--surface-0))",
|
||||||
borderColor: "var(--border-default, #e5e7eb)",
|
borderColor: "rgb(var(--border-default))",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<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: "var(--border-default, #e5e7eb)",
|
borderColor: "rgb(var(--border-default))",
|
||||||
backgroundColor: "var(--surface-0, #ffffff)",
|
backgroundColor: "rgb(var(--surface-0))",
|
||||||
}}
|
}}
|
||||||
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 shadow-2xl sm:w-96 lg:inset-y-16"
|
className="fixed inset-y-0 right-0 z-40 flex w-full flex-col border-l sm:w-96 lg:inset-y-16"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "var(--surface-0, #ffffff)",
|
backgroundColor: "rgb(var(--surface-0))",
|
||||||
borderColor: "var(--border-default, #e5e7eb)",
|
borderColor: "rgb(var(--border-default))",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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", () => ({
|
||||||
@@ -36,8 +37,24 @@ 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
|
||||||
*/
|
*/
|
||||||
@@ -59,9 +76,9 @@ function createMockIdea(id: string, title: string, content: string): Idea {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Configure streamChatMessage to immediately fail,
|
* Configure streamChatMessage to immediately fail,
|
||||||
* without using a non-streaming fallback.
|
* triggering the fallback to sendChatMessage.
|
||||||
*/
|
*/
|
||||||
function makeStreamFail(error: Error = new Error("Streaming not available")): void {
|
function makeStreamFail(): void {
|
||||||
mockStreamChatMessage.mockImplementation(
|
mockStreamChatMessage.mockImplementation(
|
||||||
(
|
(
|
||||||
_request,
|
_request,
|
||||||
@@ -71,7 +88,7 @@ function makeStreamFail(error: Error = new Error("Streaming not available")): vo
|
|||||||
_signal?: AbortSignal
|
_signal?: AbortSignal
|
||||||
): void => {
|
): void => {
|
||||||
// Call synchronously so the Promise rejects immediately
|
// Call synchronously so the Promise rejects immediately
|
||||||
onError(error);
|
onError(new Error("Streaming not available"));
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -138,7 +155,24 @@ describe("useChat", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("sendMessage (streaming failure path)", () => {
|
describe("sendMessage (fallback path when streaming fails)", () => {
|
||||||
|
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());
|
||||||
|
|
||||||
@@ -152,19 +186,22 @@ describe("useChat", () => {
|
|||||||
expect(result.current.messages).toHaveLength(1); // only welcome
|
expect(result.current.messages).toHaveLength(1); // only welcome
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle streaming errors gracefully", async () => {
|
it("should handle API errors gracefully", async () => {
|
||||||
|
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||||
makeStreamFail(new Error("Streaming not available"));
|
mockSendChatMessage.mockRejectedValueOnce(new Error("API Error"));
|
||||||
|
|
||||||
const { result } = renderHook(() => useChat());
|
const onError = vi.fn();
|
||||||
|
const { result } = renderHook(() => useChat({ onError }));
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await result.current.sendMessage("Hello");
|
await result.current.sendMessage("Hello");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Streaming fails, no fallback, placeholder is removed
|
expect(result.current.error).toBe("Unable to send message. Please try again.");
|
||||||
expect(result.current.error).toContain("Chat error:");
|
expect(onError).toHaveBeenCalledWith(expect.any(Error));
|
||||||
expect(result.current.messages).toHaveLength(2); // welcome + user (no assistant)
|
expect(result.current.messages).toHaveLength(3);
|
||||||
|
expect(result.current.messages[2]?.content).toBe("Something went wrong. Please try again.");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -551,8 +588,9 @@ 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);
|
||||||
makeStreamFail(new Error("Test error"));
|
mockSendChatMessage.mockRejectedValueOnce(new Error("Test error"));
|
||||||
|
|
||||||
const { result } = renderHook(() => useChat());
|
const { result } = renderHook(() => useChat());
|
||||||
|
|
||||||
@@ -560,7 +598,7 @@ describe("useChat", () => {
|
|||||||
await result.current.sendMessage("Hello");
|
await result.current.sendMessage("Hello");
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.error).toContain("Chat error:");
|
expect(result.current.error).toBe("Unable to send message. Please try again.");
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.clearError();
|
result.current.clearError();
|
||||||
@@ -570,14 +608,87 @@ describe("useChat", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Note: "error context logging" tests removed - the detailed logging with LLM_ERROR type
|
describe("error context logging", () => {
|
||||||
// was removed in commit 44da50d when guest fallback mode was removed.
|
it("should log comprehensive error context when sendMessage fails", async () => {
|
||||||
// The implementation now uses simple console.warn for streaming failures.
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||||
|
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 streaming error when stream fails", async () => {
|
it("should show LLM error and add error message to chat when API fails", async () => {
|
||||||
|
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||||
makeStreamFail(new Error("Streaming not available"));
|
mockSendChatMessage.mockRejectedValueOnce(new Error("Model not available"));
|
||||||
|
|
||||||
const { result } = renderHook(() => useChat());
|
const { result } = renderHook(() => useChat());
|
||||||
|
|
||||||
@@ -585,9 +696,9 @@ describe("useChat", () => {
|
|||||||
await result.current.sendMessage("Hello");
|
await result.current.sendMessage("Hello");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Streaming fails, placeholder is removed, error is set
|
expect(result.current.error).toBe("Unable to send message. Please try again.");
|
||||||
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.");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should keep assistant message visible when save fails (streaming path)", async () => {
|
it("should keep assistant message visible when save fails (streaming path)", async () => {
|
||||||
@@ -606,10 +717,27 @@ 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);
|
||||||
makeStreamSucceed(["Response"]);
|
mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("Response"));
|
||||||
mockCreateConversation.mockRejectedValueOnce(new Error("DB error"));
|
mockCreateConversation.mockRejectedValueOnce(new Error("DB error"));
|
||||||
|
|
||||||
const { result } = renderHook(() => useChat());
|
const { result } = renderHook(() => useChat());
|
||||||
@@ -637,6 +765,53 @@ 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);
|
||||||
@@ -654,5 +829,37 @@ 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,7 +4,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useCallback, useRef } from "react";
|
import { useState, useCallback, useRef } from "react";
|
||||||
import { streamChatMessage, type ChatMessage as ApiChatMessage } from "@/lib/api/chat";
|
import {
|
||||||
|
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";
|
||||||
|
|
||||||
@@ -214,6 +219,8 @@ 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;
|
||||||
@@ -241,6 +248,7 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
|
streamingSucceeded = true;
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
abortControllerRef.current = null;
|
abortControllerRef.current = null;
|
||||||
resolve();
|
resolve();
|
||||||
@@ -271,26 +279,140 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Streaming failed — show error (no guest fallback, auth required)
|
// Streaming failed - check if auth error, try guest mode
|
||||||
console.warn("Streaming failed", {
|
const isAuthError =
|
||||||
error: err instanceof Error ? err : new Error(String(err)),
|
err instanceof Error &&
|
||||||
});
|
(err.message.includes("403") ||
|
||||||
|
err.message.includes("401") ||
|
||||||
|
err.message.includes("auth") ||
|
||||||
|
err.message.includes("Forbidden"));
|
||||||
|
|
||||||
setMessages((prev) => {
|
if (isAuthError) {
|
||||||
const withoutPlaceholder = prev.filter((m) => m.id !== assistantMessageId);
|
console.warn("Auth failed, trying guest chat mode");
|
||||||
messagesRef.current = withoutPlaceholder;
|
|
||||||
return withoutPlaceholder;
|
|
||||||
});
|
|
||||||
setIsStreaming(false);
|
|
||||||
setIsLoading(false);
|
|
||||||
|
|
||||||
const errorMsg = err instanceof Error ? err.message : "Chat unavailable";
|
// Try guest chat streaming
|
||||||
setError(`Chat error: ${errorMsg}`);
|
try {
|
||||||
return;
|
await new Promise<void>((guestResolve, guestReject) => {
|
||||||
|
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 =
|
||||||
|
|||||||
@@ -1,182 +0,0 @@
|
|||||||
# 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,20 +89,3 @@ 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