chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
<script>
|
||||
import { apiState, setApiKey, addMessage, clearMessageHistory, forceStop, messageList, currentMessage, enableThinking, getMessageDetails } from '$lib/anthropic.js';
|
||||
import { tick } from 'svelte';
|
||||
import { get } from 'svelte/store';
|
||||
import PanelButton from './PanelButton.svelte';
|
||||
import SmallButton from './SmallButton.svelte';
|
||||
import { aiActivity } from './activities.js';
|
||||
import html2canvas from 'html2canvas-pro';
|
||||
export let handleTool;
|
||||
let stopRequested = false;
|
||||
function handleKeyEnter(e)
|
||||
{
|
||||
if(e.key != "Enter")
|
||||
return;
|
||||
var value = e.target.value;
|
||||
if(value == "")
|
||||
return;
|
||||
setApiKey(value);
|
||||
}
|
||||
function handleMessage(e)
|
||||
{
|
||||
if(e.key != "Enter")
|
||||
return;
|
||||
e.preventDefault();
|
||||
var textArea = e.target;
|
||||
var value = textArea.value;
|
||||
if(value == "")
|
||||
return;
|
||||
textArea.style.height = "unset";
|
||||
// Reset the textarea
|
||||
currentMessage.set("");
|
||||
addMessage(value, handleTool);
|
||||
}
|
||||
function handleResize(e)
|
||||
{
|
||||
var textArea = e.target;
|
||||
textArea.style.height = textArea.scrollHeight + "px";
|
||||
}
|
||||
async function scrollToBottom(node)
|
||||
{
|
||||
await tick();
|
||||
node.scrollTop = node.scrollHeight;
|
||||
if (!get(aiActivity)) {
|
||||
document.getElementById("ai-input").focus();
|
||||
}
|
||||
}
|
||||
function scrollMessage(node, messageList)
|
||||
{
|
||||
// Make sure the messages are always scrolled to the bottom
|
||||
scrollToBottom(node);
|
||||
return {
|
||||
update(messageList) {
|
||||
scrollToBottom(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
async function handleStop() {
|
||||
stopRequested = true;
|
||||
await forceStop();
|
||||
stopRequested = false;
|
||||
}
|
||||
|
||||
async function handleDownload() {
|
||||
const messageListElement = document.getElementById('message-list');
|
||||
// Temporarily add padding and background for the list
|
||||
messageListElement.classList.add("p-1");
|
||||
const canvas = await html2canvas(messageListElement);
|
||||
messageListElement.classList.remove("p-1");
|
||||
const link = document.createElement('a');
|
||||
link.href = canvas.toDataURL('image/png');
|
||||
link.download = 'WebVM_Claude.png';
|
||||
link.click();
|
||||
}
|
||||
|
||||
function toggleThinkingMode() {
|
||||
enableThinking.set(!get(enableThinking));
|
||||
}
|
||||
</script>
|
||||
<h1 class="text-lg font-bold">Claude AI Integration</h1>
|
||||
<p>WebVM is integrated with Claude by Anthropic AI. You can prompt the AI to control the system.</p>
|
||||
<p>You need to provide your API key. The key is only saved locally to your browser.</p>
|
||||
<div class="flex grow flex-col overflow-y-hidden gap-2">
|
||||
<p class="flex flex-row gap-2">
|
||||
<span class="mr-auto flex items-center">Conversation history</span>
|
||||
<SmallButton buttonIcon="fa-solid fa-download" clickHandler={handleDownload} buttonTooltip="Save conversation as image"></SmallButton>
|
||||
<SmallButton buttonIcon="fa-solid fa-brain" clickHandler={toggleThinkingMode} buttonTooltip="{$enableThinking ? "Disable" : "Enable"} thinking mode" bgColor={$enableThinking ? "bg-neutral-500" : "bg-neutral-700"}></SmallButton>
|
||||
<SmallButton buttonIcon="fa-solid fa-trash-can" clickHandler={clearMessageHistory} buttonTooltip="Clear conversation history"></SmallButton>
|
||||
</p>
|
||||
<div class="flex grow overflow-y-scroll scrollbar" use:scrollMessage={$messageList}>
|
||||
<div class="h-full w-full">
|
||||
<div class="w-full min-h-full flex flex-col gap-2 justify-end bg-neutral-600" id="message-list">
|
||||
{#each $messageList as msg}
|
||||
{@const details = getMessageDetails(msg)}
|
||||
{#if details.isToolUse}
|
||||
<p class="bg-neutral-700 p-2 rounded-md italic"><i class='fas {details.icon} w-6 mr-2 text-center'></i>{details.messageContent}</p>
|
||||
{:else if !details.isToolResult}
|
||||
<p class="{msg.role == 'error' ? 'bg-red-900' : 'bg-neutral-700'} p-2 rounded-md whitespace-pre-wrap"><i class='fas {details.icon} w-6 mr-2 text-center'></i>{details.messageContent}</p>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{#if $apiState == "KEY_REQUIRED"}
|
||||
<textarea class="bg-neutral-700 p-2 rounded-md placeholder-gray-400 resize-none shrink-0" placeholder="Insert your Claude API Key" rows="1" on:keydown={handleKeyEnter} on:input={handleResize} id="ai-input"/>
|
||||
{:else if $aiActivity}
|
||||
{#if stopRequested }
|
||||
<PanelButton buttonIcon="fa-solid fa-hand" buttonText="Stopping...">
|
||||
</PanelButton>
|
||||
{:else}
|
||||
<PanelButton buttonIcon="fa-solid fa-hand" clickHandler={handleStop} buttonText="Stop">
|
||||
</PanelButton>
|
||||
{/if}
|
||||
{:else}
|
||||
<textarea class="bg-neutral-700 p-2 rounded-md placeholder-gray-400 resize-none shrink-0" placeholder={handleTool === null ? "Waiting for system initialization..." : "Prompt..."} rows="1" on:keydown={handleMessage} on:input={handleResize} bind:value={$currentMessage} id="ai-input" disabled={handleTool === null}/>
|
||||
{/if}
|
||||
@@ -0,0 +1,9 @@
|
||||
<script>
|
||||
export let title;
|
||||
export let image;
|
||||
export let url;
|
||||
</script>
|
||||
<a href={url} target="_blank"><div class="bg-neutral-700 hover:bg-neutral-500 p-2 rounded-md">
|
||||
<img class="w-full aspect-[40/21]" src={image}>
|
||||
<h2 class="text-sm font-bold">{title}</h2>
|
||||
</div></a>
|
||||
@@ -0,0 +1,12 @@
|
||||
<script>
|
||||
import PanelButton from './PanelButton.svelte';
|
||||
import { cpuPercentage } from './activities.js'
|
||||
</script>
|
||||
|
||||
<h1 class="text-lg font-bold">Engine</h1>
|
||||
<PanelButton buttonImage="assets/cheerpx.svg" clickUrl="https://cheerpx.io/docs" buttonText="Explore CheerpX">
|
||||
</PanelButton>
|
||||
<p><span class="font-bold">Virtual CPU: </span>{$cpuPercentage}%</p>
|
||||
<p>CheerpX is a x86 virtualization engine in WebAssembly</p>
|
||||
<p>It can securely run unmodified x86 binaries and libraries in the browser</p>
|
||||
<p>Excited about our technology? <a class="underline" href="https://cheerpx.io/docs/getting-started" target="_blank">Start building</a> your projects using <a class="underline" href="https://cheerpx.io/" target="_blank">CheerpX</a> today!</p>
|
||||
@@ -0,0 +1,12 @@
|
||||
<script>
|
||||
import PanelButton from './PanelButton.svelte';
|
||||
import DiscordPresenceCount from 'labs/packages/astro-theme/components/nav/DiscordPresenceCount.svelte'
|
||||
</script>
|
||||
|
||||
<h1 class="text-lg font-bold">Discord</h1>
|
||||
<PanelButton buttonImage="assets/discord-mark-blue.svg" clickUrl="https://discord.gg/yTNZgySKGa" buttonText="Join our Discord">
|
||||
<i class='fas fa-circle fa-xs ml-auto text-green-500'></i>
|
||||
<span class="ml-1"><DiscordPresenceCount /></span>
|
||||
</PanelButton>
|
||||
<p>Do you have any question about WebVM or CheerpX?</p>
|
||||
<p>Join our community, we are happy to help!</p>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script>
|
||||
import PanelButton from './PanelButton.svelte';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { diskLatency } from './activities.js'
|
||||
var dispatch = createEventDispatcher();
|
||||
let state = "START";
|
||||
function handleReset()
|
||||
{
|
||||
if(state == "START")
|
||||
state = "CONFIRM";
|
||||
else if (state == "CONFIRM") {
|
||||
state = "RESETTING";
|
||||
dispatch('reset');
|
||||
}
|
||||
}
|
||||
function getButtonText(state)
|
||||
{
|
||||
if(state == "START")
|
||||
return "Reset disk";
|
||||
else if (state == "RESETTING")
|
||||
return "Resetting...";
|
||||
else
|
||||
return "Reset disk. Confirm?";
|
||||
}
|
||||
function getBgColor(state)
|
||||
{
|
||||
if(state == "START")
|
||||
{
|
||||
// Use default
|
||||
return undefined;
|
||||
}
|
||||
else
|
||||
{
|
||||
return "bg-red-900";
|
||||
}
|
||||
}
|
||||
function getHoverColor(state)
|
||||
{
|
||||
if(state == "START")
|
||||
{
|
||||
// Use default
|
||||
return undefined;
|
||||
}
|
||||
else
|
||||
{
|
||||
return "hover:bg-red-700";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<h1 class="text-lg font-bold">Disk</h1>
|
||||
<PanelButton buttonIcon="fa-solid fa-trash-can" clickHandler={handleReset} buttonText={getButtonText(state)} bgColor={getBgColor(state)} hoverColor={getHoverColor(state)}>
|
||||
</PanelButton>
|
||||
{#if state == "CONFIRM"}
|
||||
<p><span class="font-bold">Warning: </span>WebVM will reload</p>
|
||||
{:else if state == "RESETTING"}
|
||||
<p><span class="font-bold">Reset in progress: </span>Please wait...</p>
|
||||
{:else}
|
||||
<p><span class="font-bold">Backend latency: </span>{$diskLatency}ms</p>
|
||||
{/if}
|
||||
<p>WebVM runs on top of a complete Linux distribution</p>
|
||||
<p>Filesystems up to 2GB are supported and data is downloaded completely on-demand</p>
|
||||
<p>The WebVM cloud backend uses WebSockets and it's distributed via a global CDN to minimize download latency</p>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script>
|
||||
import PanelButton from './PanelButton.svelte';
|
||||
import GitHubStarCount from 'labs/packages/astro-theme/components/nav/GitHubStarCount.svelte'
|
||||
</script>
|
||||
|
||||
<h1 class="text-lg font-bold">GitHub</h1>
|
||||
<PanelButton buttonImage="assets/github-mark-white.svg" clickUrl="https://github.com/leaningtech/webvm" buttonText="GitHub repo">
|
||||
<i class='fas fa-star fa-xs ml-auto'></i>
|
||||
<span class="ml-1"><GitHubStarCount repo="leaningtech/webvm"/></span>
|
||||
</PanelButton>
|
||||
<p>Like WebVM? <a class="underline" href="https://github.com/leaningtech/webvm" target="_blank">Give us a star!</a></p>
|
||||
<p>WebVM is FOSS, you can fork it to build your own version and begin working on your CheerpX-based project</p>
|
||||
<p>Found a bug? Please open a <a class="underline" href="https://github.com/leaningtech/webvm/issues" target="_blank">GitHub issue</a></p>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script>
|
||||
export let icon;
|
||||
export let info;
|
||||
export let activity;
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
function handleMouseover() {
|
||||
dispatch('mouseover', info);
|
||||
}
|
||||
function handleClick() {
|
||||
dispatch('click', { icon, info });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="p-3 cursor-pointer text-center hover:bg-neutral-600 {$activity ? "text-amber-500 animate-pulse" : "hover:text-gray-100"}"
|
||||
style="animation-duration: 0.5s"
|
||||
on:mouseenter={handleMouseover}
|
||||
on:click={handleClick}
|
||||
>
|
||||
<i class='{icon} fa-xl'></i>
|
||||
</div>
|
||||
@@ -0,0 +1,11 @@
|
||||
<h1 class="text-lg font-bold">Information</h1>
|
||||
<img src="assets/webvm_hero.png" alt="WebVM Logo" class="w-56 h-56 object-contain self-center">
|
||||
<p>WebVM is a virtual Linux environment running in the browser via WebAssembly</p>
|
||||
<p>It is based on:</p>
|
||||
<ul class="list-disc list-inside">
|
||||
<li><a class="underline" target="_blank" href="https://cheerpx.io/">CheerpX</a>: x86 JIT in Wasm</li>
|
||||
<li><a class="underline" target="_blank" href="https://xtermjs.org/">Xterm.js</a>: interactive terminal</li>
|
||||
<li>Local/private <a class="underline" target="_blank" href="https://cheerpx.io/docs/guides/File-System-support">file storage</a></li>
|
||||
<li><a class="underline" target="_blank" href="https://cheerpx.io/docs/guides/Networking">Networking</a> via <a class="underline" target="_blank" href="https://tailscale.com/">Tailscale</a></li>
|
||||
</ul>
|
||||
<slot></slot>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script>
|
||||
import { networkData, startLogin, updateButtonData } from '$lib/network.js'
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import PanelButton from './PanelButton.svelte';
|
||||
var dispatch = createEventDispatcher();
|
||||
var connectionState = networkData.connectionState;
|
||||
var exitNode = networkData.exitNode;
|
||||
|
||||
function handleConnect() {
|
||||
connectionState.set("DOWNLOADING");
|
||||
dispatch('connect');
|
||||
}
|
||||
|
||||
let buttonData = null;
|
||||
$: buttonData = updateButtonData($connectionState, handleConnect);
|
||||
</script>
|
||||
<h1 class="text-lg font-bold">Networking</h1>
|
||||
<PanelButton buttonImage="assets/tailscale.svg" clickUrl={buttonData.clickUrl} clickHandler={buttonData.clickHandler} rightClickHandler={buttonData.rightClickHandler} buttonTooltip={buttonData.buttonTooltip} buttonText={buttonData.buttonText}>
|
||||
{#if $connectionState == "CONNECTED"}
|
||||
<i class='fas fa-circle fa-xs ml-auto {$exitNode ? 'text-green-500' : 'text-amber-500'}' title={$exitNode ? 'Ready' : 'No exit node'}></i>
|
||||
{/if}
|
||||
</PanelButton>
|
||||
<p>WebVM can connect to the Internet via Tailscale</p>
|
||||
<p>Using Tailscale is required since browser do not support TCP/UDP sockets (yet!)</p>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script>
|
||||
export let clickUrl = null;
|
||||
export let clickHandler = null;
|
||||
export let rightClickHandler = null;
|
||||
export let buttonTooltip = null;
|
||||
export let bgColor = "bg-neutral-700";
|
||||
export let hoverColor = "hover:bg-neutral-500"
|
||||
export let buttonImage = null;
|
||||
export let buttonIcon = null;
|
||||
export let buttonText;
|
||||
</script>
|
||||
|
||||
<a href={clickUrl} target="_blank" on:click={clickHandler} on:contextmenu={rightClickHandler}><p class="flex flex-row items-center {bgColor} p-2 rounded-md shadow-md shadow-neutral-900 {(clickUrl != null || clickHandler != null) ? `${hoverColor} cursor-pointer` : ""}" title={buttonTooltip}>
|
||||
{#if buttonImage}
|
||||
<img src={buttonImage} class="inline w-8 h-8"/>
|
||||
{:else if buttonIcon}
|
||||
<i class="w-8 {buttonIcon} text-center" style="font-size: 2em;"></i>
|
||||
{/if}
|
||||
<span class="ml-1">{buttonText}</span><slot></slot></p></a>
|
||||
@@ -0,0 +1,14 @@
|
||||
<script>
|
||||
import BlogPost from './BlogPost.svelte';
|
||||
import { page } from '$app/stores';
|
||||
</script>
|
||||
<h1 class="text-lg font-bold">Blog posts</h1>
|
||||
<div class="overflow-y-scroll scrollbar flex flex-col gap-2">
|
||||
{#each $page.data.posts as post}
|
||||
<BlogPost
|
||||
title={post.title}
|
||||
image={post.image}
|
||||
url={post.url}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,135 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import Icon from './Icon.svelte';
|
||||
import InformationTab from './InformationTab.svelte';
|
||||
import NetworkingTab from './NetworkingTab.svelte';
|
||||
import CpuTab from './CpuTab.svelte';
|
||||
import DiskTab from './DiskTab.svelte';
|
||||
import AnthropicTab from './AnthropicTab.svelte';
|
||||
import PostsTab from './PostsTab.svelte';
|
||||
import DiscordTab from './DiscordTab.svelte';
|
||||
import GitHubTab from './GitHubTab.svelte';
|
||||
import SmallButton from './SmallButton.svelte';
|
||||
import { cpuActivity, diskActivity, aiActivity } from './activities.js';
|
||||
const icons = [
|
||||
{ icon: 'fas fa-info-circle', info: 'Information', activity: null },
|
||||
{ icon: 'fas fa-wifi', info: 'Networking', activity: null },
|
||||
{ icon: 'fas fa-microchip', info: 'CPU', activity: cpuActivity },
|
||||
{ icon: 'fas fa-compact-disc', info: 'Disk', activity: diskActivity },
|
||||
{ icon: 'fas fa-robot', info: 'ClaudeAI', activity: aiActivity },
|
||||
null,
|
||||
{ icon: 'fas fa-book-open', info: 'Posts', activity: null },
|
||||
{ icon: 'fab fa-discord', info: 'Discord', activity: null },
|
||||
{ icon: 'fab fa-github', info: 'GitHub', activity: null },
|
||||
];
|
||||
let dispatch = createEventDispatcher();
|
||||
let activeInfo = null; // Tracks currently visible info.
|
||||
let hideTimeout = 0; // Timeout for hiding info panel.
|
||||
export let sideBarPinned;
|
||||
|
||||
function showInfo(info) {
|
||||
clearTimeout(hideTimeout);
|
||||
hideTimeout = 0;
|
||||
activeInfo = info;
|
||||
}
|
||||
function hideInfo() {
|
||||
// Never remove the sidebar if pinning is enabled
|
||||
if(sideBarPinned)
|
||||
return;
|
||||
// Prevents multiple timers and hides the info panel after 400ms unless interrupted.
|
||||
clearTimeout(hideTimeout);
|
||||
hideTimeout = setTimeout(() => {
|
||||
activeInfo = null;
|
||||
hideTimeout = 0;
|
||||
}, 400);
|
||||
}
|
||||
function handleMouseEnterPanel() {
|
||||
clearTimeout(hideTimeout);
|
||||
hideTimeout = 0;
|
||||
}
|
||||
// Toggles the info panel for the clicked icon.
|
||||
function handleClick(icon) {
|
||||
if(sideBarPinned)
|
||||
return;
|
||||
// Hides the panel if the icon is active. Otherwise, shows the panel with info.
|
||||
if (activeInfo === icon.info) {
|
||||
activeInfo = null;
|
||||
} else {
|
||||
activeInfo = icon.info;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSidebarPin() {
|
||||
sideBarPinned = !sideBarPinned;
|
||||
dispatch('sidebarPinChange', sideBarPinned);
|
||||
}
|
||||
|
||||
export let handleTool;
|
||||
</script>
|
||||
|
||||
<div class="flex flex-row w-14 h-full bg-neutral-700" >
|
||||
<div class="flex flex-col shrink-0 w-14 text-gray-300">
|
||||
{#each icons as i}
|
||||
{#if i}
|
||||
<Icon
|
||||
icon={i.icon}
|
||||
info={i.info}
|
||||
activity={i.activity}
|
||||
on:mouseover={(e) => showInfo(e.detail)}
|
||||
on:click={() => handleClick(i)}
|
||||
/>
|
||||
{:else}
|
||||
<div class="grow" on:mouseenter={handleMouseEnterPanel}></div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
<div
|
||||
class="relative flex flex-col gap-5 shrink-0 w-80 h-full z-10 p-2 bg-neutral-600 text-gray-100 opacity-95"
|
||||
class:hidden={!activeInfo}
|
||||
on:mouseenter={handleMouseEnterPanel}
|
||||
on:mouseleave={hideInfo}
|
||||
>
|
||||
<div class="absolute right-2 top-2">
|
||||
<SmallButton
|
||||
buttonIcon="fa-solid fa-thumbtack"
|
||||
clickHandler={toggleSidebarPin}
|
||||
buttonTooltip={sideBarPinned ? "Unpin Sidebar" : "Pin Sidebar"}
|
||||
bgColor={sideBarPinned ? "bg-neutral-500" : "bg-neutral-700"}
|
||||
/>
|
||||
</div>
|
||||
{#if activeInfo === 'Information'}
|
||||
<InformationTab>
|
||||
<slot></slot>
|
||||
</InformationTab>
|
||||
{:else if activeInfo === 'Networking'}
|
||||
<NetworkingTab on:connect/>
|
||||
{:else if activeInfo === 'CPU'}
|
||||
<CpuTab/>
|
||||
{:else if activeInfo === 'Disk'}
|
||||
<DiskTab on:reset/>
|
||||
{:else if activeInfo === 'ClaudeAI'}
|
||||
<AnthropicTab handleTool={handleTool} />
|
||||
{:else if activeInfo === 'Posts'}
|
||||
<PostsTab/>
|
||||
{:else if activeInfo === 'Discord'}
|
||||
<DiscordTab/>
|
||||
{:else if activeInfo === 'GitHub'}
|
||||
<GitHubTab/>
|
||||
{:else}
|
||||
<p>TODO: {activeInfo}</p>
|
||||
{/if}
|
||||
|
||||
<div class="mt-auto text-sm text-gray-300">
|
||||
<div class="pt-1 pb-1">
|
||||
<a href="https://cheerpx.io/" target="_blank">
|
||||
<span>Powered by CheerpX</span>
|
||||
<img src="assets/cheerpx.svg" alt="CheerpX Logo" class="w-6 h-6 inline-block">
|
||||
</a>
|
||||
</div>
|
||||
<hr class="border-t border-solid border-gray-300">
|
||||
<div class="pt-1 pb-1">
|
||||
<a href="https://leaningtech.com/" target="”_blank”">© 2022-2025 Leaning Technologies</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,11 @@
|
||||
<script>
|
||||
export let clickHandler = null;
|
||||
export let buttonTooltip = null;
|
||||
export let bgColor = "bg-neutral-700";
|
||||
export let hoverColor = "hover:bg-neutral-500"
|
||||
export let buttonIcon = null;
|
||||
</script>
|
||||
|
||||
<span class="inline-block h-7 {bgColor} text-sm p-1 rounded-md shadow-md shadow-neutral-900 {(clickHandler != null) ? `${hoverColor} cursor-pointer` : ""}" title={buttonTooltip} on:click={clickHandler}>
|
||||
<i class="w-5 {buttonIcon} text-center"></i>
|
||||
</span>
|
||||
@@ -0,0 +1,385 @@
|
||||
<script>
|
||||
import { onMount, tick } from 'svelte';
|
||||
import { get } from 'svelte/store';
|
||||
import Nav from 'labs/packages/global-navbar/src/Nav.svelte';
|
||||
import SideBar from '$lib/SideBar.svelte';
|
||||
import '$lib/global.css';
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
import '@fortawesome/fontawesome-free/css/all.min.css'
|
||||
import { networkInterface, startLogin } from '$lib/network.js'
|
||||
import { cpuActivity, diskActivity, cpuPercentage, diskLatency } from '$lib/activities.js'
|
||||
import { introMessage, errorMessage, unexpectedErrorMessage } from '$lib/messages.js'
|
||||
import { displayConfig, handleToolImpl } from '$lib/anthropic.js'
|
||||
import { tryPlausible } from '$lib/plausible.js'
|
||||
|
||||
export let configObj = null;
|
||||
export let processCallback = null;
|
||||
export let cacheId = null;
|
||||
export let cpuActivityEvents = [];
|
||||
export let diskLatencies = [];
|
||||
export let activityEventsInterval = 0;
|
||||
|
||||
var term = null;
|
||||
var cx = null;
|
||||
var fitAddon = null;
|
||||
var cxReadFunc = null;
|
||||
var blockCache = null;
|
||||
var processCount = 0;
|
||||
var curVT = 0;
|
||||
var sideBarPinned = false;
|
||||
function writeData(buf, vt)
|
||||
{
|
||||
if(vt != 1)
|
||||
return;
|
||||
term.write(new Uint8Array(buf));
|
||||
}
|
||||
function readData(str)
|
||||
{
|
||||
if(cxReadFunc == null)
|
||||
return;
|
||||
for(var i=0;i<str.length;i++)
|
||||
cxReadFunc(str.charCodeAt(i));
|
||||
}
|
||||
function printMessage(msg)
|
||||
{
|
||||
for(var i=0;i<msg.length;i++)
|
||||
term.write(msg[i] + "\n");
|
||||
}
|
||||
function expireEvents(list, curTime, limitTime)
|
||||
{
|
||||
while(list.length > 1)
|
||||
{
|
||||
if(list[1].t < limitTime)
|
||||
{
|
||||
list.shift();
|
||||
}
|
||||
else
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
function cleanupEvents()
|
||||
{
|
||||
var curTime = Date.now();
|
||||
var limitTime = curTime - 10000;
|
||||
expireEvents(cpuActivityEvents, curTime, limitTime);
|
||||
computeCpuActivity(curTime, limitTime);
|
||||
if(cpuActivityEvents.length == 0)
|
||||
{
|
||||
clearInterval(activityEventsInterval);
|
||||
activityEventsInterval = 0;
|
||||
}
|
||||
}
|
||||
function computeCpuActivity(curTime, limitTime)
|
||||
{
|
||||
var totalActiveTime = 0;
|
||||
var lastActiveTime = limitTime;
|
||||
var lastWasActive = false;
|
||||
for(var i=0;i<cpuActivityEvents.length;i++)
|
||||
{
|
||||
var e = cpuActivityEvents[i];
|
||||
// NOTE: The first event could be before the limit,
|
||||
// we need at least one event to correctly mark
|
||||
// active time when there is long time under load
|
||||
var eTime = e.t;
|
||||
if(eTime < limitTime)
|
||||
eTime = limitTime;
|
||||
if(e.state == "ready")
|
||||
{
|
||||
// Inactive state, add the time from lastActiveTime
|
||||
totalActiveTime += (eTime - lastActiveTime);
|
||||
lastWasActive = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Active state
|
||||
lastActiveTime = eTime;
|
||||
lastWasActive = true;
|
||||
}
|
||||
}
|
||||
// Add the last interval if needed
|
||||
if(lastWasActive)
|
||||
{
|
||||
totalActiveTime += (curTime - lastActiveTime);
|
||||
}
|
||||
cpuPercentage.set(Math.ceil((totalActiveTime / 10000) * 100));
|
||||
}
|
||||
function hddCallback(state)
|
||||
{
|
||||
diskActivity.set(state != "ready");
|
||||
}
|
||||
function latencyCallback(latency)
|
||||
{
|
||||
diskLatencies.push(latency);
|
||||
if(diskLatencies.length > 30)
|
||||
diskLatencies.shift();
|
||||
// Average the latency over at most 30 blocks
|
||||
var total = 0;
|
||||
for(var i=0;i<diskLatencies.length;i++)
|
||||
total += diskLatencies[i];
|
||||
var avg = total / diskLatencies.length;
|
||||
diskLatency.set(Math.ceil(avg));
|
||||
}
|
||||
function cpuCallback(state)
|
||||
{
|
||||
cpuActivity.set(state != "ready");
|
||||
var curTime = Date.now();
|
||||
var limitTime = curTime - 10000;
|
||||
expireEvents(cpuActivityEvents, curTime, limitTime);
|
||||
cpuActivityEvents.push({t: curTime, state: state});
|
||||
computeCpuActivity(curTime, limitTime);
|
||||
// Start an interval timer to cleanup old samples when no further activity is received
|
||||
if(activityEventsInterval != 0)
|
||||
clearInterval(activityEventsInterval);
|
||||
activityEventsInterval = setInterval(cleanupEvents, 2000);
|
||||
}
|
||||
function computeXTermFontSize()
|
||||
{
|
||||
return parseInt(getComputedStyle(document.body).fontSize);
|
||||
}
|
||||
function setScreenSize(display)
|
||||
{
|
||||
var internalMult = 1.0;
|
||||
var displayWidth = display.offsetWidth;
|
||||
var displayHeight = display.offsetHeight;
|
||||
var minWidth = 1024;
|
||||
var minHeight = 768;
|
||||
if(displayWidth < minWidth)
|
||||
internalMult = minWidth / displayWidth;
|
||||
if(displayHeight < minHeight)
|
||||
internalMult = Math.max(internalMult, minHeight / displayHeight);
|
||||
var internalWidth = Math.floor(displayWidth * internalMult);
|
||||
var internalHeight = Math.floor(displayHeight * internalMult);
|
||||
cx.setKmsCanvas(display, internalWidth, internalHeight);
|
||||
// Compute the size to be used for AI screenshots
|
||||
var screenshotMult = 1.0;
|
||||
var maxWidth = 1024;
|
||||
var maxHeight = 768;
|
||||
if(internalWidth > maxWidth)
|
||||
screenshotMult = maxWidth / internalWidth;
|
||||
if(internalHeight > maxHeight)
|
||||
screenshotMult = Math.min(screenshotMult, maxHeight / internalHeight);
|
||||
var screenshotWidth = Math.floor(internalWidth * screenshotMult);
|
||||
var screenshotHeight = Math.floor(internalHeight * screenshotMult);
|
||||
// Track the state of the mouse as requested by the AI, to avoid losing the position due to user movement
|
||||
displayConfig.set({width: screenshotWidth, height: screenshotHeight, mouseMult: internalMult * screenshotMult});
|
||||
}
|
||||
var curInnerWidth = 0;
|
||||
var curInnerHeight = 0;
|
||||
function handleResize()
|
||||
{
|
||||
// Avoid spurious resize events caused by the soft keyboard
|
||||
if(curInnerWidth == window.innerWidth && curInnerHeight == window.innerHeight)
|
||||
return;
|
||||
curInnerWidth = window.innerWidth;
|
||||
curInnerHeight = window.innerHeight;
|
||||
triggerResize();
|
||||
}
|
||||
function triggerResize()
|
||||
{
|
||||
term.options.fontSize = computeXTermFontSize();
|
||||
fitAddon.fit();
|
||||
const display = document.getElementById("display");
|
||||
if(display)
|
||||
setScreenSize(display);
|
||||
}
|
||||
async function initTerminal()
|
||||
{
|
||||
const { Terminal } = await import('@xterm/xterm');
|
||||
const { FitAddon } = await import('@xterm/addon-fit');
|
||||
const { WebLinksAddon } = await import('@xterm/addon-web-links');
|
||||
term = new Terminal({cursorBlink:true, convertEol:true, fontFamily:"monospace", fontWeight: 400, fontWeightBold: 700, fontSize: computeXTermFontSize()});
|
||||
fitAddon = new FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
var linkAddon = new WebLinksAddon();
|
||||
term.loadAddon(linkAddon);
|
||||
const consoleDiv = document.getElementById("console");
|
||||
term.open(consoleDiv);
|
||||
term.scrollToTop();
|
||||
fitAddon.fit();
|
||||
window.addEventListener("resize", handleResize);
|
||||
term.focus();
|
||||
term.onData(readData);
|
||||
// Avoid undesired default DnD handling
|
||||
function preventDefaults (e) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
consoleDiv.addEventListener("dragover", preventDefaults, false);
|
||||
consoleDiv.addEventListener("dragenter", preventDefaults, false);
|
||||
consoleDiv.addEventListener("dragleave", preventDefaults, false);
|
||||
consoleDiv.addEventListener("drop", preventDefaults, false);
|
||||
curInnerWidth = window.innerWidth;
|
||||
curInnerHeight = window.innerHeight;
|
||||
if(configObj.printIntro)
|
||||
printMessage(introMessage);
|
||||
try
|
||||
{
|
||||
await initCheerpX();
|
||||
}
|
||||
catch(e)
|
||||
{
|
||||
printMessage(unexpectedErrorMessage);
|
||||
printMessage([e.toString()]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
function handleActivateConsole(vt)
|
||||
{
|
||||
if(curVT == vt)
|
||||
return;
|
||||
curVT = vt;
|
||||
if(vt != 7)
|
||||
return;
|
||||
// Raise the display to the foreground
|
||||
const display = document.getElementById("display");
|
||||
display.parentElement.style.zIndex = 5;
|
||||
tryPlausible("Display activated");
|
||||
}
|
||||
function handleProcessCreated()
|
||||
{
|
||||
processCount++;
|
||||
if(processCallback)
|
||||
processCallback(processCount);
|
||||
}
|
||||
async function initCheerpX()
|
||||
{
|
||||
const CheerpX = await import('@leaningtech/cheerpx');
|
||||
var blockDevice = null;
|
||||
switch(configObj.diskImageType)
|
||||
{
|
||||
case "cloud":
|
||||
try
|
||||
{
|
||||
blockDevice = await CheerpX.CloudDevice.create(configObj.diskImageUrl);
|
||||
}
|
||||
catch(e)
|
||||
{
|
||||
// Report the failure and try again with plain HTTP
|
||||
var wssProtocol = "wss:";
|
||||
if(configObj.diskImageUrl.startsWith(wssProtocol))
|
||||
{
|
||||
// WebSocket protocol failed, try agin using plain HTTP
|
||||
tryPlausible("WS Disk failure");
|
||||
blockDevice = await CheerpX.CloudDevice.create("https:" + configObj.diskImageUrl.substr(wssProtocol.length));
|
||||
}
|
||||
else
|
||||
{
|
||||
// No other recovery option
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "bytes":
|
||||
blockDevice = await CheerpX.HttpBytesDevice.create(configObj.diskImageUrl);
|
||||
break;
|
||||
case "github":
|
||||
blockDevice = await CheerpX.GitHubDevice.create(configObj.diskImageUrl);
|
||||
break;
|
||||
default:
|
||||
throw new Error("Unrecognized device type");
|
||||
}
|
||||
blockCache = await CheerpX.IDBDevice.create(cacheId);
|
||||
var overlayDevice = await CheerpX.OverlayDevice.create(blockDevice, blockCache);
|
||||
var webDevice = await CheerpX.WebDevice.create("");
|
||||
var documentsDevice = await CheerpX.WebDevice.create("documents");
|
||||
var dataDevice = await CheerpX.DataDevice.create();
|
||||
var mountPoints = [
|
||||
// The root filesystem, as an Ext2 image
|
||||
{type:"ext2", dev:overlayDevice, path:"/"},
|
||||
// Access to files on the Web server, relative to the current page
|
||||
{type:"dir", dev:webDevice, path:"/web"},
|
||||
// Access to read-only data coming from JavaScript
|
||||
{type:"dir", dev:dataDevice, path:"/data"},
|
||||
// Automatically created device files
|
||||
{type:"devs", path:"/dev"},
|
||||
// Pseudo-terminals
|
||||
{type:"devpts", path:"/dev/pts"},
|
||||
// The Linux 'proc' filesystem which provides information about running processes
|
||||
{type:"proc", path:"/proc"},
|
||||
// The Linux 'sysfs' filesystem which is used to enumerate emulated devices
|
||||
{type:"sys", path:"/sys"},
|
||||
// Convenient access to sample documents in the user directory
|
||||
{type:"dir", dev:documentsDevice, path:"/home/user/documents"}
|
||||
];
|
||||
try
|
||||
{
|
||||
cx = await CheerpX.Linux.create({mounts: mountPoints, networkInterface: networkInterface});
|
||||
}
|
||||
catch(e)
|
||||
{
|
||||
printMessage(errorMessage);
|
||||
printMessage([e.toString()]);
|
||||
return;
|
||||
}
|
||||
cx.registerCallback("cpuActivity", cpuCallback);
|
||||
cx.registerCallback("diskActivity", hddCallback);
|
||||
cx.registerCallback("diskLatency", latencyCallback);
|
||||
cx.registerCallback("processCreated", handleProcessCreated);
|
||||
term.scrollToBottom();
|
||||
cxReadFunc = cx.setCustomConsole(writeData, term.cols, term.rows);
|
||||
const display = document.getElementById("display");
|
||||
if(display)
|
||||
{
|
||||
setScreenSize(display);
|
||||
cx.setActivateConsole(handleActivateConsole);
|
||||
}
|
||||
// Run the command in a loop, in case the user exits
|
||||
while (true)
|
||||
{
|
||||
await cx.run(configObj.cmd, configObj.args, configObj.opts);
|
||||
}
|
||||
}
|
||||
onMount(initTerminal);
|
||||
async function handleConnect()
|
||||
{
|
||||
const w = window.open("login.html", "_blank");
|
||||
cx.networkLogin();
|
||||
try
|
||||
{
|
||||
w.location.href = await startLogin();
|
||||
}
|
||||
catch(e)
|
||||
{
|
||||
w.close();
|
||||
console.warn(e);
|
||||
}
|
||||
}
|
||||
async function handleReset()
|
||||
{
|
||||
// Be robust before initialization
|
||||
if(blockCache == null)
|
||||
return;
|
||||
await blockCache.reset();
|
||||
location.reload();
|
||||
}
|
||||
async function handleTool(tool)
|
||||
{
|
||||
return await handleToolImpl(tool, term);
|
||||
}
|
||||
async function handleSidebarPinChange(event)
|
||||
{
|
||||
sideBarPinned = event.detail;
|
||||
// Make sure the pinning state of reflected in the layout
|
||||
await tick();
|
||||
// Adjust the layout based on the new sidebar state
|
||||
triggerResize();
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="relative w-full h-full">
|
||||
<Nav />
|
||||
<div class="absolute top-10 bottom-0 left-0 right-0">
|
||||
<SideBar on:connect={handleConnect} on:reset={handleReset} handleTool={!configObj.needsDisplay || curVT == 7 ? handleTool : null} on:sidebarPinChange={handleSidebarPinChange}>
|
||||
<slot></slot>
|
||||
</SideBar>
|
||||
{#if configObj.needsDisplay}
|
||||
<div class="absolute top-0 bottom-0 {sideBarPinned ? 'left-[23.5rem]' : 'left-14'} right-0">
|
||||
<canvas class="w-full h-full cursor-none" id="display"></canvas>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="absolute top-0 bottom-0 {sideBarPinned ? 'left-[23.5rem]' : 'left-14'} right-0 p-1 scrollbar" id="console">
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
@@ -0,0 +1,7 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const cpuActivity = writable(false);
|
||||
export const diskActivity = writable(false);
|
||||
export const aiActivity = writable(false);
|
||||
export const cpuPercentage = writable(0);
|
||||
export const diskLatency = writable(0);
|
||||
@@ -0,0 +1,507 @@
|
||||
import { get, writable } from 'svelte/store';
|
||||
import { browser } from '$app/environment'
|
||||
import { aiActivity } from '$lib/activities.js'
|
||||
import { tryPlausible } from '$lib/plausible.js';
|
||||
|
||||
import Anthropic from '@anthropic-ai/sdk';
|
||||
|
||||
var client = null;
|
||||
var messages = [];
|
||||
var stopFlag = false;
|
||||
var lastScreenshot = null;
|
||||
var screenshotCanvas = null;
|
||||
var screenshotCtx = null;
|
||||
|
||||
export function setApiKey(key)
|
||||
{
|
||||
client = new Anthropic({apiKey: key, dangerouslyAllowBrowser: true});
|
||||
// Reset messages
|
||||
messages = []
|
||||
messageList.set(messages);
|
||||
localStorage.setItem("anthropic-api-key", key);
|
||||
apiState.set("READY");
|
||||
tryPlausible("ClaudeAI Key");
|
||||
}
|
||||
|
||||
function clearApiKey()
|
||||
{
|
||||
localStorage.removeItem("anthropic-api-key");
|
||||
apiState.set("KEY_REQUIRED");
|
||||
}
|
||||
|
||||
function addMessageInternal(role, content)
|
||||
{
|
||||
messages.push({role: role, content: content});
|
||||
messageList.set(messages);
|
||||
}
|
||||
|
||||
async function sendMessages(handleTool)
|
||||
{
|
||||
aiActivity.set(true);
|
||||
try
|
||||
{
|
||||
var dc = get(displayConfig);
|
||||
var tool = dc ? { type: "computer_20250124", name: "computer", display_width_px: dc.width, display_height_px: dc.height, display_number: 1 } : { type: "bash_20250124", name: "bash" }
|
||||
const config = {max_tokens: 2048,
|
||||
messages: messages,
|
||||
system: "You are running on a virtualized machine. Wait some extra time after all operations to compensate for slowdown.",
|
||||
model: 'claude-3-7-sonnet-20250219',
|
||||
tools: [tool],
|
||||
tool_choice: {type: "auto", disable_parallel_tool_use: true},
|
||||
betas: ["computer-use-2025-01-24"]
|
||||
};
|
||||
if(get(enableThinking))
|
||||
config.thinking = { type: "enabled", budget_tokens: 1024 };
|
||||
const response = await client.beta.messages.create(config);
|
||||
if(stopFlag)
|
||||
{
|
||||
aiActivity.set(false);
|
||||
return;
|
||||
}
|
||||
// Remove all the image payloads, we don't want to send them over and over again
|
||||
for(var i=0;i<messages.length;i++)
|
||||
{
|
||||
var c = messages[i].content;
|
||||
if(Array.isArray(c))
|
||||
{
|
||||
if(c[0].type == "tool_result" && c[0].content && c[0].content[0].type == "image")
|
||||
delete c[0].content;
|
||||
}
|
||||
}
|
||||
var content = response.content;
|
||||
// Be robust to multiple response
|
||||
for(var i=0;i<content.length;i++)
|
||||
{
|
||||
var c = content[i];
|
||||
if(c.type == "text")
|
||||
{
|
||||
addMessageInternal(response.role, c.text);
|
||||
}
|
||||
else if(c.type == "tool_use")
|
||||
{
|
||||
addMessageInternal(response.role, [c]);
|
||||
var commandResponse = await handleTool(c.input);
|
||||
var responseObj = {type: "tool_result", tool_use_id: c.id };
|
||||
if(commandResponse != null)
|
||||
{
|
||||
if(commandResponse instanceof Error)
|
||||
{
|
||||
console.warn(`Tool error: ${commandResponse.message}`);
|
||||
responseObj.content = commandResponse.message;
|
||||
responseObj.is_error = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
responseObj.content = commandResponse;
|
||||
}
|
||||
}
|
||||
addMessageInternal("user", [responseObj]);
|
||||
if(stopFlag)
|
||||
{
|
||||
// Maintain state consitency by stopping after adding a valid response
|
||||
aiActivity.set(false);
|
||||
return;
|
||||
}
|
||||
sendMessages(handleTool);
|
||||
}
|
||||
else if(c.type == "thinking")
|
||||
{
|
||||
addMessageInternal(response.role, [c]);
|
||||
}
|
||||
else
|
||||
{
|
||||
console.warn(`Invalid response type: ${c.type}`);
|
||||
}
|
||||
}
|
||||
if(response.stop_reason == "end_turn")
|
||||
{
|
||||
tryPlausible("ClaudeAI Success");
|
||||
aiActivity.set(false);
|
||||
}
|
||||
}
|
||||
catch(e)
|
||||
{
|
||||
tryPlausible("ClaudeAI Error");
|
||||
if(e.status == 401)
|
||||
{
|
||||
addMessageInternal('error', 'Invalid API key');
|
||||
clearApiKey();
|
||||
}
|
||||
else
|
||||
{
|
||||
addMessageInternal('error', e.error.error.message);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
export function addMessage(text, handleTool)
|
||||
{
|
||||
addMessageInternal('user', text);
|
||||
sendMessages(handleTool);
|
||||
tryPlausible("ClaudeAI Use");
|
||||
}
|
||||
|
||||
export function clearMessageHistory() {
|
||||
messages.length = 0;
|
||||
messageList.set(messages);
|
||||
}
|
||||
|
||||
export function forceStop() {
|
||||
stopFlag = true;
|
||||
return new Promise((resolve) => {
|
||||
const unsubscribe = aiActivity.subscribe((value) => {
|
||||
if (!value) {
|
||||
unsubscribe();
|
||||
stopFlag = false;
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function getMessageDetails(msg) {
|
||||
const isToolUse = Array.isArray(msg.content) && msg.content[0].type === "tool_use";
|
||||
const isToolResult = Array.isArray(msg.content) && msg.content[0].type === "tool_result";
|
||||
const isThinking = Array.isArray(msg.content) && msg.content[0].type === "thinking";
|
||||
let icon = "";
|
||||
let messageContent = "";
|
||||
|
||||
if (isToolUse) {
|
||||
let tool = msg.content[0].input;
|
||||
if (tool.action === "screenshot") {
|
||||
icon = "fa-desktop";
|
||||
messageContent = "Screenshot";
|
||||
} else if (tool.action === "mouse_move") {
|
||||
icon = "fa-mouse-pointer";
|
||||
var coords = tool.coordinate;
|
||||
messageContent = `Mouse at (${coords[0]}, ${coords[1]})`;
|
||||
} else if (tool.action === "left_click") {
|
||||
icon = "fa-mouse-pointer";
|
||||
var coords = tool.coordinate;
|
||||
messageContent = `Left click at (${coords[0]}, ${coords[1]})`;
|
||||
} else if (tool.action === "right_click") {
|
||||
icon = "fa-mouse-pointer";
|
||||
var coords = tool.coordinate;
|
||||
messageContent = `Right click at (${coords[0]}, ${coords[1]})`;
|
||||
} else if (tool.action === "wait") {
|
||||
icon = "fa-hourglass-half";
|
||||
messageContent = "Waiting";
|
||||
} else if (tool.action === "key") {
|
||||
icon = "fa-keyboard";
|
||||
messageContent = `Key press: ${tool.text}`;
|
||||
} else if (tool.action === "type") {
|
||||
icon = "fa-keyboard";
|
||||
messageContent = "Type text";
|
||||
} else {
|
||||
icon = "fa-screwdriver-wrench";
|
||||
messageContent = "Use the system";
|
||||
}
|
||||
} else if (isThinking) {
|
||||
icon = "fa-brain";
|
||||
messageContent = "Thinking...";
|
||||
} else {
|
||||
icon = msg.role === "user" ? "fa-user" : "fa-robot";
|
||||
messageContent = msg.content;
|
||||
}
|
||||
|
||||
return {
|
||||
isToolUse,
|
||||
isToolResult,
|
||||
icon,
|
||||
messageContent,
|
||||
role: msg.role
|
||||
};
|
||||
}
|
||||
|
||||
async function yieldHelper(timeout)
|
||||
{
|
||||
return new Promise(function(f2, r2)
|
||||
{
|
||||
setTimeout(f2, timeout);
|
||||
});
|
||||
}
|
||||
|
||||
async function kmsSendChar(textArea, charStr)
|
||||
{
|
||||
textArea.value = "_" + charStr;
|
||||
var ke = new KeyboardEvent("keydown");
|
||||
textArea.dispatchEvent(ke);
|
||||
var ke = new KeyboardEvent("keyup");
|
||||
textArea.dispatchEvent(ke);
|
||||
await yieldHelper(0);
|
||||
}
|
||||
|
||||
function getKmsInputElement()
|
||||
{
|
||||
// Find the CheerpX textare, it's attached to the body element
|
||||
for(const node of document.body.children)
|
||||
{
|
||||
if(node.tagName == "TEXTAREA")
|
||||
return node;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function handleToolImpl(tool, term)
|
||||
{
|
||||
if(tool.command)
|
||||
{
|
||||
var sentinel = "# End of AI command";
|
||||
var buffer = term.buffer.active;
|
||||
// Get the current cursor position
|
||||
var marker = term.registerMarker();
|
||||
var startLine = marker.line;
|
||||
marker.dispose();
|
||||
var ret = new Promise(function(f, r)
|
||||
{
|
||||
var callbackDisposer = term.onWriteParsed(function()
|
||||
{
|
||||
var curLength = buffer.length;
|
||||
// Accumulate the output and see if the sentinel has been printed
|
||||
var output = "";
|
||||
for(var i=startLine + 1;i<curLength;i++)
|
||||
{
|
||||
var curLine = buffer.getLine(i).translateToString(true, 0, term.cols);;
|
||||
if(curLine.indexOf(sentinel) >= 0)
|
||||
{
|
||||
// We are done, cleanup and return
|
||||
callbackDisposer.dispose();
|
||||
return f(output);
|
||||
}
|
||||
output += curLine + "\n";
|
||||
}
|
||||
});
|
||||
});
|
||||
term.input(tool.command);
|
||||
term.input("\n");
|
||||
term.input(sentinel);
|
||||
term.input("\n");
|
||||
return ret;
|
||||
}
|
||||
else if(tool.action)
|
||||
{
|
||||
// Desktop control
|
||||
// TODO: We should have an explicit API to interact with CheerpX display
|
||||
switch(tool.action)
|
||||
{
|
||||
case "screenshot":
|
||||
{
|
||||
// Insert a 3 seconds delay unconditionally, the reference implementation uses 2
|
||||
await yieldHelper(3000);
|
||||
var delayCount = 0;
|
||||
var display = document.getElementById("display");
|
||||
var dc = get(displayConfig);
|
||||
if(screenshotCanvas == null)
|
||||
{
|
||||
screenshotCanvas = document.createElement("canvas");
|
||||
screenshotCtx = screenshotCanvas.getContext("2d");
|
||||
}
|
||||
if(screenshotCanvas.width != dc.width || screenshotCanvas.height != dc.height)
|
||||
{
|
||||
screenshotCanvas.width = dc.width;
|
||||
screenshotCanvas.height = dc.height;
|
||||
}
|
||||
while(1)
|
||||
{
|
||||
// Resize the canvas to a Claude compatible size
|
||||
screenshotCtx.drawImage(display, 0, 0, display.width, display.height, 0, 0, dc.width, dc.height);
|
||||
var dataUrl = screenshotCanvas.toDataURL("image/png");
|
||||
if(dataUrl == lastScreenshot)
|
||||
{
|
||||
// Delay at most 3 times
|
||||
if(delayCount < 3)
|
||||
{
|
||||
// TODO: Defensive message, validate and remove
|
||||
console.warn("Identical screenshot, rate limiting");
|
||||
delayCount++;
|
||||
// Wait some time and retry
|
||||
await yieldHelper(5000);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
lastScreenshot = dataUrl;
|
||||
// Remove prefix from the encoded data
|
||||
dataUrl = dataUrl.substring("data:image/png;base64,".length);
|
||||
var imageSrc = { type: "base64", media_type: "image/png", data: dataUrl };
|
||||
var contentObj = { type: "image", source: imageSrc };
|
||||
return [ contentObj ];
|
||||
}
|
||||
}
|
||||
case "mouse_move":
|
||||
{
|
||||
var coords = tool.coordinate;
|
||||
var dc = get(displayConfig);
|
||||
var mouseX = coords[0] / dc.mouseMult;
|
||||
var mouseY = coords[1] / dc.mouseMult;
|
||||
var display = document.getElementById("display");
|
||||
var clientRect = display.getBoundingClientRect();
|
||||
var me = new MouseEvent('mousemove', { clientX: mouseX + clientRect.left, clientY: mouseY + clientRect.top });
|
||||
display.dispatchEvent(me);
|
||||
return null;
|
||||
}
|
||||
case "left_click":
|
||||
{
|
||||
var coords = tool.coordinate;
|
||||
var dc = get(displayConfig);
|
||||
var mouseX = coords[0] / dc.mouseMult;
|
||||
var mouseY = coords[1] / dc.mouseMult;
|
||||
var display = document.getElementById("display");
|
||||
var clientRect = display.getBoundingClientRect();
|
||||
var me = new MouseEvent('mousedown', { clientX: mouseX + clientRect.left, clientY: mouseY + clientRect.top, button: 0 });
|
||||
display.dispatchEvent(me);
|
||||
// This delay prevent X11 logic from debouncing the mouseup
|
||||
await yieldHelper(60)
|
||||
me = new MouseEvent('mouseup', { clientX: mouseX + clientRect.left, clientY: mouseY + clientRect.top, button: 0 });
|
||||
display.dispatchEvent(me);
|
||||
return null;
|
||||
}
|
||||
case "right_click":
|
||||
{
|
||||
var coords = tool.coordinate;
|
||||
var dc = get(displayConfig);
|
||||
var mouseX = coords[0] / dc.mouseMult;
|
||||
var mouseY = coords[1] / dc.mouseMult;
|
||||
var display = document.getElementById("display");
|
||||
var clientRect = display.getBoundingClientRect();
|
||||
var me = new MouseEvent('mousedown', { clientX: mouseX + clientRect.left, clientY: mouseY + clientRect.top, button: 2 });
|
||||
display.dispatchEvent(me);
|
||||
// This delay prevent X11 logic from debouncing the mouseup
|
||||
await yieldHelper(60)
|
||||
me = new MouseEvent('mouseup', { clientX: mouseX + clientRect.left, clientY: mouseY + clientRect.top, button: 2 });
|
||||
display.dispatchEvent(me);
|
||||
return null;
|
||||
}
|
||||
case "type":
|
||||
{
|
||||
var str = tool.text;
|
||||
return new Promise(async function(f, r)
|
||||
{
|
||||
var textArea = getKmsInputElement();
|
||||
for(var i=0;i<str.length;i++)
|
||||
{
|
||||
await kmsSendChar(textArea, str[i]);
|
||||
}
|
||||
f(null);
|
||||
});
|
||||
}
|
||||
case "key":
|
||||
{
|
||||
var textArea = getKmsInputElement();
|
||||
var key = tool.text;
|
||||
// Support arbitrary order of modifiers
|
||||
var isCtrl = false;
|
||||
var isAlt = false;
|
||||
var isShift = false;
|
||||
while(1)
|
||||
{
|
||||
if(key.startsWith("shift+"))
|
||||
{
|
||||
isShift = true;
|
||||
key = key.substr("shift+".length);
|
||||
var ke = new KeyboardEvent("keydown", {keyCode: 0x10});
|
||||
textArea.dispatchEvent(ke);
|
||||
await yieldHelper(0);
|
||||
continue;
|
||||
}
|
||||
else if(key.startsWith("ctrl+"))
|
||||
{
|
||||
isCtrl = true;
|
||||
key = key.substr("ctrl+".length);
|
||||
var ke = new KeyboardEvent("keydown", {keyCode: 0x11});
|
||||
textArea.dispatchEvent(ke);
|
||||
await yieldHelper(0);
|
||||
continue;
|
||||
}
|
||||
else if(key.startsWith("alt+"))
|
||||
{
|
||||
isAlt = true;
|
||||
key = key.substr("alt+".length);
|
||||
var ke = new KeyboardEvent("keydown", {keyCode: 0x12});
|
||||
textArea.dispatchEvent(ke);
|
||||
await yieldHelper(0);
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
var ret = null;
|
||||
// Dispatch single chars directly and parse the rest
|
||||
if(key.length == 1)
|
||||
{
|
||||
await kmsSendChar(textArea, key);
|
||||
}
|
||||
else
|
||||
{
|
||||
switch(tool.text)
|
||||
{
|
||||
case "Return":
|
||||
await kmsSendChar(textArea, "\n");
|
||||
break;
|
||||
case "Escape":
|
||||
var ke = new KeyboardEvent("keydown", {keyCode: 0x1b});
|
||||
textArea.dispatchEvent(ke);
|
||||
await yieldHelper(0);
|
||||
ke = new KeyboardEvent("keyup", {keyCode: 0x1b});
|
||||
textArea.dispatchEvent(ke);
|
||||
await yieldHelper(0);
|
||||
break;
|
||||
default:
|
||||
// TODO: Support more key combinations
|
||||
ret = new Error(`Error: Invalid key '${tool.text}'`);
|
||||
}
|
||||
}
|
||||
if(isShift)
|
||||
{
|
||||
var ke = new KeyboardEvent("keyup", {keyCode: 0x10});
|
||||
textArea.dispatchEvent(ke);
|
||||
await yieldHelper(0);
|
||||
}
|
||||
if(isCtrl)
|
||||
{
|
||||
var ke = new KeyboardEvent("keyup", {keyCode: 0x11});
|
||||
textArea.dispatchEvent(ke);
|
||||
await yieldHelper(0);
|
||||
}
|
||||
if(isAlt)
|
||||
{
|
||||
var ke = new KeyboardEvent("keyup", {keyCode: 0x12});
|
||||
textArea.dispatchEvent(ke);
|
||||
await yieldHelper(0);
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
case "wait":
|
||||
{
|
||||
// Wait 2x what the machine expects to compensate for virtualization slowdown
|
||||
await yieldHelper(tool.duration * 2 * 1000);
|
||||
return null;
|
||||
}
|
||||
default:
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
return new Error("Error: Invalid action");
|
||||
}
|
||||
else
|
||||
{
|
||||
// We can get there due to model hallucinations
|
||||
return new Error("Error: Invalid tool syntax");
|
||||
}
|
||||
}
|
||||
|
||||
function initialize()
|
||||
{
|
||||
var savedApiKey = localStorage.getItem("anthropic-api-key");
|
||||
if(savedApiKey)
|
||||
setApiKey(savedApiKey);
|
||||
}
|
||||
|
||||
export const apiState = writable("KEY_REQUIRED");
|
||||
export const messageList = writable(messages);
|
||||
export const currentMessage = writable("");
|
||||
export const displayConfig = writable(null);
|
||||
export const enableThinking = writable(false);
|
||||
|
||||
if(browser)
|
||||
initialize();
|
||||
@@ -0,0 +1,26 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Archivo:ital,wght@0,100..900;1,100..900&display=swap');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind utilities;
|
||||
|
||||
body
|
||||
{
|
||||
font-family: Archivo, sans-serif;
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: black;
|
||||
}
|
||||
|
||||
html
|
||||
{
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@media (width <= 850px)
|
||||
{
|
||||
html
|
||||
{
|
||||
font-size: calc(100vw / 55);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
const color = "\x1b[1;35m";
|
||||
const underline = "\x1b[94;4m";
|
||||
const normal = "\x1b[0m";
|
||||
export const introMessage = [
|
||||
"+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~+",
|
||||
"| |",
|
||||
"| WebVM is a virtual Linux environment running in the browser via WebAssembly.|",
|
||||
"| |",
|
||||
"| WebVM is powered by the CheerpX virtualization engine, which enables safe, |",
|
||||
"| sandboxed execution of x86 binaries, fully client-side. |",
|
||||
"| |",
|
||||
"| CheerpX includes an x86-to-WebAssembly JIT compiler, a virtual block-based |",
|
||||
"| file system, and a Linux syscall emulator. |",
|
||||
"| |",
|
||||
"| Try out the new Alpine / Xorg / i3 WebVM: " + underline + "https://webvm.io/alpine.html" + normal + " |",
|
||||
"| |",
|
||||
"| [News] BrowserCode: run Claude Code in the browser via WebAssembly |",
|
||||
"| |",
|
||||
"| " + underline + "https://browsercode.io/claude" + normal + " |",
|
||||
"| |",
|
||||
"+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~+",
|
||||
"",
|
||||
" Welcome to WebVM. If unsure, try these examples:",
|
||||
"",
|
||||
" python3 examples/python3/fibonacci.py ",
|
||||
" gcc -o helloworld examples/c/helloworld.c && ./helloworld",
|
||||
" objdump -d ./helloworld | less -M",
|
||||
" vim examples/c/helloworld.c",
|
||||
" curl --max-time 15 parrot.live # requires networking",
|
||||
"",
|
||||
];
|
||||
export const errorMessage = [
|
||||
color + "CheerpX could not start" + normal,
|
||||
"",
|
||||
"Check the DevTools console for more information",
|
||||
"",
|
||||
"CheerpX is expected to work with recent desktop versions of Chrome, Edge, Firefox and Safari",
|
||||
"",
|
||||
"Give it a try from a desktop version / another browser!",
|
||||
"",
|
||||
"CheerpX internal error message is:",
|
||||
"",
|
||||
];
|
||||
export const unexpectedErrorMessage = [
|
||||
color + "WebVM encountered an unexpected error" + normal,
|
||||
"",
|
||||
"Check the DevTools console for further information",
|
||||
"",
|
||||
"Please consider reporting a bug!",
|
||||
"",
|
||||
"CheerpX internal error message is:",
|
||||
"",
|
||||
];
|
||||
@@ -0,0 +1,190 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { browser } from '$app/environment'
|
||||
|
||||
let authKey = undefined;
|
||||
let controlUrl = undefined;
|
||||
if(browser)
|
||||
{
|
||||
let params = new URLSearchParams("?"+window.location.hash.substr(1));
|
||||
authKey = params.get("authKey") || undefined;
|
||||
controlUrl = params.get("controlUrl") || undefined;
|
||||
}
|
||||
let dashboardUrl = controlUrl ? null : "https://login.tailscale.com/admin/machines";
|
||||
let resolveLogin = null;
|
||||
let rejectLogin = null;
|
||||
let loginPromise = null;
|
||||
let connectionState = writable("DISCONNECTED");
|
||||
let exitNode = writable(false);
|
||||
|
||||
function resetLoginPromise()
|
||||
{
|
||||
loginPromise = new Promise((f,r) => {
|
||||
resolveLogin = f;
|
||||
rejectLogin = r;
|
||||
});
|
||||
}
|
||||
|
||||
function validateLoginUrl(url)
|
||||
{
|
||||
const parsedUrl = new URL(url);
|
||||
if(parsedUrl.protocol != "https:" && parsedUrl.protocol != "http:")
|
||||
throw new Error("Invalid Tailscale login URL scheme");
|
||||
return parsedUrl.href;
|
||||
}
|
||||
|
||||
function loginUrlCb(url)
|
||||
{
|
||||
try
|
||||
{
|
||||
url = validateLoginUrl(url);
|
||||
}
|
||||
catch(e)
|
||||
{
|
||||
connectionState.set("LOGINFAILED");
|
||||
rejectLogin(e);
|
||||
resetLoginPromise();
|
||||
return;
|
||||
}
|
||||
connectionState.set("LOGINREADY");
|
||||
resolveLogin(url);
|
||||
}
|
||||
|
||||
function stateUpdateCb(state)
|
||||
{
|
||||
switch(state)
|
||||
{
|
||||
case 6 /*Running*/:
|
||||
{
|
||||
connectionState.set("CONNECTED");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function netmapUpdateCb(map)
|
||||
{
|
||||
networkData.currentIp = map.self.addresses[0];
|
||||
var exitNodeFound = false;
|
||||
for(var i=0; i < map.peers.length;i++)
|
||||
{
|
||||
if(map.peers[i].exitNode)
|
||||
{
|
||||
exitNodeFound = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if(exitNodeFound)
|
||||
{
|
||||
exitNode.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
export async function startLogin()
|
||||
{
|
||||
connectionState.set("LOGINSTARTING");
|
||||
const url = await loginPromise;
|
||||
networkData.loginUrl = url;
|
||||
return url;
|
||||
}
|
||||
|
||||
async function handleCopyIP(event)
|
||||
{
|
||||
// To prevent the default contexmenu from showing up when right-clicking..
|
||||
event.preventDefault();
|
||||
// Copy the IP to the clipboard.
|
||||
try
|
||||
{
|
||||
await window.navigator.clipboard.writeText(networkData.currentIp)
|
||||
connectionState.set("IPCOPIED");
|
||||
setTimeout(() => {
|
||||
connectionState.set("CONNECTED");
|
||||
}, 2000);
|
||||
}
|
||||
catch(msg)
|
||||
{
|
||||
console.log("Copy ip to clipboard: Error: " + msg);
|
||||
}
|
||||
}
|
||||
|
||||
export function updateButtonData(state, handleConnect) {
|
||||
switch(state) {
|
||||
case "DISCONNECTED":
|
||||
return {
|
||||
buttonText: "Connect to Tailscale",
|
||||
isClickable: true,
|
||||
clickHandler: handleConnect,
|
||||
clickUrl: null,
|
||||
buttonTooltip: null,
|
||||
rightClickHandler: null
|
||||
};
|
||||
case "DOWNLOADING":
|
||||
return {
|
||||
buttonText: "Loading IP stack...",
|
||||
isClickable: false,
|
||||
clickHandler: null,
|
||||
clickUrl: null,
|
||||
buttonTooltip: null,
|
||||
rightClickHandler: null
|
||||
};
|
||||
case "LOGINSTARTING":
|
||||
return {
|
||||
buttonText: "Starting Login...",
|
||||
isClickable: false,
|
||||
clickHandler: null,
|
||||
clickUrl: null,
|
||||
buttonTooltip: null,
|
||||
rightClickHandler: null
|
||||
};
|
||||
case "LOGINREADY":
|
||||
return {
|
||||
buttonText: "Login to Tailscale",
|
||||
isClickable: true,
|
||||
clickHandler: null,
|
||||
clickUrl: networkData.loginUrl,
|
||||
buttonTooltip: null,
|
||||
rightClickHandler: null
|
||||
};
|
||||
case "LOGINFAILED":
|
||||
return {
|
||||
buttonText: "Invalid login URL",
|
||||
isClickable: false,
|
||||
clickHandler: null,
|
||||
clickUrl: null,
|
||||
buttonTooltip: null,
|
||||
rightClickHandler: null
|
||||
};
|
||||
case "CONNECTED":
|
||||
return {
|
||||
buttonText: `IP: ${networkData.currentIp}`,
|
||||
isClickable: true,
|
||||
clickHandler: null,
|
||||
clickUrl: networkData.dashboardUrl,
|
||||
buttonTooltip: "Right-click to copy",
|
||||
rightClickHandler: handleCopyIP
|
||||
};
|
||||
case "IPCOPIED":
|
||||
return {
|
||||
buttonText: "Copied!",
|
||||
isClickable: false,
|
||||
clickHandler: null,
|
||||
clickUrl: null,
|
||||
buttonTooltip: null,
|
||||
rightClickHandler: null
|
||||
};
|
||||
default:
|
||||
return {
|
||||
buttonText: `Text for state: ${state}`,
|
||||
isClickable: false,
|
||||
clickHandler: null,
|
||||
clickUrl: null,
|
||||
buttonTooltip: null,
|
||||
rightClickHandler: null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const networkInterface = { authKey: authKey, controlUrl: controlUrl, loginUrlCb: loginUrlCb, stateUpdateCb: stateUpdateCb, netmapUpdateCb: netmapUpdateCb };
|
||||
|
||||
export const networkData = { currentIp: null, connectionState: connectionState, exitNode: exitNode, loginUrl: null, dashboardUrl: dashboardUrl }
|
||||
|
||||
resetLoginPromise();
|
||||
@@ -0,0 +1,6 @@
|
||||
// Some ad-blockers block the plausible script from loading. Check if `plausible`
|
||||
// is defined before calling it.
|
||||
export function tryPlausible(msg) {
|
||||
if (self.plausible)
|
||||
plausible(msg)
|
||||
}
|
||||
Reference in New Issue
Block a user