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" />
|
||||
</svg>
|
||||
</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
|
||||
class="hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
|
||||
title="Lock vault"
|
||||
@ -107,6 +126,11 @@
|
||||
<!-- Session area -->
|
||||
<SessionContainer />
|
||||
</div>
|
||||
|
||||
<!-- Copilot Panel (slides in from right) -->
|
||||
<transition name="copilot-slide">
|
||||
<CopilotPanel v-if="copilotStore.isPanelOpen" />
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<!-- Status bar -->
|
||||
@ -128,6 +152,7 @@ import { ref, onMounted, onUnmounted } from "vue";
|
||||
import { useAppStore } from "@/stores/app.store";
|
||||
import { useConnectionStore } from "@/stores/connection.store";
|
||||
import { useSessionStore } from "@/stores/session.store";
|
||||
import { useCopilotStore } from "@/stores/copilot.store";
|
||||
import SidebarToggle from "@/components/sidebar/SidebarToggle.vue";
|
||||
import ConnectionTree from "@/components/sidebar/ConnectionTree.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 ThemePicker from "@/components/common/ThemePicker.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 { SidebarTab } from "@/components/sidebar/SidebarToggle.vue";
|
||||
import type { FileEntry } from "@/composables/useSftp";
|
||||
@ -146,6 +172,7 @@ import type { FileEntry } from "@/composables/useSftp";
|
||||
const appStore = useAppStore();
|
||||
const connectionStore = useConnectionStore();
|
||||
const sessionStore = useSessionStore();
|
||||
const copilotStore = useCopilotStore();
|
||||
|
||||
const sidebarWidth = ref(240);
|
||||
const sidebarTab = ref<SidebarTab>("connections");
|
||||
@ -247,6 +274,13 @@ function handleQuickConnect(): void {
|
||||
|
||||
/** Global keyboard shortcut handler. */
|
||||
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
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === "k") {
|
||||
event.preventDefault();
|
||||
@ -262,3 +296,17 @@ onUnmounted(() => {
|
||||
document.removeEventListener("keydown", handleKeydown);
|
||||
});
|
||||
</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