mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
fix(ui): AI chat panel collapse and session actions
Chat panel fixes: - Fix collapse button not working (use store's isOpenSignal) - Add isOpenSignal accessor for proper SolidJS reactivity - Click-outside handler to close dropdowns Session management UI: - Session actions menu (summarize, diff, revert) - Better tool result handling with exit codes - Continuation messages after command execution - Session title generation from first message OpenCode API additions: - Session summarize/diff/revert endpoints - Better model format handling
This commit is contained in:
@@ -880,9 +880,8 @@ function App() {
|
||||
</div>
|
||||
<ToastContainer />
|
||||
<TokenRevealDialog />
|
||||
{/* Fixed AI Assistant Button - always visible on the side when AI is enabled */}
|
||||
<Show when={aiChatStore.enabled === true && !aiChatStore.isOpen}>
|
||||
{/* This component only shows when chat is closed */}
|
||||
{/* Fixed AI Assistant Button - only shows when chat is CLOSED */}
|
||||
<Show when={aiChatStore.enabled === true && !aiChatStore.isOpenSignal()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => aiChatStore.toggle()}
|
||||
|
||||
@@ -37,6 +37,34 @@ export interface AIStatus {
|
||||
engine: string;
|
||||
}
|
||||
|
||||
// OpenCode Agent (build, code, etc.) with specific permissions and model
|
||||
export interface Agent {
|
||||
name: string;
|
||||
description?: string;
|
||||
mode: 'subagent' | 'primary' | 'all';
|
||||
native?: boolean;
|
||||
hidden?: boolean;
|
||||
color?: string;
|
||||
model?: {
|
||||
providerID: string;
|
||||
modelID: string;
|
||||
};
|
||||
}
|
||||
|
||||
// File change from a session
|
||||
export interface FileChange {
|
||||
path: string;
|
||||
status: 'added' | 'modified' | 'deleted';
|
||||
added: number;
|
||||
removed: number;
|
||||
}
|
||||
|
||||
// Session diff showing all file changes
|
||||
export interface SessionDiff {
|
||||
files: FileChange[];
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
export class OpenCodeAPI {
|
||||
private static baseUrl = '/api/ai';
|
||||
|
||||
@@ -99,6 +127,48 @@ export class OpenCodeAPI {
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// OpenCode Extended Features
|
||||
// ============================================
|
||||
|
||||
// List available agents (build, code, etc.)
|
||||
static async listAgents(): Promise<Agent[]> {
|
||||
return apiFetchJSON(`${this.baseUrl}/agents`) as Promise<Agent[]>;
|
||||
}
|
||||
|
||||
// Summarize a session (compress context when nearing limits)
|
||||
static async summarizeSession(sessionId: string): Promise<{ success: boolean; message?: string }> {
|
||||
return apiFetchJSON(`${this.baseUrl}/sessions/${sessionId}/summarize`, {
|
||||
method: 'POST',
|
||||
}) as Promise<{ success: boolean; message?: string }>;
|
||||
}
|
||||
|
||||
// Get file changes/diff for a session
|
||||
static async getSessionDiff(sessionId: string): Promise<SessionDiff> {
|
||||
return apiFetchJSON(`${this.baseUrl}/sessions/${sessionId}/diff`) as Promise<SessionDiff>;
|
||||
}
|
||||
|
||||
// Fork a session (create a branch point)
|
||||
static async forkSession(sessionId: string): Promise<ChatSession> {
|
||||
return apiFetchJSON(`${this.baseUrl}/sessions/${sessionId}/fork`, {
|
||||
method: 'POST',
|
||||
}) as Promise<ChatSession>;
|
||||
}
|
||||
|
||||
// Revert session changes
|
||||
static async revertSession(sessionId: string): Promise<{ success: boolean }> {
|
||||
return apiFetchJSON(`${this.baseUrl}/sessions/${sessionId}/revert`, {
|
||||
method: 'POST',
|
||||
}) as Promise<{ success: boolean }>;
|
||||
}
|
||||
|
||||
// Unrevert session changes (redo)
|
||||
static async unrevertSession(sessionId: string): Promise<{ success: boolean }> {
|
||||
return apiFetchJSON(`${this.baseUrl}/sessions/${sessionId}/unrevert`, {
|
||||
method: 'POST',
|
||||
}) as Promise<{ success: boolean }>;
|
||||
}
|
||||
|
||||
// Stream chat - the main chat interface
|
||||
static async chat(
|
||||
prompt: string,
|
||||
@@ -139,7 +209,7 @@ export class OpenCodeAPI {
|
||||
const STREAM_TIMEOUT_MS = 300000; // 5 minutes
|
||||
|
||||
try {
|
||||
for (;;) {
|
||||
for (; ;) {
|
||||
if (Date.now() - lastEventTime > STREAM_TIMEOUT_MS) {
|
||||
logger.warn('[OpenCode] Stream timeout');
|
||||
break;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, Show, For, createEffect } from 'solid-js';
|
||||
import { Component, Show, For, createEffect, createMemo } from 'solid-js';
|
||||
import { MessageItem } from './MessageItem';
|
||||
import type { ChatMessage, PendingApproval, PendingQuestion } from './types';
|
||||
|
||||
@@ -18,7 +18,7 @@ interface ChatMessagesProps {
|
||||
|
||||
/**
|
||||
* ChatMessages - Renders the scrollable message list.
|
||||
*
|
||||
*
|
||||
* Features:
|
||||
* - Auto-scroll to bottom on new messages
|
||||
* - Empty state with suggestions
|
||||
@@ -28,15 +28,34 @@ export const ChatMessages: Component<ChatMessagesProps> = (props) => {
|
||||
let messagesEndRef: HTMLDivElement | undefined;
|
||||
let containerRef: HTMLDivElement | undefined;
|
||||
|
||||
// Auto-scroll to bottom on new messages
|
||||
// Track content changes for auto-scroll (not just array length)
|
||||
// This tracks: message count, last message content length, streaming state, and stream events
|
||||
const scrollTrigger = createMemo(() => {
|
||||
const msgs = props.messages;
|
||||
if (msgs.length === 0) return 0;
|
||||
const lastMsg = msgs[msgs.length - 1];
|
||||
// Combine multiple signals to ensure we detect all content updates
|
||||
return msgs.length +
|
||||
(lastMsg.content?.length || 0) +
|
||||
(lastMsg.isStreaming ? 1000000 : 0) +
|
||||
(lastMsg.streamEvents?.length || 0);
|
||||
});
|
||||
|
||||
// Auto-scroll to bottom on new messages or streaming content
|
||||
createEffect(() => {
|
||||
// Access the trigger to establish dependency (void suppresses unused var warning)
|
||||
void scrollTrigger();
|
||||
|
||||
if (props.messages.length > 0 && messagesEndRef && containerRef) {
|
||||
// Only auto-scroll if user is near the bottom
|
||||
// Only auto-scroll if user is near the bottom (within 200px)
|
||||
const { scrollTop, scrollHeight, clientHeight } = containerRef;
|
||||
const isNearBottom = scrollHeight - scrollTop - clientHeight < 150;
|
||||
const isNearBottom = scrollHeight - scrollTop - clientHeight < 200;
|
||||
|
||||
if (isNearBottom) {
|
||||
messagesEndRef.scrollIntoView({ behavior: 'smooth' });
|
||||
// Use instant scroll during active streaming for smoother experience
|
||||
const lastMsg = props.messages[props.messages.length - 1];
|
||||
const behavior = lastMsg.isStreaming ? 'instant' : 'smooth';
|
||||
messagesEndRef.scrollIntoView({ behavior: behavior as ScrollBehavior });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Component, Show, For, Switch, Match, createMemo } from 'solid-js';
|
||||
import { renderMarkdown } from '../aiChatUtils';
|
||||
import { ThinkingBlock } from './ThinkingBlock';
|
||||
import { ToolExecutionBlock, PendingToolBlock } from './ToolExecutionBlock';
|
||||
import { ToolExecutionBlock } from './ToolExecutionBlock';
|
||||
import { ApprovalCard } from './ApprovalCard';
|
||||
import { QuestionCard } from './QuestionCard';
|
||||
import type { ChatMessage, PendingApproval, PendingQuestion, StreamDisplayEvent } from './types';
|
||||
@@ -127,9 +127,9 @@ export const MessageItem: Component<MessageItemProps> = (props) => {
|
||||
/>
|
||||
</Match>
|
||||
|
||||
{/* Pending tool (currently running) - shown in chronological position */}
|
||||
{/* Pending tool - hidden, we only show completed tools */}
|
||||
<Match when={evt.type === 'pending_tool' && evt.pendingTool}>
|
||||
<PendingToolBlock tool={evt.pendingTool!} />
|
||||
<></>
|
||||
</Match>
|
||||
|
||||
{/* Completed tool execution block */}
|
||||
@@ -140,12 +140,12 @@ export const MessageItem: Component<MessageItemProps> = (props) => {
|
||||
{/* Content/text block */}
|
||||
<Match when={evt.type === 'content' && evt.content}>
|
||||
<div
|
||||
class="text-sm prose prose-slate prose-sm dark:prose-invert max-w-none
|
||||
class="text-sm prose prose-slate prose-sm dark:prose-invert max-w-none overflow-x-auto
|
||||
prose-p:leading-relaxed prose-p:my-2
|
||||
prose-pre:bg-slate-900 prose-pre:text-slate-100 prose-pre:rounded-lg prose-pre:text-xs
|
||||
prose-pre:bg-slate-900 prose-pre:text-slate-100 prose-pre:rounded-lg prose-pre:text-xs prose-pre:overflow-x-auto prose-pre:max-w-full
|
||||
prose-code:text-purple-600 dark:prose-code:text-purple-400
|
||||
prose-code:bg-purple-50 dark:prose-code:bg-purple-900/30
|
||||
prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded
|
||||
prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded prose-code:break-all
|
||||
prose-code:before:content-none prose-code:after:content-none
|
||||
prose-headings:text-slate-900 dark:prose-headings:text-slate-100
|
||||
prose-strong:text-slate-900 dark:prose-strong:text-slate-100
|
||||
@@ -183,12 +183,12 @@ export const MessageItem: Component<MessageItemProps> = (props) => {
|
||||
{/* Fallback: show content if no stream events */}
|
||||
<Show when={props.message.content && !hasStreamEvents()}>
|
||||
<div
|
||||
class="text-sm prose prose-slate prose-sm dark:prose-invert max-w-none
|
||||
class="text-sm prose prose-slate prose-sm dark:prose-invert max-w-none overflow-x-auto
|
||||
prose-p:leading-relaxed prose-p:my-2
|
||||
prose-pre:bg-slate-900 prose-pre:text-slate-100 prose-pre:rounded-lg prose-pre:text-xs
|
||||
prose-code:text-purple-600 dark:prose-code:text-purple-400
|
||||
prose-code:bg-purple-50 dark:prose-code:bg-purple-900/30
|
||||
prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded
|
||||
prose-pre:bg-slate-900 prose-pre:text-slate-100 prose-pre:rounded-lg prose-pre:text-xs prose-pre:overflow-x-auto prose-pre:max-w-full
|
||||
prose-code:text-purple-600 dark:prose-code:text-purple-400
|
||||
prose-code:bg-purple-50 dark:prose-code:bg-purple-900/30
|
||||
prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded prose-code:break-all
|
||||
prose-code:before:content-none prose-code:after:content-none
|
||||
prose-headings:text-slate-900 dark:prose-headings:text-slate-100
|
||||
prose-strong:text-slate-900 dark:prose-strong:text-slate-100
|
||||
|
||||
@@ -9,7 +9,7 @@ interface ToolExecutionBlockProps {
|
||||
* ToolExecutionBlock - Displays completed tool executions in a compact terminal-like style.
|
||||
*/
|
||||
export const ToolExecutionBlock: Component<ToolExecutionBlockProps> = (props) => {
|
||||
const [showOutput, setShowOutput] = createSignal(true);
|
||||
const [showOutput, setShowOutput] = createSignal(false); // Collapsed by default like Claude Code
|
||||
|
||||
// Get display name for tool
|
||||
const toolLabel = createMemo(() => {
|
||||
@@ -34,14 +34,26 @@ export const ToolExecutionBlock: Component<ToolExecutionBlockProps> = (props) =>
|
||||
return output.trim().length > 0 && !output.includes('not available');
|
||||
});
|
||||
|
||||
// Truncate output
|
||||
// Show only last few lines by default, full output when expanded
|
||||
const displayOutput = createMemo(() => {
|
||||
const output = props.tool.output || '';
|
||||
const maxLen = 300;
|
||||
if (!showOutput() && output.length > maxLen) {
|
||||
return output.substring(0, maxLen) + '...';
|
||||
if (showOutput()) {
|
||||
// Show full output when expanded
|
||||
return output;
|
||||
}
|
||||
return output;
|
||||
// Show last 3 lines by default
|
||||
const lines = output.split('\n').filter(line => line.trim());
|
||||
if (lines.length <= 3) {
|
||||
return output.trim();
|
||||
}
|
||||
const lastLines = lines.slice(-3).join('\n');
|
||||
return '...\n' + lastLines;
|
||||
});
|
||||
|
||||
const hasMoreOutput = createMemo(() => {
|
||||
const output = props.tool.output || '';
|
||||
const lines = output.split('\n').filter(line => line.trim());
|
||||
return lines.length > 3;
|
||||
});
|
||||
|
||||
const statusIcon = () => props.tool.success ? '✓' : '✗';
|
||||
@@ -53,9 +65,9 @@ export const ToolExecutionBlock: Component<ToolExecutionBlockProps> = (props) =>
|
||||
<div class="my-1 font-mono text-[11px]">
|
||||
{/* Compact single-line header */}
|
||||
<div
|
||||
class={`flex items-center gap-1.5 px-2 py-1 rounded ${hasOutput() ? 'cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-800' : ''
|
||||
class={`flex items-center gap-1.5 px-2 py-1 rounded ${hasMoreOutput() ? 'cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-800' : ''
|
||||
} ${showOutput() ? 'bg-slate-50 dark:bg-slate-800/50' : ''}`}
|
||||
onClick={() => hasOutput() && setShowOutput(!showOutput())}
|
||||
onClick={() => hasMoreOutput() && setShowOutput(!showOutput())}
|
||||
>
|
||||
{/* Status icon */}
|
||||
<span class={`${statusColor()} font-bold`}>{statusIcon()}</span>
|
||||
@@ -70,8 +82,8 @@ export const ToolExecutionBlock: Component<ToolExecutionBlockProps> = (props) =>
|
||||
{props.tool.input.length > 60 ? props.tool.input.substring(0, 60) + '...' : props.tool.input}
|
||||
</code>
|
||||
|
||||
{/* Expand indicator if has output */}
|
||||
<Show when={hasOutput()}>
|
||||
{/* Expand indicator if has more output */}
|
||||
<Show when={hasMoreOutput()}>
|
||||
<svg
|
||||
class={`w-3 h-3 text-slate-400 transition-transform ${showOutput() ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
@@ -83,18 +95,18 @@ export const ToolExecutionBlock: Component<ToolExecutionBlockProps> = (props) =>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Expanded output */}
|
||||
<Show when={showOutput() && hasOutput()}>
|
||||
<div class="ml-4 mt-1 mb-2 pl-2 border-l-2 border-slate-200 dark:border-slate-700">
|
||||
<pre class="text-[10px] text-slate-600 dark:text-slate-400 whitespace-pre-wrap break-words leading-relaxed max-h-40 overflow-y-auto">
|
||||
{/* Output - always show last few lines, expandable for full output */}
|
||||
<Show when={hasOutput()}>
|
||||
<div class="ml-4 mt-1 mb-2 pl-2 border-l-2 border-slate-200 dark:border-slate-700 overflow-hidden">
|
||||
<pre class={`text-[10px] text-slate-600 dark:text-slate-400 whitespace-pre-wrap break-all leading-relaxed overflow-y-auto overflow-x-hidden bg-slate-50 dark:bg-slate-900/50 rounded p-2 ${showOutput() ? 'max-h-64' : 'max-h-20'}`}>
|
||||
{displayOutput()}
|
||||
</pre>
|
||||
<Show when={(props.tool.output || '').length > 300}>
|
||||
<Show when={hasMoreOutput()}>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setShowOutput(!showOutput()); }}
|
||||
class="mt-1 text-[9px] text-purple-600 dark:text-purple-400 hover:underline"
|
||||
>
|
||||
{showOutput() && (props.tool.output || '').length > 300 ? 'Show less' : 'Show all'}
|
||||
{showOutput() ? 'Show less' : 'Show full output'}
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -120,6 +120,12 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
|
||||
case 'tool_start': {
|
||||
const data = event.data as { name: string; input: string };
|
||||
|
||||
// Skip tool_start for "question" - these are handled by the question event type
|
||||
if (data.name === 'question' || data.name === 'Question') {
|
||||
return msg;
|
||||
}
|
||||
|
||||
const toolId = generateId(); // Unique ID to track this tool
|
||||
const pendingTool = { name: data.name, input: data.input };
|
||||
|
||||
@@ -141,8 +147,14 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
const pendingTools = msg.pendingTools || [];
|
||||
const events = msg.streamEvents || [];
|
||||
|
||||
// Find the matching pending tool (by name, since we may not have ID in the event)
|
||||
const matchingPendingIndex = pendingTools.findIndex((t) => t.name === data.name);
|
||||
// Normalize tool name for matching - strip MCP server prefix (pulse_) which may be doubled
|
||||
const normalizeToolName = (name: string) => name.replace(/^(pulse_)+/, '');
|
||||
const normalizedEndName = normalizeToolName(data.name);
|
||||
|
||||
// Find the matching pending tool (by normalized name)
|
||||
const matchingPendingIndex = pendingTools.findIndex(
|
||||
(t) => normalizeToolName(t.name) === normalizedEndName
|
||||
);
|
||||
const updatedPending = matchingPendingIndex >= 0
|
||||
? [...pendingTools.slice(0, matchingPendingIndex), ...pendingTools.slice(matchingPendingIndex + 1)]
|
||||
: pendingTools;
|
||||
@@ -159,7 +171,7 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
let updatedEvents = [...events];
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
const evt = events[i];
|
||||
if (evt.type === 'pending_tool' && evt.pendingTool?.name === data.name) {
|
||||
if (evt.type === 'pending_tool' && normalizeToolName(evt.pendingTool?.name || '') === normalizedEndName) {
|
||||
// Replace pending with completed
|
||||
updatedEvents[i] = { type: 'tool', tool: newToolCall };
|
||||
break;
|
||||
@@ -252,9 +264,24 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
);
|
||||
};
|
||||
|
||||
// Send a message
|
||||
// Send a message - allows sending mid-stream (aborts current response like OpenCode TUI)
|
||||
const sendMessage = async (prompt: string) => {
|
||||
if (!prompt.trim() || isLoading()) return;
|
||||
if (!prompt.trim()) return;
|
||||
|
||||
// If already streaming, abort the current request first
|
||||
if (isLoading() && abortControllerRef) {
|
||||
logger.debug('[useChat] Aborting current stream to send new message');
|
||||
abortControllerRef.abort();
|
||||
abortControllerRef = null;
|
||||
// Mark any streaming messages as stopped
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.isStreaming
|
||||
? { ...msg, isStreaming: false, pendingTools: [] }
|
||||
: msg
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure we have a session for conversation continuity
|
||||
// Without this, every message creates a new session and loses context
|
||||
@@ -436,9 +463,27 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
updateQuestion(messageId, questionId, { isAnswering: true });
|
||||
|
||||
try {
|
||||
// Send answer to OpenCode via API
|
||||
await OpenCodeAPI.answerQuestion(questionId, answers);
|
||||
// Remove the question after successful answer
|
||||
// Remove the question card after successful answer
|
||||
updateQuestion(messageId, questionId, { removed: true });
|
||||
|
||||
// After answering, OpenCode continues processing but the SSE stream has closed.
|
||||
// We need to send a follow-up message to get the continuation.
|
||||
const answerSummary = answers.map(a => a.value).join(', ');
|
||||
logger.debug('[useChat] Question answered, sending continuation', { questionId, answerSummary });
|
||||
|
||||
// Wait for any previous stream to finish
|
||||
if (isLoading()) {
|
||||
logger.debug('[useChat] Waiting for stream to finish before sending answer');
|
||||
const idle = await waitForIdleInternal(10000);
|
||||
if (!idle) {
|
||||
logger.warn('[useChat] Timeout waiting for stream, sending anyway');
|
||||
}
|
||||
}
|
||||
|
||||
// Send the answer as a message to continue the conversation
|
||||
await sendMessage(answerSummary || 'Continue');
|
||||
} catch (error) {
|
||||
logger.error('[useChat] Failed to answer question:', error);
|
||||
notificationStore.error('Failed to answer question');
|
||||
@@ -446,6 +491,49 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
}
|
||||
};
|
||||
|
||||
// Internal helper to wait for idle state
|
||||
const waitForIdleInternal = (timeoutMs: number): Promise<boolean> => {
|
||||
return new Promise((resolve) => {
|
||||
if (!isLoading()) {
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
const startTime = Date.now();
|
||||
const checkInterval = setInterval(() => {
|
||||
if (!isLoading()) {
|
||||
clearInterval(checkInterval);
|
||||
resolve(true);
|
||||
} else if (Date.now() - startTime > timeoutMs) {
|
||||
clearInterval(checkInterval);
|
||||
resolve(false);
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
};
|
||||
|
||||
// Wait for the chat to become idle (not loading)
|
||||
// Useful for sending follow-up messages after approvals
|
||||
const waitForIdle = (timeoutMs = 30000): Promise<boolean> => {
|
||||
return new Promise((resolve) => {
|
||||
if (!isLoading()) {
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const checkInterval = setInterval(() => {
|
||||
if (!isLoading()) {
|
||||
clearInterval(checkInterval);
|
||||
resolve(true);
|
||||
} else if (Date.now() - startTime > timeoutMs) {
|
||||
clearInterval(checkInterval);
|
||||
logger.warn('[useChat] waitForIdle timed out');
|
||||
resolve(false);
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
messages,
|
||||
isLoading,
|
||||
@@ -461,5 +549,6 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
addToolResult,
|
||||
updateQuestion,
|
||||
answerQuestion,
|
||||
waitForIdle,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Component, Show, createSignal, onMount, For, createMemo, createEffect } from 'solid-js';
|
||||
import { Component, Show, createSignal, onMount, onCleanup, For, createMemo, createEffect } from 'solid-js';
|
||||
import { AIAPI } from '@/api/ai';
|
||||
import { OpenCodeAPI, type ChatSession } from '@/api/opencode';
|
||||
import { notificationStore } from '@/stores/notifications';
|
||||
import { aiChatStore } from '@/stores/aiChat';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { useChat } from './hooks/useChat';
|
||||
import { ChatMessages } from './ChatMessages';
|
||||
@@ -23,8 +24,8 @@ interface AIChatProps {
|
||||
* session management, and streaming response display.
|
||||
*/
|
||||
export const AIChat: Component<AIChatProps> = (props) => {
|
||||
// UI state
|
||||
const [isOpen] = createSignal(true);
|
||||
// UI state - use store's isOpenSignal for reactivity
|
||||
const isOpen = aiChatStore.isOpenSignal;
|
||||
const [input, setInput] = createSignal('');
|
||||
const [sessions, setSessions] = createSignal<ChatSession[]>([]);
|
||||
const [showSessions, setShowSessions] = createSignal(false);
|
||||
@@ -35,6 +36,8 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
const [modelQuery, setModelQuery] = createSignal('');
|
||||
const [defaultModel, setDefaultModel] = createSignal('');
|
||||
const [chatOverrideModel, setChatOverrideModel] = createSignal('');
|
||||
const [showSessionActions, setShowSessionActions] = createSignal(false);
|
||||
const [sessionActionLoading, setSessionActionLoading] = createSignal<string | null>(null);
|
||||
|
||||
const loadModelSelections = (): Record<string, string> => {
|
||||
try {
|
||||
@@ -240,6 +243,21 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Click outside handler to close all dropdowns
|
||||
onMount(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
// Only close if click is outside dropdown containers
|
||||
if (!target.closest('[data-dropdown]')) {
|
||||
setShowModelSelector(false);
|
||||
setShowSessions(false);
|
||||
setShowSessionActions(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
onCleanup(() => document.removeEventListener('click', handleClickOutside));
|
||||
});
|
||||
|
||||
// Handle submit
|
||||
const handleSubmit = () => {
|
||||
const prompt = input().trim();
|
||||
@@ -248,7 +266,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
setInput('');
|
||||
};
|
||||
|
||||
// Handle key down
|
||||
// Handle key down - allow sending even while loading (will abort and send)
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
@@ -302,17 +320,55 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
|
||||
// Add tool result if command was executed
|
||||
if (result.approved) {
|
||||
const execResult = (result as { result?: { stdout?: string; stderr?: string; exit_code?: number } }).result;
|
||||
const output = execResult
|
||||
? `Exit code: ${execResult.exit_code}\n${execResult.stdout || ''}${execResult.stderr ? '\nStderr: ' + execResult.stderr : ''}`
|
||||
: 'Command approved but no execution result available.';
|
||||
const typedResult = result as {
|
||||
result?: { stdout?: string; stderr?: string; exit_code?: number };
|
||||
error?: string;
|
||||
message?: string;
|
||||
executed?: boolean;
|
||||
};
|
||||
const execResult = typedResult.result;
|
||||
|
||||
let output: string;
|
||||
let success: boolean;
|
||||
|
||||
if (execResult) {
|
||||
output = `Exit code: ${execResult.exit_code}\n${execResult.stdout || ''}${execResult.stderr ? '\nStderr: ' + execResult.stderr : ''}`;
|
||||
success = execResult.exit_code === 0;
|
||||
} else if (typedResult.error) {
|
||||
output = `Execution failed: ${typedResult.error}`;
|
||||
success = false;
|
||||
} else if (typedResult.message) {
|
||||
output = typedResult.message;
|
||||
success = false;
|
||||
} else {
|
||||
output = 'Command approved but no execution result available.';
|
||||
success = false;
|
||||
}
|
||||
|
||||
chat.addToolResult(messageId, {
|
||||
name: approval.toolName,
|
||||
input: approval.command,
|
||||
output: output.trim(),
|
||||
success: execResult ? execResult.exit_code === 0 : false,
|
||||
success,
|
||||
});
|
||||
|
||||
// Continue the conversation - short message, output is already in the tool result
|
||||
const continuationMessage = success
|
||||
? 'Command executed. Please analyze the result and continue.'
|
||||
: 'Command completed with issues. Please analyze and advise.';
|
||||
|
||||
// Wait for any ongoing stream to complete before sending continuation
|
||||
if (chat.isLoading()) {
|
||||
logger.debug('[AIChat] Waiting for chat to become idle before continuation');
|
||||
const isIdle = await chat.waitForIdle(30000);
|
||||
if (!isIdle) {
|
||||
logger.warn('[AIChat] Timeout waiting for chat to become idle');
|
||||
notificationStore.warning('Chat is busy. Sending continuation anyway...');
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug('[AIChat] Sending continuation message', { sessionId: chat.sessionId() });
|
||||
await chat.sendMessage(continuationMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[AIChat] Approval failed:', error);
|
||||
@@ -373,6 +429,61 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Session action handlers
|
||||
const handleSummarize = async () => {
|
||||
const sessionId = chat.sessionId();
|
||||
if (!sessionId) return;
|
||||
|
||||
setSessionActionLoading('summarize');
|
||||
setShowSessionActions(false);
|
||||
try {
|
||||
await OpenCodeAPI.summarizeSession(sessionId);
|
||||
notificationStore.success('Session summarized to save context');
|
||||
} catch (error) {
|
||||
notificationStore.error('Failed to summarize session');
|
||||
} finally {
|
||||
setSessionActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGetDiff = async () => {
|
||||
const sessionId = chat.sessionId();
|
||||
if (!sessionId) return;
|
||||
|
||||
setSessionActionLoading('diff');
|
||||
setShowSessionActions(false);
|
||||
try {
|
||||
const diff = await OpenCodeAPI.getSessionDiff(sessionId);
|
||||
const files = diff.files || [];
|
||||
if (files.length === 0) {
|
||||
notificationStore.info('No file changes in this session');
|
||||
} else {
|
||||
notificationStore.success(`${files.length} file(s) changed in this session`);
|
||||
// Could open a modal here to show detailed diff
|
||||
}
|
||||
} catch (error) {
|
||||
notificationStore.error('Failed to get session diff');
|
||||
} finally {
|
||||
setSessionActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevert = async () => {
|
||||
const sessionId = chat.sessionId();
|
||||
if (!sessionId) return;
|
||||
|
||||
setSessionActionLoading('revert');
|
||||
setShowSessionActions(false);
|
||||
try {
|
||||
await OpenCodeAPI.revertSession(sessionId);
|
||||
notificationStore.success('Session changes reverted');
|
||||
} catch (error) {
|
||||
notificationStore.error('Failed to revert session');
|
||||
} finally {
|
||||
setSessionActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
class={`flex-shrink-0 h-full bg-white dark:bg-slate-900 border-l border-slate-200 dark:border-slate-700 flex flex-col transition-all duration-300 overflow-hidden ${isOpen() ? 'w-[480px]' : 'w-0 border-l-0'
|
||||
@@ -397,7 +508,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
|
||||
<div class="flex items-center gap-1.5">
|
||||
{/* Model selector */}
|
||||
<div class="relative">
|
||||
<div class="relative" data-dropdown>
|
||||
<button
|
||||
onClick={toggleModelSelector}
|
||||
class="flex items-center gap-1.5 px-2.5 py-1.5 text-[11px] text-slate-600 dark:text-slate-300 hover:text-slate-800 dark:hover:text-slate-100 rounded-lg border border-slate-200 dark:border-slate-700 hover:border-slate-300 dark:hover:border-slate-600 bg-white dark:bg-slate-800 transition-colors"
|
||||
@@ -526,7 +637,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
</div>
|
||||
|
||||
{/* Session picker */}
|
||||
<div class="relative">
|
||||
<div class="relative" data-dropdown>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowModelSelector(false);
|
||||
@@ -590,10 +701,75 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Session Actions Menu */}
|
||||
<div class="relative" data-dropdown>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!chat.sessionId()) {
|
||||
notificationStore.info('Send a message first to start a session');
|
||||
return;
|
||||
}
|
||||
setShowModelSelector(false);
|
||||
setShowSessions(false);
|
||||
setShowSessionActions(!showSessionActions());
|
||||
}}
|
||||
disabled={sessionActionLoading() !== null}
|
||||
class="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Session actions (summarize, diff, revert)"
|
||||
>
|
||||
<Show when={sessionActionLoading()} fallback={
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
|
||||
</svg>
|
||||
}>
|
||||
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
</Show>
|
||||
</button>
|
||||
|
||||
<Show when={showSessionActions()}>
|
||||
<div class="absolute right-0 top-full mt-1 w-48 bg-white dark:bg-slate-800 rounded-xl shadow-xl border border-slate-200 dark:border-slate-700 z-50 overflow-hidden">
|
||||
<button
|
||||
onClick={handleSummarize}
|
||||
class="w-full px-3 py-2 text-left text-sm flex items-center gap-2 text-slate-700 dark:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
>
|
||||
<svg class="w-4 h-4 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16m-7 6h7" />
|
||||
</svg>
|
||||
Summarize context
|
||||
</button>
|
||||
<button
|
||||
onClick={handleGetDiff}
|
||||
class="w-full px-3 py-2 text-left text-sm flex items-center gap-2 text-slate-700 dark:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
>
|
||||
<svg class="w-4 h-4 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
View file changes
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRevert}
|
||||
class="w-full px-3 py-2 text-left text-sm flex items-center gap-2 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 border-t border-slate-200 dark:border-slate-700"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
|
||||
</svg>
|
||||
Revert changes
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={props.onClose}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
props.onClose();
|
||||
}}
|
||||
class="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors"
|
||||
title="Close panel"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 5l7 7-7 7M6 5l7 7-7 7" />
|
||||
@@ -670,24 +846,22 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Ask about your infrastructure..."
|
||||
rows={2}
|
||||
disabled={chat.isLoading()}
|
||||
class="flex-1 px-4 py-3 text-sm rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent resize-none disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
class="flex-1 px-4 py-3 text-sm rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent resize-none"
|
||||
/>
|
||||
<div class="flex flex-col gap-1.5 self-end">
|
||||
<Show
|
||||
when={chat.isLoading()}
|
||||
fallback={
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!input().trim()}
|
||||
class="px-4 py-3 bg-gradient-to-r from-purple-600 to-violet-600 hover:from-purple-700 hover:to-violet-700 text-white rounded-xl disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-lg shadow-purple-500/20 hover:shadow-xl hover:shadow-purple-500/30"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
|
||||
</svg>
|
||||
</button>
|
||||
}
|
||||
<div class="flex gap-1.5 self-end">
|
||||
{/* Send button - always visible, sends new message (aborts current if streaming) */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!input().trim()}
|
||||
class="px-4 py-3 bg-gradient-to-r from-purple-600 to-violet-600 hover:from-purple-700 hover:to-violet-700 text-white rounded-xl disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-lg shadow-purple-500/20 hover:shadow-xl hover:shadow-purple-500/30"
|
||||
title={chat.isLoading() ? "Send (will interrupt current response)" : "Send"}
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
|
||||
</svg>
|
||||
</button>
|
||||
{/* Stop button - only visible while streaming */}
|
||||
<Show when={chat.isLoading()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={chat.stop}
|
||||
|
||||
@@ -122,7 +122,6 @@ export const AISettings: Component = () => {
|
||||
autoFixModel: '', // Empty means use patrol model
|
||||
baseUrl: '', // Legacy - kept for compatibility
|
||||
clearApiKey: false,
|
||||
autonomousMode: false,
|
||||
authMethod: 'api_key' as AuthMethod,
|
||||
patrolIntervalMinutes: 360, // 6 hours default
|
||||
alertTriggeredAnalysis: true,
|
||||
@@ -155,7 +154,6 @@ export const AISettings: Component = () => {
|
||||
autoFixModel: '',
|
||||
baseUrl: '',
|
||||
clearApiKey: false,
|
||||
autonomousMode: false,
|
||||
authMethod: 'api_key',
|
||||
patrolIntervalMinutes: 360, // 6 hours default
|
||||
alertTriggeredAnalysis: true,
|
||||
@@ -185,7 +183,6 @@ export const AISettings: Component = () => {
|
||||
autoFixModel: data.auto_fix_model || '',
|
||||
baseUrl: data.base_url || '',
|
||||
clearApiKey: false,
|
||||
autonomousMode: data.autonomous_mode || false,
|
||||
authMethod: data.auth_method || 'api_key',
|
||||
patrolIntervalMinutes: data.patrol_interval_minutes ?? 360, // Use minutes, default to 6hr
|
||||
alertTriggeredAnalysis: data.alert_triggered_analysis !== false, // default to true
|
||||
@@ -341,11 +338,6 @@ export const AISettings: Component = () => {
|
||||
payload.enabled = form.enabled;
|
||||
}
|
||||
|
||||
// Include autonomous mode if changed
|
||||
if (form.autonomousMode !== settings()?.autonomous_mode) {
|
||||
payload.autonomous_mode = form.autonomousMode;
|
||||
}
|
||||
|
||||
// Include patrol settings if changed
|
||||
if (form.patrolIntervalMinutes !== (settings()?.patrol_interval_minutes ?? 360)) {
|
||||
payload.patrol_interval_minutes = form.patrolIntervalMinutes;
|
||||
@@ -1187,48 +1179,6 @@ export const AISettings: Component = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Autonomous Mode */}
|
||||
<div class={`${formField} p-4 rounded-lg border ${form.autonomousMode ? 'bg-amber-50 dark:bg-amber-900/20 border-amber-200 dark:border-amber-800' : 'bg-gray-50 dark:bg-gray-800/50 border-gray-200 dark:border-gray-700'}`}>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1">
|
||||
<label class={`${labelClass()} flex items-center gap-2`}>
|
||||
Autonomous Mode
|
||||
<Show when={form.autonomousMode}>
|
||||
<span class="px-1.5 py-0.5 text-[10px] font-semibold bg-amber-200 dark:bg-amber-800 text-amber-800 dark:text-amber-200 rounded">
|
||||
ENABLED
|
||||
</span>
|
||||
</Show>
|
||||
</label>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||
{form.autonomousMode
|
||||
? 'AI will execute all commands without asking for approval. Only enable if you trust your configured model.'
|
||||
: 'AI will ask for approval before running commands that modify your system. Read-only commands (like df, ps, docker stats) run automatically.'}
|
||||
</p>
|
||||
<div class="mt-2 p-2 bg-amber-100/50 dark:bg-amber-900/30 rounded border border-amber-200 dark:border-amber-800 text-[10px] text-amber-800 dark:text-amber-200">
|
||||
<strong>⚠️ Legal Disclaimer:</strong> AI models can hallucinate. You are responsible for any damage caused by autonomous actions. See <a href="https://github.com/rcourtman/Pulse/blob/main/TERMS.md" target="_blank" class="underline">Terms of Service</a>.
|
||||
</div>
|
||||
<Show when={autoFixLocked()}>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||
<a
|
||||
class="text-indigo-600 dark:text-indigo-400 font-medium hover:underline"
|
||||
href="https://pulserelay.pro/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Upgrade to Pro
|
||||
</a>{' '}
|
||||
to enable autonomous mode.
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={form.autonomousMode}
|
||||
onChange={(event) => setForm('autonomousMode', event.currentTarget.checked)}
|
||||
disabled={saving() || autoFixLocked()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Patrol & Efficiency Settings - Collapsible */}
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<button
|
||||
@@ -1466,14 +1416,13 @@ export const AISettings: Component = () => {
|
||||
💡 Increase for slow Ollama hardware (default: 300s / 5 min)
|
||||
</p>
|
||||
|
||||
{/* Infrastructure Control Settings */}
|
||||
<div class="space-y-3 p-4 rounded-lg border border-purple-200 dark:border-purple-800 bg-purple-50 dark:bg-purple-900/20">
|
||||
{/* AI Permission Level */}
|
||||
<div class={`space-y-3 p-4 rounded-lg border ${form.controlLevel === 'autonomous' ? 'border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20' : 'border-purple-200 dark:border-purple-800 bg-purple-50 dark:bg-purple-900/20'}`}>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Infrastructure Control</span>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">AI Permission Level</span>
|
||||
<Show when={form.controlLevel !== 'read_only'}>
|
||||
<span class={`px-1.5 py-0.5 text-[10px] font-medium rounded ${
|
||||
form.controlLevel === 'autonomous'
|
||||
@@ -1487,9 +1436,9 @@ export const AISettings: Component = () => {
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Control Level */}
|
||||
{/* Permission Level */}
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="text-xs font-medium text-gray-600 dark:text-gray-400 w-28 flex-shrink-0">Control Level</label>
|
||||
<label class="text-xs font-medium text-gray-600 dark:text-gray-400 w-28 flex-shrink-0">Permission</label>
|
||||
<select
|
||||
value={form.controlLevel}
|
||||
onChange={(e) => setForm('controlLevel', e.currentTarget.value as 'read_only' | 'suggest' | 'controlled' | 'autonomous')}
|
||||
@@ -1497,17 +1446,35 @@ export const AISettings: Component = () => {
|
||||
disabled={saving()}
|
||||
>
|
||||
<option value="read_only">Read Only - AI can only observe</option>
|
||||
<option value="suggest">Suggest - AI suggests commands to copy/paste</option>
|
||||
<option value="controlled">Controlled - AI executes with approval</option>
|
||||
<option value="suggest">Suggest - AI suggests commands for you to run</option>
|
||||
<option value="controlled">Controlled - AI executes with your approval</option>
|
||||
<option value="autonomous">Autonomous - AI executes without approval (Pro)</option>
|
||||
</select>
|
||||
</div>
|
||||
<p class="text-[10px] text-gray-500 dark:text-gray-400 ml-[7.5rem]">
|
||||
{form.controlLevel === 'read_only' && '🔒 AI can only query infrastructure, no control actions'}
|
||||
{form.controlLevel === 'suggest' && '💬 AI suggests commands like "pct stop 101" for you to run'}
|
||||
{form.controlLevel === 'controlled' && '✅ AI can start/stop VMs and containers with your approval'}
|
||||
{form.controlLevel === 'autonomous' && '⚠️ AI executes control actions without asking'}
|
||||
{form.controlLevel === 'read_only' && '🔒 AI can only query and observe - no commands or control actions'}
|
||||
{form.controlLevel === 'suggest' && '💬 AI suggests commands for you to copy/paste and run manually'}
|
||||
{form.controlLevel === 'controlled' && '✅ AI can execute commands and control VMs/containers with your approval'}
|
||||
{form.controlLevel === 'autonomous' && '⚠️ AI executes all commands and control actions without asking'}
|
||||
</p>
|
||||
<Show when={form.controlLevel === 'autonomous'}>
|
||||
<div class="p-2 bg-amber-100/50 dark:bg-amber-900/30 rounded border border-amber-200 dark:border-amber-800 text-[10px] text-amber-800 dark:text-amber-200">
|
||||
<strong>Legal Disclaimer:</strong> AI models can hallucinate. You are responsible for any damage caused by autonomous actions. See <a href="https://github.com/rcourtman/Pulse/blob/main/TERMS.md" target="_blank" class="underline">Terms of Service</a>.
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={form.controlLevel === 'autonomous' && autoFixLocked()}>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<a
|
||||
class="text-indigo-600 dark:text-indigo-400 font-medium hover:underline"
|
||||
href="https://pulserelay.pro/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Upgrade to Pro
|
||||
</a>{' '}
|
||||
to enable autonomous mode.
|
||||
</p>
|
||||
</Show>
|
||||
|
||||
{/* Protected Guests - Only show if control is enabled */}
|
||||
<Show when={form.controlLevel !== 'read_only'}>
|
||||
|
||||
@@ -133,11 +133,14 @@ const loadSessionFromServer = async (_sessionId: string): Promise<boolean> => {
|
||||
};
|
||||
|
||||
export const aiChatStore = {
|
||||
// Check if chat is open
|
||||
// Check if chat is open (non-reactive getter for simple checks)
|
||||
get isOpen() {
|
||||
return isAIChatOpen();
|
||||
},
|
||||
|
||||
// Reactive accessor - use this in Show/createEffect for proper reactivity
|
||||
isOpenSignal: isAIChatOpen,
|
||||
|
||||
// Get current context (legacy single-item)
|
||||
get context() {
|
||||
return aiChatContext();
|
||||
|
||||
Reference in New Issue
Block a user