Compare commits
8 Commits
feature/ch
...
fix/chat-a
| Author | SHA1 | Date | |
|---|---|---|---|
| 44da50d0b3 | |||
| 44fb402ef2 | |||
| f42c47e314 | |||
| 8069aeadb5 | |||
| 1f883c4c04 | |||
| 5207d8c0c9 | |||
| d1c9a747b9 | |||
| 3d669713d7 |
@@ -338,41 +338,43 @@ steps:
|
||||
- security-trivy-orchestrator
|
||||
- security-trivy-web
|
||||
|
||||
# ─── Deploy to Docker Swarm (main only) ─────────────────────
|
||||
|
||||
# ─── Deploy to Docker Swarm via Portainer (main only) ─────────────────────
|
||||
# ─── Deploy to Docker Swarm via Portainer API (main only) ─────────────────────
|
||||
|
||||
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
|
||||
PORTAINER_STACK_ID: "121"
|
||||
commands:
|
||||
- apk add --no-cache curl openssh-client
|
||||
- apk add --no-cache curl
|
||||
- |
|
||||
set -e
|
||||
echo "🚀 Deploying to Docker Swarm..."
|
||||
echo "🚀 Deploying to Docker Swarm via Portainer API..."
|
||||
|
||||
# Setup SSH for fallback
|
||||
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
|
||||
# Use Portainer API to update the stack (forces pull of new images)
|
||||
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
|
||||
-H "X-API-Key: $PORTAINER_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$PORTAINER_URL/api/stacks/$PORTAINER_STACK_ID/git/redeploy")
|
||||
|
||||
# Force service updates (images are pulled from public registry)
|
||||
ssh -o StrictHostKeyChecking=no localadmin@10.1.1.45 \
|
||||
"docker service update --with-registry-auth --force mosaic-stack-api && \
|
||||
docker service update --with-registry-auth --force mosaic-stack-web && \
|
||||
docker service update --with-registry-auth --force mosaic-stack-orchestrator && \
|
||||
docker service update --with-registry-auth --force mosaic-stack-coordinator && \
|
||||
echo '✅ All services updated'"
|
||||
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
|
||||
BODY=$(echo "$RESPONSE" | head -n -1)
|
||||
|
||||
if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "202" ]; then
|
||||
echo "✅ Stack update triggered successfully"
|
||||
else
|
||||
echo "❌ Stack update failed (HTTP $HTTP_CODE)"
|
||||
echo "$BODY"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Wait for services to converge
|
||||
echo "⏳ Waiting for services to converge..."
|
||||
sleep 30
|
||||
echo "✅ Deploy complete"
|
||||
when:
|
||||
- branch: [main]
|
||||
event: [push, manual, tag]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Body, Controller, HttpException, Logger, Post, Req, Res, UseGuards } from "@nestjs/common";
|
||||
import type { Response } from "express";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { SkipCsrf } from "../common/decorators/skip-csrf.decorator";
|
||||
import type { MaybeAuthenticatedRequest } from "../auth/types/better-auth-request.interface";
|
||||
import { ChatStreamDto } from "./chat-proxy.dto";
|
||||
import { ChatProxyService } from "./chat-proxy.service";
|
||||
@@ -14,6 +15,7 @@ export class ChatProxyController {
|
||||
// POST /api/chat/guest
|
||||
// Guest chat endpoint - no authentication required
|
||||
// Uses a shared LLM configuration for unauthenticated users
|
||||
@SkipCsrf()
|
||||
@Post("guest")
|
||||
async guestChat(
|
||||
@Body() body: ChatStreamDto,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { AuthModule } from "../auth/auth.module";
|
||||
import { AgentConfigModule } from "../agent-config/agent-config.module";
|
||||
import { ContainerLifecycleModule } from "../container-lifecycle/container-lifecycle.module";
|
||||
@@ -7,7 +8,7 @@ import { ChatProxyController } from "./chat-proxy.controller";
|
||||
import { ChatProxyService } from "./chat-proxy.service";
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, PrismaModule, ContainerLifecycleModule, AgentConfigModule],
|
||||
imports: [AuthModule, PrismaModule, ContainerLifecycleModule, AgentConfigModule, ConfigModule],
|
||||
controllers: [ChatProxyController],
|
||||
providers: [ChatProxyService],
|
||||
exports: [ChatProxyService],
|
||||
|
||||
@@ -342,6 +342,31 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
||||
)}
|
||||
|
||||
{/* 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
|
||||
className="sticky bottom-0 border-t"
|
||||
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">
|
||||
<ChatInput
|
||||
onSend={handleSendMessage}
|
||||
disabled={isChatLoading}
|
||||
disabled={isChatLoading || !user}
|
||||
inputRef={inputRef}
|
||||
isStreaming={isStreaming}
|
||||
onStopStreaming={abortStream}
|
||||
|
||||
@@ -55,8 +55,8 @@ export function ChatOverlay(): React.JSX.Element {
|
||||
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"
|
||||
style={{
|
||||
backgroundColor: "rgb(var(--accent-primary))",
|
||||
color: "rgb(var(--text-on-accent))",
|
||||
backgroundColor: "var(--accent-primary, #10b981)",
|
||||
color: "var(--text-on-accent, #ffffff)",
|
||||
}}
|
||||
aria-label="Open chat"
|
||||
title="Open Jarvis chat (Cmd+Shift+J)"
|
||||
@@ -78,18 +78,18 @@ export function ChatOverlay(): React.JSX.Element {
|
||||
if (isMinimized) {
|
||||
return (
|
||||
<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={{
|
||||
backgroundColor: "rgb(var(--surface-0))",
|
||||
borderColor: "rgb(var(--border-default))",
|
||||
backgroundColor: "var(--surface-0, #ffffff)",
|
||||
borderColor: "var(--border-default, #e5e7eb)",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
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"
|
||||
style={{
|
||||
borderColor: "rgb(var(--border-default))",
|
||||
backgroundColor: "rgb(var(--surface-0))",
|
||||
borderColor: "var(--border-default, #e5e7eb)",
|
||||
backgroundColor: "var(--surface-0, #ffffff)",
|
||||
}}
|
||||
aria-label="Expand chat"
|
||||
>
|
||||
@@ -135,10 +135,10 @@ export function ChatOverlay(): React.JSX.Element {
|
||||
|
||||
{/* Chat Panel */}
|
||||
<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={{
|
||||
backgroundColor: "rgb(var(--surface-0))",
|
||||
borderColor: "rgb(var(--border-default))",
|
||||
backgroundColor: "var(--surface-0, #ffffff)",
|
||||
borderColor: "var(--border-default, #e5e7eb)",
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
|
||||
@@ -4,12 +4,7 @@
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import {
|
||||
sendChatMessage,
|
||||
streamChatMessage,
|
||||
streamGuestChat,
|
||||
type ChatMessage as ApiChatMessage,
|
||||
} from "@/lib/api/chat";
|
||||
import { streamChatMessage, type ChatMessage as ApiChatMessage } from "@/lib/api/chat";
|
||||
import { createConversation, updateConversation, getIdea, type Idea } from "@/lib/api/ideas";
|
||||
import { safeJsonParse, isMessageArray } from "@/lib/utils/safe-json";
|
||||
|
||||
@@ -219,8 +214,6 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
|
||||
const controller = new AbortController();
|
||||
abortControllerRef.current = controller;
|
||||
|
||||
let streamingSucceeded = false;
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let hasReceivedData = false;
|
||||
@@ -248,7 +241,6 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
|
||||
});
|
||||
},
|
||||
() => {
|
||||
streamingSucceeded = true;
|
||||
setIsStreaming(false);
|
||||
abortControllerRef.current = null;
|
||||
resolve();
|
||||
@@ -279,140 +271,26 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
|
||||
return;
|
||||
}
|
||||
|
||||
// Streaming failed - check if auth error, try guest mode
|
||||
const isAuthError =
|
||||
err instanceof Error &&
|
||||
(err.message.includes("403") ||
|
||||
err.message.includes("401") ||
|
||||
err.message.includes("auth") ||
|
||||
err.message.includes("Forbidden"));
|
||||
// Streaming failed — show error (no guest fallback, auth required)
|
||||
console.warn("Streaming failed", {
|
||||
error: err instanceof Error ? err : new Error(String(err)),
|
||||
});
|
||||
|
||||
if (isAuthError) {
|
||||
console.warn("Auth failed, trying guest chat mode");
|
||||
setMessages((prev) => {
|
||||
const withoutPlaceholder = prev.filter((m) => m.id !== assistantMessageId);
|
||||
messagesRef.current = withoutPlaceholder;
|
||||
return withoutPlaceholder;
|
||||
});
|
||||
setIsStreaming(false);
|
||||
setIsLoading(false);
|
||||
|
||||
// Try guest chat streaming
|
||||
try {
|
||||
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;
|
||||
}
|
||||
}
|
||||
const errorMsg = err instanceof Error ? err.message : "Chat unavailable";
|
||||
setError(`Chat error: ${errorMsg}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
|
||||
if (!streamingSucceeded) {
|
||||
return;
|
||||
}
|
||||
|
||||
const finalMessages = messagesRef.current;
|
||||
|
||||
const isFirstMessage =
|
||||
|
||||
Reference in New Issue
Block a user