feat: AI copilot panel — chat UI with streaming, tool visualization, OAuth settings
Add the XO copilot right-side panel with: - Pinia store (copilot.store.ts) managing panel state, messages, streaming, and token tracking - useCopilot composable with mock streaming (30-100ms/word) and context-aware responses - CopilotPanel: 320px collapsible panel with header, scrollable message list, and input area - CopilotMessage: markdown rendering (bold, code, lists) with streaming cursor animation - CopilotToolViz: color-coded tool call cards (green=terminal, yellow=SFTP, blue=RDP) with pending spinner, done checkmark, expandable input/result JSON - CopilotSettings: model selector, token usage, clear history, connect/disconnect - MainLayout integration: ghost icon toggle in toolbar, Ctrl+Shift+K shortcut, CSS slide transition, content area resizes (not overlay) All Wails AIService bindings are TODOs with mock behavior. Pure Tailwind CSS, no external markdown library. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1793576030
commit
be868e8172
117
frontend/src/components/copilot/CopilotMessage.vue
Normal file
117
frontend/src/components/copilot/CopilotMessage.vue
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex mb-3"
|
||||||
|
:class="message.role === 'user' ? 'justify-end' : 'justify-start'"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="max-w-[90%] rounded-lg px-3 py-2 text-sm bg-[#161b22] border-l-2"
|
||||||
|
:class="message.role === 'user' ? 'border-[#58a6ff]' : 'border-[#3fb950]'"
|
||||||
|
>
|
||||||
|
<!-- Rendered content -->
|
||||||
|
<div
|
||||||
|
v-if="message.role === 'assistant'"
|
||||||
|
class="copilot-md text-[#e0e0e0] leading-relaxed break-words"
|
||||||
|
v-html="renderedContent"
|
||||||
|
/>
|
||||||
|
<div v-else class="text-[#e0e0e0] leading-relaxed break-words whitespace-pre-wrap">
|
||||||
|
{{ message.content }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tool call visualizations (assistant only) -->
|
||||||
|
<CopilotToolViz
|
||||||
|
v-for="tc in message.toolCalls"
|
||||||
|
:key="tc.id"
|
||||||
|
:tool-call="tc"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Streaming cursor -->
|
||||||
|
<span
|
||||||
|
v-if="isStreaming && message.role === 'assistant'"
|
||||||
|
class="inline-block w-2 h-4 bg-[#3fb950] align-middle animate-pulse ml-0.5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import type { Message } from "@/stores/copilot.store";
|
||||||
|
import CopilotToolViz from "./CopilotToolViz.vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
message: Message;
|
||||||
|
isStreaming?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple regex-based markdown renderer.
|
||||||
|
* Handles: bold, inline code, code blocks, list items, newlines.
|
||||||
|
* No external library needed.
|
||||||
|
*/
|
||||||
|
const renderedContent = computed(() => {
|
||||||
|
let text = props.message.content;
|
||||||
|
|
||||||
|
// Escape HTML
|
||||||
|
text = text
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">");
|
||||||
|
|
||||||
|
// Code blocks (```...```)
|
||||||
|
text = text.replace(
|
||||||
|
/```(\w*)\n?([\s\S]*?)```/g,
|
||||||
|
'<pre class="bg-[#0d1117] rounded p-2 my-1.5 overflow-x-auto text-xs font-mono"><code>$2</code></pre>',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Inline code (`...`)
|
||||||
|
text = text.replace(
|
||||||
|
/`([^`]+)`/g,
|
||||||
|
'<code class="bg-[#0d1117] text-[#79c0ff] px-1 py-0.5 rounded text-xs font-mono">$1</code>',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Bold (**...**)
|
||||||
|
text = text.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
|
||||||
|
|
||||||
|
// Italic (*...*)
|
||||||
|
text = text.replace(/\*(.+?)\*/g, "<em>$1</em>");
|
||||||
|
|
||||||
|
// List items (- item)
|
||||||
|
text = text.replace(/^- (.+)$/gm, '<li class="ml-3 list-disc">$1</li>');
|
||||||
|
|
||||||
|
// Wrap consecutive <li> in <ul>
|
||||||
|
text = text.replace(
|
||||||
|
/(<li[^>]*>.*?<\/li>\n?)+/g,
|
||||||
|
'<ul class="my-1">$&</ul>',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Newlines to <br> (except inside <pre>)
|
||||||
|
text = text.replace(/\n(?!<\/?pre|<\/?ul|<\/?li)/g, "<br>");
|
||||||
|
|
||||||
|
return text;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Markdown styling within copilot messages */
|
||||||
|
.copilot-md :deep(strong) {
|
||||||
|
color: #e0e0e0;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copilot-md :deep(em) {
|
||||||
|
color: #8b949e;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copilot-md :deep(code) {
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copilot-md :deep(pre) {
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copilot-md :deep(ul) {
|
||||||
|
padding-left: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
237
frontend/src/components/copilot/CopilotPanel.vue
Normal file
237
frontend/src/components/copilot/CopilotPanel.vue
Normal file
@ -0,0 +1,237 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex flex-col h-full bg-[#0d1117] border-l border-[#30363d] overflow-hidden"
|
||||||
|
:style="{ width: panelWidth + 'px' }"
|
||||||
|
>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between px-3 py-2 bg-[#161b22] border-b border-[#30363d] shrink-0">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<!-- Ghost icon -->
|
||||||
|
<span class="text-base" title="XO AI Copilot">{{ ghostIcon }}</span>
|
||||||
|
<span class="text-sm font-bold text-[#58a6ff]">XO</span>
|
||||||
|
<span class="text-[10px] text-[#484f58]">{{ modelShort }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<!-- Token counter -->
|
||||||
|
<span class="text-[10px] text-[#484f58]">
|
||||||
|
{{ formatTokens(store.tokenUsage.input) }} / {{ formatTokens(store.tokenUsage.output) }}
|
||||||
|
</span>
|
||||||
|
<!-- Settings button -->
|
||||||
|
<button
|
||||||
|
class="text-[#8b949e] hover:text-[#e0e0e0] transition-colors cursor-pointer"
|
||||||
|
title="Settings"
|
||||||
|
@click="store.showSettings = true"
|
||||||
|
>
|
||||||
|
<svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M7.429 1.525a6.593 6.593 0 011.142 0c.036.003.108.036.137.146l.289 1.105c.147.56.55.967.997 1.189.174.086.341.183.501.29.417.278.97.423 1.53.27l1.102-.303c.11-.03.175.016.195.046.219.31.41.641.573.989.014.031.022.11-.059.19l-.815.806c-.411.406-.562.957-.53 1.456a4.588 4.588 0 010 .582c-.032.499.119 1.05.53 1.456l.815.806c.08.08.073.159.059.19a6.494 6.494 0 01-.573.99c-.02.029-.086.074-.195.045l-1.103-.303c-.559-.153-1.112-.008-1.529.27-.16.107-.327.204-.5.29-.449.222-.851.628-.998 1.189l-.289 1.105c-.029.11-.101.143-.137.146a6.613 6.613 0 01-1.142 0c-.036-.003-.108-.037-.137-.146l-.289-1.105c-.147-.56-.55-.967-.997-1.189a4.502 4.502 0 01-.501-.29c-.417-.278-.97-.423-1.53-.27l-1.102.303c-.11.03-.175-.016-.195-.046a6.492 6.492 0 01-.573-.989c-.014-.031-.022-.11.059-.19l.815-.806c.411-.406.562-.957.53-1.456a4.587 4.587 0 010-.582c.032-.499-.119-1.05-.53-1.456l-.815-.806c-.08-.08-.073-.159-.059-.19a6.44 6.44 0 01.573-.99c.02-.029.086-.074.195-.045l1.103.303c.559.153 1.112.008 1.529-.27.16-.107.327-.204.5-.29.449-.222.851-.628.998-1.189l.289-1.105c.029-.11.101-.143.137-.146zM8 0c-.236 0-.47.01-.701.03-.743.065-1.29.615-1.458 1.261l-.29 1.106c-.017.066-.078.158-.211.224a5.994 5.994 0 00-.668.386c-.123.082-.233.117-.3.1L3.27 2.801c-.635-.175-1.322.017-1.768.681A7.963 7.963 0 00.767 4.905c-.324.627-.2 1.358.246 1.8l.816.806c.049.048.098.153.088.313a6.088 6.088 0 000 .352c.01.16-.04.265-.088.313l-.816.806c-.446.443-.57 1.173-.246 1.8a7.96 7.96 0 00.736 1.323c.446.664 1.133.856 1.768.681l1.103-.303c.066-.018.177.018.3.1.22.147.447.28.668.386.133.066.194.158.212.224l.289 1.106c.169.646.715 1.196 1.458 1.26a8.094 8.094 0 001.402 0c.743-.064 1.29-.614 1.458-1.26l.29-1.106c.017-.066.078-.158.211-.224a5.98 5.98 0 00.668-.386c.123-.082.233-.117.3-.1l1.102.302c.635.175 1.322-.017 1.768-.68a7.96 7.96 0 00.736-1.324c.324-.627.2-1.357-.246-1.8l-.816-.805c-.048-.048-.098-.153-.088-.313a6.15 6.15 0 000-.352c-.01-.16.04-.265.088-.313l.816-.806c.446-.443.57-1.173.246-1.8a7.963 7.963 0 00-.736-1.323c-.446-.664-1.133-.856-1.768-.681l-1.103.303c-.066.018-.176-.018-.3-.1a5.99 5.99 0 00-.667-.386c-.133-.066-.194-.158-.212-.224L10.16 1.29C9.99.645 9.444.095 8.701.031A8.094 8.094 0 008 0zm1.5 8a1.5 1.5 0 11-3 0 1.5 1.5 0 013 0zM11 8a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<!-- Close button -->
|
||||||
|
<button
|
||||||
|
class="text-[#8b949e] hover:text-[#e0e0e0] transition-colors cursor-pointer"
|
||||||
|
title="Close panel (Ctrl+Shift+K)"
|
||||||
|
@click="store.togglePanel()"
|
||||||
|
>
|
||||||
|
<svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
<path d="M3.72 3.72a.75.75 0 011.06 0L8 6.94l3.22-3.22a.75.75 0 111.06 1.06L9.06 8l3.22 3.22a.75.75 0 11-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 01-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 010-1.06z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Unauthenticated state -->
|
||||||
|
<div
|
||||||
|
v-if="!store.isAuthenticated"
|
||||||
|
class="flex-1 flex flex-col items-center justify-center px-6"
|
||||||
|
>
|
||||||
|
<span class="text-4xl mb-4">{{ ghostIcon }}</span>
|
||||||
|
<p class="text-sm text-[#8b949e] text-center mb-4">
|
||||||
|
Connect to Claude to activate the XO AI copilot.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
class="px-4 py-2 text-sm font-medium text-white bg-[#1f6feb] hover:bg-[#388bfd] rounded transition-colors cursor-pointer"
|
||||||
|
@click="store.startLogin()"
|
||||||
|
>
|
||||||
|
Connect to Claude
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Message list -->
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
ref="messageListRef"
|
||||||
|
class="flex-1 overflow-y-auto px-3 py-3"
|
||||||
|
>
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div
|
||||||
|
v-if="store.messages.length === 0"
|
||||||
|
class="flex flex-col items-center justify-center h-full text-center"
|
||||||
|
>
|
||||||
|
<span class="text-3xl mb-3 opacity-40">{{ ghostIcon }}</span>
|
||||||
|
<p class="text-sm text-[#8b949e]">XO standing by.</p>
|
||||||
|
<p class="text-xs text-[#484f58] mt-1">Send a message to begin.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Messages -->
|
||||||
|
<CopilotMessage
|
||||||
|
v-for="msg in store.messages"
|
||||||
|
:key="msg.id"
|
||||||
|
:message="msg"
|
||||||
|
:is-streaming="store.isStreaming && msg.id === lastAssistantId"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Input area (authenticated only) -->
|
||||||
|
<div
|
||||||
|
v-if="store.isAuthenticated"
|
||||||
|
class="shrink-0 border-t border-[#30363d] p-3 bg-[#161b22]"
|
||||||
|
>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<textarea
|
||||||
|
ref="inputRef"
|
||||||
|
v-model="inputText"
|
||||||
|
:disabled="store.isStreaming"
|
||||||
|
placeholder="Message the XO..."
|
||||||
|
rows="1"
|
||||||
|
class="flex-1 resize-none px-2.5 py-1.5 text-sm rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] placeholder-[#484f58] outline-none focus:border-[#58a6ff] transition-colors disabled:opacity-50"
|
||||||
|
@keydown="handleInputKeydown"
|
||||||
|
@input="autoResize"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="shrink-0 px-2.5 py-1.5 rounded transition-colors cursor-pointer"
|
||||||
|
:class="
|
||||||
|
canSend
|
||||||
|
? 'bg-[#1f6feb] hover:bg-[#388bfd] text-white'
|
||||||
|
: 'bg-[#21262d] text-[#484f58] cursor-not-allowed'
|
||||||
|
"
|
||||||
|
:disabled="!canSend"
|
||||||
|
title="Send message"
|
||||||
|
@click="handleSend"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
<path d="M.989 8 .064 2.68a1.342 1.342 0 0 1 1.85-1.462l13.402 5.744a1.13 1.13 0 0 1 0 2.076L1.913 14.782a1.343 1.343 0 0 1-1.85-1.463L.99 8Zm.603-5.108L2.18 7.25h4.57a.75.75 0 0 1 0 1.5H2.18l-.588 4.358L14.233 8 1.592 2.892Z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- Streaming indicator -->
|
||||||
|
<div v-if="store.isStreaming" class="flex items-center gap-1.5 mt-1.5 text-[10px] text-[#3fb950]">
|
||||||
|
<span class="w-1.5 h-1.5 rounded-full bg-[#3fb950] animate-pulse" />
|
||||||
|
XO is responding...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Settings modal -->
|
||||||
|
<CopilotSettings v-if="store.showSettings" @close="store.showSettings = false" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch, nextTick } from "vue";
|
||||||
|
import { useCopilotStore } from "@/stores/copilot.store";
|
||||||
|
import { useCopilot } from "@/composables/useCopilot";
|
||||||
|
import CopilotMessage from "./CopilotMessage.vue";
|
||||||
|
import CopilotSettings from "./CopilotSettings.vue";
|
||||||
|
|
||||||
|
const store = useCopilotStore();
|
||||||
|
const { processMessage } = useCopilot();
|
||||||
|
|
||||||
|
const panelWidth = 320;
|
||||||
|
const inputText = ref("");
|
||||||
|
const inputRef = ref<HTMLTextAreaElement | null>(null);
|
||||||
|
const messageListRef = ref<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
/** Short model name for header display. */
|
||||||
|
const modelShort = computed(() => {
|
||||||
|
const m = store.model;
|
||||||
|
if (m.includes("sonnet")) return "sonnet";
|
||||||
|
if (m.includes("opus")) return "opus";
|
||||||
|
if (m.includes("haiku")) return "haiku";
|
||||||
|
return m;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Token formatter. */
|
||||||
|
function formatTokens(n: number): string {
|
||||||
|
if (n >= 1000) return (n / 1000).toFixed(1) + "K";
|
||||||
|
return String(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ID of the last assistant message (for streaming cursor). */
|
||||||
|
const lastAssistantId = computed(() => {
|
||||||
|
for (let i = store.messages.length - 1; i >= 0; i--) {
|
||||||
|
if (store.messages[i].role === "assistant") return store.messages[i].id;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Whether the send button should be active. */
|
||||||
|
const canSend = computed(() => inputText.value.trim().length > 0 && !store.isStreaming);
|
||||||
|
|
||||||
|
/** Send the message. */
|
||||||
|
async function handleSend(): Promise<void> {
|
||||||
|
const text = inputText.value.trim();
|
||||||
|
if (!text || store.isStreaming) return;
|
||||||
|
|
||||||
|
inputText.value = "";
|
||||||
|
resetTextareaHeight();
|
||||||
|
|
||||||
|
store.sendMessage(text);
|
||||||
|
await nextTick();
|
||||||
|
scrollToBottom();
|
||||||
|
|
||||||
|
await processMessage(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handle keyboard shortcuts in the input area. */
|
||||||
|
function handleInputKeydown(event: KeyboardEvent): void {
|
||||||
|
if (event.key === "Enter" && !event.shiftKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
handleSend();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Auto-resize textarea based on content. */
|
||||||
|
function autoResize(): void {
|
||||||
|
const el = inputRef.value;
|
||||||
|
if (!el) return;
|
||||||
|
el.style.height = "auto";
|
||||||
|
el.style.height = Math.min(el.scrollHeight, 120) + "px";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset textarea height back to single line. */
|
||||||
|
function resetTextareaHeight(): void {
|
||||||
|
const el = inputRef.value;
|
||||||
|
if (!el) return;
|
||||||
|
el.style.height = "auto";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scroll the message list to the bottom. */
|
||||||
|
function scrollToBottom(): void {
|
||||||
|
const el = messageListRef.value;
|
||||||
|
if (!el) return;
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Auto-scroll when messages change or streaming content updates. */
|
||||||
|
watch(
|
||||||
|
() => store.messages.length,
|
||||||
|
async () => {
|
||||||
|
await nextTick();
|
||||||
|
scrollToBottom();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => {
|
||||||
|
const msgs = store.messages;
|
||||||
|
if (msgs.length === 0) return "";
|
||||||
|
return msgs[msgs.length - 1].content;
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
await nextTick();
|
||||||
|
scrollToBottom();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// The ghost icon used in multiple places
|
||||||
|
const ghostIcon = "\uD83D\uDC7B";
|
||||||
|
</script>
|
||||||
137
frontend/src/components/copilot/CopilotSettings.vue
Normal file
137
frontend/src/components/copilot/CopilotSettings.vue
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Backdrop -->
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||||
|
@click.self="emit('close')"
|
||||||
|
>
|
||||||
|
<!-- Modal -->
|
||||||
|
<div class="w-80 bg-[#161b22] border border-[#30363d] rounded-lg shadow-2xl overflow-hidden">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between px-4 py-3 border-b border-[#30363d]">
|
||||||
|
<h3 class="text-sm font-semibold text-[#e0e0e0]">XO Settings</h3>
|
||||||
|
<button
|
||||||
|
class="text-[#8b949e] hover:text-[#e0e0e0] transition-colors cursor-pointer"
|
||||||
|
@click="emit('close')"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
<path d="M3.72 3.72a.75.75 0 011.06 0L8 6.94l3.22-3.22a.75.75 0 111.06 1.06L9.06 8l3.22 3.22a.75.75 0 11-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 01-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 010-1.06z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<div class="px-4 py-4 space-y-4">
|
||||||
|
<!-- Auth section -->
|
||||||
|
<div v-if="!store.isAuthenticated">
|
||||||
|
<button
|
||||||
|
class="w-full px-3 py-2 text-sm font-medium text-white bg-[#1f6feb] hover:bg-[#388bfd] rounded transition-colors cursor-pointer"
|
||||||
|
@click="handleLogin"
|
||||||
|
>
|
||||||
|
Connect to Claude
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<label class="block text-xs text-[#8b949e] mb-1">Or enter API Key</label>
|
||||||
|
<input
|
||||||
|
v-model="apiKey"
|
||||||
|
type="password"
|
||||||
|
placeholder="sk-ant-..."
|
||||||
|
class="w-full px-2.5 py-1.5 text-xs rounded bg-[#0d1117] border border-[#30363d] text-[#e0e0e0] placeholder-[#484f58] outline-none focus:border-[#58a6ff] transition-colors"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="mt-2 w-full px-3 py-1.5 text-xs font-medium text-[#e0e0e0] bg-[#21262d] hover:bg-[#30363d] border border-[#30363d] rounded transition-colors cursor-pointer"
|
||||||
|
:disabled="!apiKey.trim()"
|
||||||
|
@click="handleApiKey"
|
||||||
|
>
|
||||||
|
Authenticate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Model selector -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-[#8b949e] mb-1">Model</label>
|
||||||
|
<select
|
||||||
|
v-model="store.model"
|
||||||
|
class="w-full px-2.5 py-1.5 text-xs rounded bg-[#0d1117] border border-[#30363d] text-[#e0e0e0] outline-none focus:border-[#58a6ff] transition-colors cursor-pointer appearance-none"
|
||||||
|
>
|
||||||
|
<option value="claude-sonnet-4-5-20250514">claude-sonnet-4-5-20250514</option>
|
||||||
|
<option value="claude-opus-4-5-20250414">claude-opus-4-5-20250414</option>
|
||||||
|
<option value="claude-haiku-4-5-20251001">claude-haiku-4-5-20251001</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Token usage -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-[#8b949e] mb-1">Token Usage</label>
|
||||||
|
<div class="flex items-center gap-3 text-xs text-[#e0e0e0]">
|
||||||
|
<span>
|
||||||
|
<span class="text-[#8b949e]">In:</span> {{ formatTokens(store.tokenUsage.input) }}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<span class="text-[#8b949e]">Out:</span> {{ formatTokens(store.tokenUsage.output) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="space-y-2 pt-2 border-t border-[#30363d]">
|
||||||
|
<button
|
||||||
|
class="w-full px-3 py-1.5 text-xs font-medium text-[#e0e0e0] bg-[#21262d] hover:bg-[#30363d] border border-[#30363d] rounded transition-colors cursor-pointer"
|
||||||
|
@click="handleClearHistory"
|
||||||
|
>
|
||||||
|
Clear History
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="store.isAuthenticated"
|
||||||
|
class="w-full px-3 py-1.5 text-xs font-medium text-[#f85149] bg-[#21262d] hover:bg-[#30363d] border border-[#30363d] rounded transition-colors cursor-pointer"
|
||||||
|
@click="handleDisconnect"
|
||||||
|
>
|
||||||
|
Disconnect
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { useCopilotStore } from "@/stores/copilot.store";
|
||||||
|
|
||||||
|
const store = useCopilotStore();
|
||||||
|
const apiKey = ref("");
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "close"): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function handleLogin(): void {
|
||||||
|
// TODO: Wails AIService.StartLogin()
|
||||||
|
store.startLogin();
|
||||||
|
emit("close");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleApiKey(): void {
|
||||||
|
if (!apiKey.value.trim()) return;
|
||||||
|
// TODO: Wails AIService.SetApiKey(apiKey)
|
||||||
|
store.isAuthenticated = true;
|
||||||
|
apiKey.value = "";
|
||||||
|
emit("close");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClearHistory(): void {
|
||||||
|
store.clearHistory();
|
||||||
|
emit("close");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDisconnect(): void {
|
||||||
|
store.disconnect();
|
||||||
|
emit("close");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTokens(n: number): string {
|
||||||
|
if (n >= 1000) return (n / 1000).toFixed(1) + "K";
|
||||||
|
return String(n);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
130
frontend/src/components/copilot/CopilotToolViz.vue
Normal file
130
frontend/src/components/copilot/CopilotToolViz.vue
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="rounded border overflow-hidden my-2 text-xs"
|
||||||
|
:class="borderClass"
|
||||||
|
>
|
||||||
|
<!-- Tool call header — clickable to expand -->
|
||||||
|
<button
|
||||||
|
class="w-full flex items-center gap-2 px-2.5 py-1.5 bg-[#21262d] hover:bg-[#282e36] transition-colors cursor-pointer text-left"
|
||||||
|
@click="expanded = !expanded"
|
||||||
|
>
|
||||||
|
<!-- Color bar -->
|
||||||
|
<span class="w-0.5 h-4 rounded-full shrink-0" :class="barColorClass" />
|
||||||
|
|
||||||
|
<!-- Status indicator -->
|
||||||
|
<span v-if="toolCall.status === 'pending'" class="shrink-0">
|
||||||
|
<svg class="w-3.5 h-3.5 animate-spin text-[#8b949e]" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" class="opacity-25" />
|
||||||
|
<path d="M4 12a8 8 0 018-8" stroke="currentColor" stroke-width="3" stroke-linecap="round" class="opacity-75" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span v-else-if="toolCall.status === 'done'" class="shrink-0 text-[#3fb950]">
|
||||||
|
<svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
<path d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span v-else class="shrink-0 text-[#f85149]">
|
||||||
|
<svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
<path d="M3.72 3.72a.75.75 0 011.06 0L8 6.94l3.22-3.22a.75.75 0 111.06 1.06L9.06 8l3.22 3.22a.75.75 0 11-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 01-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 010-1.06z" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Icon + summary -->
|
||||||
|
<span class="text-[#e0e0e0] truncate">{{ icon }} {{ summary }}</span>
|
||||||
|
|
||||||
|
<!-- Expand chevron -->
|
||||||
|
<svg
|
||||||
|
class="w-3 h-3 ml-auto shrink-0 text-[#8b949e] transition-transform"
|
||||||
|
:class="{ 'rotate-90': expanded }"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path d="M6.22 3.22a.75.75 0 011.06 0l4.25 4.25a.75.75 0 010 1.06l-4.25 4.25a.75.75 0 01-1.06-1.06L9.94 8 6.22 4.28a.75.75 0 010-1.06z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Expanded details -->
|
||||||
|
<div v-if="expanded" class="border-t border-[#30363d] bg-[#161b22]">
|
||||||
|
<div class="px-2.5 py-2">
|
||||||
|
<div class="text-[10px] text-[#8b949e] mb-1 uppercase tracking-wide">Input</div>
|
||||||
|
<pre class="text-[11px] text-[#e0e0e0] whitespace-pre-wrap break-all font-mono bg-[#0d1117] rounded p-2">{{ formattedInput }}</pre>
|
||||||
|
</div>
|
||||||
|
<div v-if="toolCall.result !== undefined" class="px-2.5 pb-2">
|
||||||
|
<div class="text-[10px] text-[#8b949e] mb-1 uppercase tracking-wide">Result</div>
|
||||||
|
<pre class="text-[11px] text-[#e0e0e0] whitespace-pre-wrap break-all font-mono bg-[#0d1117] rounded p-2">{{ formattedResult }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from "vue";
|
||||||
|
import type { ToolCall } from "@/stores/copilot.store";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
toolCall: ToolCall;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const expanded = ref(false);
|
||||||
|
|
||||||
|
/** Border color class based on tool call status. */
|
||||||
|
const borderClass = computed(() => {
|
||||||
|
if (props.toolCall.status === "error") return "border-[#f85149]/30";
|
||||||
|
return "border-[#30363d]";
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Left color bar based on tool type. */
|
||||||
|
const barColorClass = computed(() => {
|
||||||
|
const name = props.toolCall.name;
|
||||||
|
if (name.startsWith("terminal_") || name === "terminal_cwd") return "bg-[#3fb950]";
|
||||||
|
if (name.startsWith("sftp_")) return "bg-[#e3b341]";
|
||||||
|
if (name.startsWith("rdp_")) return "bg-[#1f6feb]";
|
||||||
|
return "bg-[#8b949e]";
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Icon for the tool type. */
|
||||||
|
const icon = computed(() => {
|
||||||
|
const name = props.toolCall.name;
|
||||||
|
const icons: Record<string, string> = {
|
||||||
|
terminal_write: "\u27E9",
|
||||||
|
terminal_read: "\u25CE",
|
||||||
|
terminal_cwd: "\u27E9",
|
||||||
|
sftp_read: "\uD83D\uDCC4",
|
||||||
|
sftp_write: "\uD83D\uDCBE",
|
||||||
|
rdp_screenshot: "\uD83D\uDCF8",
|
||||||
|
rdp_click: "\uD83D\uDDB1",
|
||||||
|
rdp_type: "\u2328",
|
||||||
|
list_sessions: "\u2261",
|
||||||
|
connect_session: "\u2192",
|
||||||
|
disconnect_session: "\u2717",
|
||||||
|
};
|
||||||
|
return icons[name] ?? "\u2022";
|
||||||
|
});
|
||||||
|
|
||||||
|
/** One-line summary of the tool call. */
|
||||||
|
const summary = computed(() => {
|
||||||
|
const name = props.toolCall.name;
|
||||||
|
const input = props.toolCall.input;
|
||||||
|
|
||||||
|
if (name === "terminal_write") return `Typed \`${input.text}\` in session ${input.sessionId}`;
|
||||||
|
if (name === "terminal_read") return `Read ${input.lines ?? "?"} lines from session ${input.sessionId}`;
|
||||||
|
if (name === "terminal_cwd") return `Working directory in session ${input.sessionId}`;
|
||||||
|
if (name === "sftp_read") return `Read ${input.path}`;
|
||||||
|
if (name === "sftp_write") return `Wrote ${input.path}`;
|
||||||
|
if (name === "rdp_screenshot") return `Screenshot from session ${input.sessionId}`;
|
||||||
|
if (name === "rdp_click") return `Clicked at (${input.x}, ${input.y})`;
|
||||||
|
if (name === "rdp_type") return `Typed \`${input.text}\``;
|
||||||
|
if (name === "list_sessions") return "List active sessions";
|
||||||
|
if (name === "connect_session") return `Connect to session ${input.sessionId}`;
|
||||||
|
if (name === "disconnect_session") return `Disconnect session ${input.sessionId}`;
|
||||||
|
|
||||||
|
return name;
|
||||||
|
});
|
||||||
|
|
||||||
|
const formattedInput = computed(() => JSON.stringify(props.toolCall.input, null, 2));
|
||||||
|
const formattedResult = computed(() =>
|
||||||
|
props.toolCall.result !== undefined
|
||||||
|
? JSON.stringify(props.toolCall.result, null, 2)
|
||||||
|
: "",
|
||||||
|
);
|
||||||
|
</script>
|
||||||
177
frontend/src/composables/useCopilot.ts
Normal file
177
frontend/src/composables/useCopilot.ts
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
import { useCopilotStore } from "@/stores/copilot.store";
|
||||||
|
import type { ToolCall } from "@/stores/copilot.store";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable providing mock Wails binding wrappers for the AI copilot.
|
||||||
|
*
|
||||||
|
* All functions simulate Claude-style behavior until real Wails bindings
|
||||||
|
* (AIService.*) are connected.
|
||||||
|
*/
|
||||||
|
export function useCopilot() {
|
||||||
|
const store = useCopilotStore();
|
||||||
|
|
||||||
|
/** Simulate word-by-word streaming output. */
|
||||||
|
async function mockStream(
|
||||||
|
text: string,
|
||||||
|
onDelta: (word: string) => void,
|
||||||
|
): Promise<void> {
|
||||||
|
const words = text.split(" ");
|
||||||
|
for (const word of words) {
|
||||||
|
await new Promise((r) => setTimeout(r, 30 + Math.random() * 70));
|
||||||
|
onDelta(word + " ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Simulate a tool call execution with realistic delay. */
|
||||||
|
async function mockToolCall(
|
||||||
|
name: string,
|
||||||
|
_input: Record<string, unknown>,
|
||||||
|
): Promise<unknown> {
|
||||||
|
await new Promise((r) => setTimeout(r, 500 + Math.random() * 1000));
|
||||||
|
|
||||||
|
if (name === "list_sessions") return [];
|
||||||
|
if (name === "terminal_read") {
|
||||||
|
return {
|
||||||
|
lines: [
|
||||||
|
"$ systemctl status nginx",
|
||||||
|
"● nginx.service - A high performance web server",
|
||||||
|
" Loaded: loaded (/lib/systemd/system/nginx.service; enabled)",
|
||||||
|
" Active: active (running) since Mon 2026-03-17 08:30:12 UTC",
|
||||||
|
" Main PID: 1234 (nginx)",
|
||||||
|
" Tasks: 5 (limit: 4915)",
|
||||||
|
" Memory: 12.4M",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (name === "terminal_write") return { status: "ok" };
|
||||||
|
if (name === "rdp_screenshot") {
|
||||||
|
return { thumbnail: "data:image/jpeg;base64,/9j/4AAQ..." };
|
||||||
|
}
|
||||||
|
if (name === "rdp_click") return { status: "ok" };
|
||||||
|
if (name === "rdp_type") return { status: "ok" };
|
||||||
|
if (name === "sftp_read") return { content: "# example file content" };
|
||||||
|
if (name === "sftp_write") return { bytesWritten: 1024 };
|
||||||
|
|
||||||
|
return { status: "ok" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a user message — route to the appropriate mock response.
|
||||||
|
*
|
||||||
|
* Routes:
|
||||||
|
* - "hello"/"hey" -> greeting
|
||||||
|
* - "ssh"/"server"/"check" -> terminal tool calls
|
||||||
|
* - "rdp"/"desktop"/"screen" -> RDP screenshot tool call
|
||||||
|
* - default -> list_sessions assessment
|
||||||
|
*/
|
||||||
|
async function processMessage(text: string): Promise<void> {
|
||||||
|
const lower = text.toLowerCase();
|
||||||
|
|
||||||
|
store.isStreaming = true;
|
||||||
|
const assistantMsg = store.createAssistantMessage();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (/\b(hello|hey|hi)\b/.test(lower)) {
|
||||||
|
await mockStream(
|
||||||
|
"XO online. I have access to your active sessions. What's the mission, Commander?",
|
||||||
|
(word) => store.appendStreamDelta(word),
|
||||||
|
);
|
||||||
|
} else if (/\b(ssh|server|check|nginx|status)\b/.test(lower)) {
|
||||||
|
await mockStream(
|
||||||
|
"On it. Let me check the server status.",
|
||||||
|
(word) => store.appendStreamDelta(word),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tool call 1: terminal_write
|
||||||
|
const writeCallId = `tc-${Date.now()}-1`;
|
||||||
|
const writeCall: ToolCall = {
|
||||||
|
id: writeCallId,
|
||||||
|
name: "terminal_write",
|
||||||
|
input: { sessionId: "s1", text: "systemctl status nginx" },
|
||||||
|
status: "pending",
|
||||||
|
};
|
||||||
|
store.addToolCall(writeCall);
|
||||||
|
|
||||||
|
const writeResult = await mockToolCall("terminal_write", writeCall.input);
|
||||||
|
store.completeToolCall(writeCallId, writeResult);
|
||||||
|
|
||||||
|
// Tool call 2: terminal_read
|
||||||
|
const readCallId = `tc-${Date.now()}-2`;
|
||||||
|
const readCall: ToolCall = {
|
||||||
|
id: readCallId,
|
||||||
|
name: "terminal_read",
|
||||||
|
input: { sessionId: "s1", lines: 20 },
|
||||||
|
status: "pending",
|
||||||
|
};
|
||||||
|
store.addToolCall(readCall);
|
||||||
|
|
||||||
|
const readResult = await mockToolCall("terminal_read", readCall.input);
|
||||||
|
store.completeToolCall(readCallId, readResult);
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
store.appendStreamDelta("\n\n");
|
||||||
|
await mockStream(
|
||||||
|
"Nginx is **active and running**. The service has been up since 08:30 UTC today, using 12.4M of memory with 5 active tasks. Everything looks healthy.",
|
||||||
|
(word) => store.appendStreamDelta(word),
|
||||||
|
);
|
||||||
|
} else if (/\b(rdp|desktop|screen|screenshot)\b/.test(lower)) {
|
||||||
|
await mockStream(
|
||||||
|
"Taking a screenshot of the remote desktop.",
|
||||||
|
(word) => store.appendStreamDelta(word),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tool call: rdp_screenshot
|
||||||
|
const callId = `tc-${Date.now()}`;
|
||||||
|
const call: ToolCall = {
|
||||||
|
id: callId,
|
||||||
|
name: "rdp_screenshot",
|
||||||
|
input: { sessionId: "s2" },
|
||||||
|
status: "pending",
|
||||||
|
};
|
||||||
|
store.addToolCall(call);
|
||||||
|
|
||||||
|
const result = await mockToolCall("rdp_screenshot", call.input);
|
||||||
|
store.completeToolCall(callId, result);
|
||||||
|
|
||||||
|
store.appendStreamDelta("\n\n");
|
||||||
|
await mockStream(
|
||||||
|
"I can see the Windows desktop. The screen shows the default wallpaper with a few application shortcuts. No error dialogs or unusual activity detected.",
|
||||||
|
(word) => store.appendStreamDelta(word),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await mockStream(
|
||||||
|
"Understood. Let me assess the situation.",
|
||||||
|
(word) => store.appendStreamDelta(word),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tool call: list_sessions
|
||||||
|
const callId = `tc-${Date.now()}`;
|
||||||
|
const call: ToolCall = {
|
||||||
|
id: callId,
|
||||||
|
name: "list_sessions",
|
||||||
|
input: {},
|
||||||
|
status: "pending",
|
||||||
|
};
|
||||||
|
store.addToolCall(call);
|
||||||
|
|
||||||
|
const result = await mockToolCall("list_sessions", call.input);
|
||||||
|
store.completeToolCall(callId, result);
|
||||||
|
|
||||||
|
store.appendStreamDelta("\n\n");
|
||||||
|
await mockStream(
|
||||||
|
"I've reviewed your current sessions. No active connections detected at the moment. Would you like me to connect to a specific server or run a diagnostic?",
|
||||||
|
(word) => store.appendStreamDelta(word),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track mock output tokens
|
||||||
|
store.tokenUsage.output += Math.ceil(
|
||||||
|
(assistantMsg.content.length) / 4,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
store.isStreaming = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { mockStream, mockToolCall, processMessage };
|
||||||
|
}
|
||||||
@ -31,6 +31,25 @@
|
|||||||
<path d="M11.5 7a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0zm-.82 4.74a6 6 0 1 1 1.06-1.06l3.04 3.04a.75.75 0 1 1-1.06 1.06l-3.04-3.04z" />
|
<path d="M11.5 7a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0zm-.82 4.74a6 6 0 1 1 1.06-1.06l3.04 3.04a.75.75 0 1 1-1.06 1.06l-3.04-3.04z" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<!-- XO Copilot toggle -->
|
||||||
|
<button
|
||||||
|
class="relative hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
|
||||||
|
:class="copilotStore.isPanelOpen ? 'text-[var(--wraith-accent-blue)]' : ''"
|
||||||
|
title="Toggle XO Copilot (Ctrl+Shift+K)"
|
||||||
|
@click="copilotStore.togglePanel()"
|
||||||
|
>
|
||||||
|
<span class="text-sm">👻</span>
|
||||||
|
<!-- Streaming indicator dot -->
|
||||||
|
<span
|
||||||
|
v-if="copilotStore.isStreaming"
|
||||||
|
class="absolute -top-0.5 -right-0.5 w-1.5 h-1.5 rounded-full bg-[var(--wraith-accent-blue)] animate-pulse"
|
||||||
|
/>
|
||||||
|
<!-- Subtle glow when open -->
|
||||||
|
<span
|
||||||
|
v-if="copilotStore.isPanelOpen"
|
||||||
|
class="absolute inset-0 rounded-full bg-[var(--wraith-accent-blue)] opacity-15 blur-sm"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
class="hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
|
class="hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
|
||||||
title="Lock vault"
|
title="Lock vault"
|
||||||
@ -107,6 +126,11 @@
|
|||||||
<!-- Session area -->
|
<!-- Session area -->
|
||||||
<SessionContainer />
|
<SessionContainer />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Copilot Panel (slides in from right) -->
|
||||||
|
<transition name="copilot-slide">
|
||||||
|
<CopilotPanel v-if="copilotStore.isPanelOpen" />
|
||||||
|
</transition>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Status bar -->
|
<!-- Status bar -->
|
||||||
@ -128,6 +152,7 @@ import { ref, onMounted, onUnmounted } from "vue";
|
|||||||
import { useAppStore } from "@/stores/app.store";
|
import { useAppStore } from "@/stores/app.store";
|
||||||
import { useConnectionStore } from "@/stores/connection.store";
|
import { useConnectionStore } from "@/stores/connection.store";
|
||||||
import { useSessionStore } from "@/stores/session.store";
|
import { useSessionStore } from "@/stores/session.store";
|
||||||
|
import { useCopilotStore } from "@/stores/copilot.store";
|
||||||
import SidebarToggle from "@/components/sidebar/SidebarToggle.vue";
|
import SidebarToggle from "@/components/sidebar/SidebarToggle.vue";
|
||||||
import ConnectionTree from "@/components/sidebar/ConnectionTree.vue";
|
import ConnectionTree from "@/components/sidebar/ConnectionTree.vue";
|
||||||
import FileTree from "@/components/sftp/FileTree.vue";
|
import FileTree from "@/components/sftp/FileTree.vue";
|
||||||
@ -139,6 +164,7 @@ import EditorWindow from "@/components/editor/EditorWindow.vue";
|
|||||||
import CommandPalette from "@/components/common/CommandPalette.vue";
|
import CommandPalette from "@/components/common/CommandPalette.vue";
|
||||||
import ThemePicker from "@/components/common/ThemePicker.vue";
|
import ThemePicker from "@/components/common/ThemePicker.vue";
|
||||||
import ImportDialog from "@/components/common/ImportDialog.vue";
|
import ImportDialog from "@/components/common/ImportDialog.vue";
|
||||||
|
import CopilotPanel from "@/components/copilot/CopilotPanel.vue";
|
||||||
import type { ThemeDefinition } from "@/components/common/ThemePicker.vue";
|
import type { ThemeDefinition } from "@/components/common/ThemePicker.vue";
|
||||||
import type { SidebarTab } from "@/components/sidebar/SidebarToggle.vue";
|
import type { SidebarTab } from "@/components/sidebar/SidebarToggle.vue";
|
||||||
import type { FileEntry } from "@/composables/useSftp";
|
import type { FileEntry } from "@/composables/useSftp";
|
||||||
@ -146,6 +172,7 @@ import type { FileEntry } from "@/composables/useSftp";
|
|||||||
const appStore = useAppStore();
|
const appStore = useAppStore();
|
||||||
const connectionStore = useConnectionStore();
|
const connectionStore = useConnectionStore();
|
||||||
const sessionStore = useSessionStore();
|
const sessionStore = useSessionStore();
|
||||||
|
const copilotStore = useCopilotStore();
|
||||||
|
|
||||||
const sidebarWidth = ref(240);
|
const sidebarWidth = ref(240);
|
||||||
const sidebarTab = ref<SidebarTab>("connections");
|
const sidebarTab = ref<SidebarTab>("connections");
|
||||||
@ -247,6 +274,13 @@ function handleQuickConnect(): void {
|
|||||||
|
|
||||||
/** Global keyboard shortcut handler. */
|
/** Global keyboard shortcut handler. */
|
||||||
function handleKeydown(event: KeyboardEvent): void {
|
function handleKeydown(event: KeyboardEvent): void {
|
||||||
|
// Ctrl+Shift+K or Cmd+Shift+K — toggle copilot panel
|
||||||
|
if ((event.ctrlKey || event.metaKey) && event.shiftKey && event.key === "K") {
|
||||||
|
event.preventDefault();
|
||||||
|
copilotStore.togglePanel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Ctrl+K or Cmd+K — open command palette
|
// Ctrl+K or Cmd+K — open command palette
|
||||||
if ((event.ctrlKey || event.metaKey) && event.key === "k") {
|
if ((event.ctrlKey || event.metaKey) && event.key === "k") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@ -262,3 +296,17 @@ onUnmounted(() => {
|
|||||||
document.removeEventListener("keydown", handleKeydown);
|
document.removeEventListener("keydown", handleKeydown);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.copilot-slide-enter-active,
|
||||||
|
.copilot-slide-leave-active {
|
||||||
|
transition: width 0.3s ease, opacity 0.3s ease;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copilot-slide-enter-from,
|
||||||
|
.copilot-slide-leave-to {
|
||||||
|
width: 0px !important;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
182
frontend/src/stores/copilot.store.ts
Normal file
182
frontend/src/stores/copilot.store.ts
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
import { defineStore } from "pinia";
|
||||||
|
import { ref } from "vue";
|
||||||
|
|
||||||
|
export interface ToolCall {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
input: Record<string, unknown>;
|
||||||
|
result?: unknown;
|
||||||
|
status: "pending" | "done" | "error";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
id: string;
|
||||||
|
role: "user" | "assistant";
|
||||||
|
content: string;
|
||||||
|
toolCalls?: ToolCall[];
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConversationSummary {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
model: string;
|
||||||
|
createdAt: string;
|
||||||
|
tokensIn: number;
|
||||||
|
tokensOut: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copilot (XO) store.
|
||||||
|
* Manages the AI assistant panel state, messages, and streaming.
|
||||||
|
*
|
||||||
|
* All Wails AIService calls are TODOs with mock behavior for now.
|
||||||
|
*/
|
||||||
|
export const useCopilotStore = defineStore("copilot", () => {
|
||||||
|
const isPanelOpen = ref(false);
|
||||||
|
const isAuthenticated = ref(true); // default true for mock
|
||||||
|
const isStreaming = ref(false);
|
||||||
|
const activeConversationId = ref<string | null>(null);
|
||||||
|
const messages = ref<Message[]>([]);
|
||||||
|
const conversations = ref<ConversationSummary[]>([]);
|
||||||
|
const model = ref("claude-sonnet-4-5-20250514");
|
||||||
|
const tokenUsage = ref({ input: 0, output: 0 });
|
||||||
|
const showSettings = ref(false);
|
||||||
|
|
||||||
|
/** Toggle the copilot panel open/closed. */
|
||||||
|
function togglePanel(): void {
|
||||||
|
isPanelOpen.value = !isPanelOpen.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start OAuth login flow. */
|
||||||
|
function startLogin(): void {
|
||||||
|
// TODO: Wails AIService.StartLogin()
|
||||||
|
isAuthenticated.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start a new conversation. */
|
||||||
|
function newConversation(): void {
|
||||||
|
// TODO: Wails AIService.NewConversation()
|
||||||
|
activeConversationId.value = `conv-${Date.now()}`;
|
||||||
|
messages.value = [];
|
||||||
|
tokenUsage.value = { input: 0, output: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send a user message and trigger a mock XO response. */
|
||||||
|
function sendMessage(text: string): void {
|
||||||
|
const userMsg: Message = {
|
||||||
|
id: `msg-${Date.now()}`,
|
||||||
|
role: "user",
|
||||||
|
content: text,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
messages.value.push(userMsg);
|
||||||
|
|
||||||
|
// Track mock input tokens
|
||||||
|
tokenUsage.value.input += Math.ceil(text.length / 4);
|
||||||
|
|
||||||
|
if (!activeConversationId.value) {
|
||||||
|
activeConversationId.value = `conv-${Date.now()}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Append a streaming text delta to the latest assistant message. */
|
||||||
|
function appendStreamDelta(text: string): void {
|
||||||
|
const last = messages.value[messages.value.length - 1];
|
||||||
|
if (last && last.role === "assistant") {
|
||||||
|
last.content += text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a new assistant message (for streaming start). */
|
||||||
|
function createAssistantMessage(): Message {
|
||||||
|
const msg: Message = {
|
||||||
|
id: `msg-${Date.now()}`,
|
||||||
|
role: "assistant",
|
||||||
|
content: "",
|
||||||
|
toolCalls: [],
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
messages.value.push(msg);
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add a tool call to the latest assistant message. */
|
||||||
|
function addToolCall(call: ToolCall): void {
|
||||||
|
const last = messages.value[messages.value.length - 1];
|
||||||
|
if (last && last.role === "assistant") {
|
||||||
|
if (!last.toolCalls) last.toolCalls = [];
|
||||||
|
last.toolCalls.push(call);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Complete a pending tool call with a result. */
|
||||||
|
function completeToolCall(callId: string, result: unknown): void {
|
||||||
|
for (const msg of messages.value) {
|
||||||
|
if (!msg.toolCalls) continue;
|
||||||
|
const tc = msg.toolCalls.find((t) => t.id === callId);
|
||||||
|
if (tc) {
|
||||||
|
tc.result = result;
|
||||||
|
tc.status = "done";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mark a tool call as errored. */
|
||||||
|
function failToolCall(callId: string, error: unknown): void {
|
||||||
|
for (const msg of messages.value) {
|
||||||
|
if (!msg.toolCalls) continue;
|
||||||
|
const tc = msg.toolCalls.find((t) => t.id === callId);
|
||||||
|
if (tc) {
|
||||||
|
tc.result = error;
|
||||||
|
tc.status = "error";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load conversation list from backend. */
|
||||||
|
async function loadConversations(): Promise<void> {
|
||||||
|
// TODO: Wails AIService.ListConversations()
|
||||||
|
conversations.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear all messages in the current conversation. */
|
||||||
|
function clearHistory(): void {
|
||||||
|
messages.value = [];
|
||||||
|
activeConversationId.value = null;
|
||||||
|
tokenUsage.value = { input: 0, output: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Disconnect from Claude (reset auth state). */
|
||||||
|
function disconnect(): void {
|
||||||
|
// TODO: Wails AIService.Disconnect()
|
||||||
|
isAuthenticated.value = false;
|
||||||
|
clearHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isPanelOpen,
|
||||||
|
isAuthenticated,
|
||||||
|
isStreaming,
|
||||||
|
activeConversationId,
|
||||||
|
messages,
|
||||||
|
conversations,
|
||||||
|
model,
|
||||||
|
tokenUsage,
|
||||||
|
showSettings,
|
||||||
|
togglePanel,
|
||||||
|
startLogin,
|
||||||
|
newConversation,
|
||||||
|
sendMessage,
|
||||||
|
appendStreamDelta,
|
||||||
|
createAssistantMessage,
|
||||||
|
addToolCall,
|
||||||
|
completeToolCall,
|
||||||
|
failToolCall,
|
||||||
|
loadConversations,
|
||||||
|
clearHistory,
|
||||||
|
disconnect,
|
||||||
|
};
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user