Backup before continuing from Codex 5.2 session - User storage, compaction suggestions, streaming improvements
This commit is contained in:
@@ -7,7 +7,8 @@
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||
"test": "node --test --experimental-strip-types src/lib/__tests__/*.test.ts src/stores/__tests__/*.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@git-diff-view/solid": "^0.0.8",
|
||||
@@ -30,8 +31,10 @@
|
||||
"autoprefixer": "10.4.21",
|
||||
"postcss": "8.5.6",
|
||||
"tailwindcss": "3",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^5.0.0",
|
||||
"vite-plugin-solid": "^2.10.0"
|
||||
"vite-plugin-solid": "^2.10.0",
|
||||
"zod": "^3.25.76"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
setIsSelectingFolder,
|
||||
showFolderSelection,
|
||||
setShowFolderSelection,
|
||||
showFolderSelectionOnStart,
|
||||
setShowFolderSelectionOnStart,
|
||||
} from "./stores/ui"
|
||||
import { useConfig } from "./stores/preferences"
|
||||
import {
|
||||
@@ -74,6 +76,8 @@ const App: Component = () => {
|
||||
const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false)
|
||||
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
|
||||
|
||||
const shouldShowFolderSelection = () => !hasInstances() || showFolderSelectionOnStart()
|
||||
|
||||
const updateInstanceTabBarHeight = () => {
|
||||
if (typeof document === "undefined") return
|
||||
const element = document.querySelector<HTMLElement>(".tab-bar-instance")
|
||||
@@ -156,6 +160,7 @@ const App: Component = () => {
|
||||
clearLaunchError()
|
||||
const instanceId = await createInstance(folderPath, selectedBinary)
|
||||
setShowFolderSelection(false)
|
||||
setShowFolderSelectionOnStart(false)
|
||||
setIsAdvancedSettingsOpen(false)
|
||||
|
||||
log.info("Created instance", {
|
||||
@@ -375,7 +380,7 @@ const App: Component = () => {
|
||||
</Dialog>
|
||||
<div class="h-screen w-screen flex flex-col">
|
||||
<Show
|
||||
when={!hasInstances()}
|
||||
when={shouldShowFolderSelection()}
|
||||
fallback={
|
||||
<>
|
||||
<InstanceTabs
|
||||
@@ -432,6 +437,7 @@ const App: Component = () => {
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowFolderSelection(false)
|
||||
setShowFolderSelectionOnStart(false)
|
||||
setIsAdvancedSettingsOpen(false)
|
||||
clearLaunchError()
|
||||
}}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { createSignal, Show, onMount, For, createMemo, createEffect } from "solid-js";
|
||||
import { createSignal, Show, onMount, For, createMemo, createEffect, onCleanup } from "solid-js";
|
||||
import { sessions, withSession, setActiveSession } from "@/stores/session-state";
|
||||
import { instances } from "@/stores/instances";
|
||||
import { sendMessage } from "@/stores/session-actions";
|
||||
import { addTask, setActiveTask } from "@/stores/task-actions";
|
||||
import { sendMessage, compactSession, updateSessionAgent, updateSessionModelForSession } from "@/stores/session-actions";
|
||||
import { addTask, setActiveTask, archiveTask } from "@/stores/task-actions";
|
||||
import { messageStoreBus } from "@/stores/message-v2/bus";
|
||||
import MessageBlockList from "@/components/message-block-list";
|
||||
import MessageBlockList, { getMessageAnchorId } from "@/components/message-block-list";
|
||||
import { formatTokenTotal } from "@/lib/formatters";
|
||||
import { addToTaskQueue, getSoloState, setActiveTaskId, toggleAutonomous, toggleAutoApproval, toggleApex } from "@/stores/solo-store";
|
||||
import { getLogger } from "@/lib/logger";
|
||||
import { clearCompactionSuggestion, getCompactionSuggestion } from "@/stores/session-compaction";
|
||||
import { emitSessionSidebarRequest } from "@/lib/session-sidebar-events";
|
||||
import {
|
||||
Command,
|
||||
Plus,
|
||||
@@ -35,10 +37,18 @@ import {
|
||||
User,
|
||||
Settings,
|
||||
Key,
|
||||
FileArchive,
|
||||
Paperclip,
|
||||
} from "lucide-solid";
|
||||
import ModelSelector from "@/components/model-selector";
|
||||
import AgentSelector from "@/components/agent-selector";
|
||||
import AttachmentChip from "@/components/attachment-chip";
|
||||
import { createFileAttachment } from "@/types/attachment";
|
||||
import type { InstanceMessageStore } from "@/stores/message-v2/instance-store";
|
||||
import type { Task } from "@/types/session";
|
||||
|
||||
const OPEN_ADVANCED_SETTINGS_EVENT = "open-advanced-settings";
|
||||
|
||||
const log = getLogger("multix-chat");
|
||||
|
||||
interface MultiTaskChatProps {
|
||||
@@ -51,17 +61,29 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
|
||||
const setSelectedTaskId = (id: string | null) => setActiveTask(props.instanceId, props.sessionId, id || undefined);
|
||||
const [isSending, setIsSending] = createSignal(false);
|
||||
const [chatInput, setChatInput] = createSignal("");
|
||||
const [isCompacting, setIsCompacting] = createSignal(false);
|
||||
const [attachments, setAttachments] = createSignal<ReturnType<typeof createFileAttachment>[]>([]);
|
||||
let scrollContainer: HTMLDivElement | undefined;
|
||||
const [bottomSentinel, setBottomSentinel] = createSignal<HTMLDivElement | null>(null);
|
||||
const [showApiManager, setShowApiManager] = createSignal(false);
|
||||
const [userScrolling, setUserScrolling] = createSignal(false);
|
||||
const [lastScrollTop, setLastScrollTop] = createSignal(0);
|
||||
let fileInputRef: HTMLInputElement | undefined;
|
||||
|
||||
// Scroll to bottom helper
|
||||
const scrollToBottom = () => {
|
||||
if (scrollContainer) {
|
||||
if (scrollContainer && !userScrolling()) {
|
||||
scrollContainer.scrollTop = scrollContainer.scrollHeight;
|
||||
}
|
||||
};
|
||||
|
||||
// Track if user is manually scrolling (not at bottom)
|
||||
const checkUserScrolling = () => {
|
||||
if (!scrollContainer) return false;
|
||||
const threshold = 50;
|
||||
const isAtBottom = scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight < threshold;
|
||||
return !isAtBottom;
|
||||
};
|
||||
|
||||
// Get current session and tasks
|
||||
const session = () => {
|
||||
const instanceSessions = sessions().get(props.instanceId);
|
||||
@@ -69,7 +91,8 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
|
||||
};
|
||||
|
||||
const tasks = () => session()?.tasks || [];
|
||||
const selectedTask = () => tasks().find(t => t.id === selectedTaskId());
|
||||
const visibleTasks = createMemo(() => tasks().filter((task) => !task.archived));
|
||||
const selectedTask = () => visibleTasks().find((task) => task.id === selectedTaskId());
|
||||
|
||||
// Message store integration
|
||||
const messageStore = () => messageStoreBus.getOrCreate(props.instanceId);
|
||||
@@ -114,19 +137,20 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
|
||||
return {
|
||||
used: usage?.actualUsageTokens ?? 0,
|
||||
total: usage?.totalCost ?? 0,
|
||||
input: usage?.inputTokens ?? 0,
|
||||
output: usage?.outputTokens ?? 0,
|
||||
reasoning: usage?.reasoningTokens ?? 0,
|
||||
cacheRead: usage?.cacheReadTokens ?? 0,
|
||||
cacheWrite: usage?.cacheWriteTokens ?? 0,
|
||||
// input: usage?.inputTokens ?? 0,
|
||||
// output: usage?.outputTokens ?? 0,
|
||||
// reasoning: usage?.reasoningTokens ?? 0,
|
||||
// cacheRead: usage?.cacheReadTokens ?? 0,
|
||||
// cacheWrite: usage?.cacheWriteTokens ?? 0,
|
||||
cost: usage?.totalCost ?? 0,
|
||||
};
|
||||
});
|
||||
|
||||
// Get current model from instance
|
||||
// Get current model from active task session
|
||||
const currentModel = createMemo(() => {
|
||||
const instance = instances().get(props.instanceId);
|
||||
return instance?.modelId || "unknown";
|
||||
const instanceSessions = sessions().get(props.instanceId);
|
||||
const session = instanceSessions?.get(activeTaskSessionId());
|
||||
return session?.model?.modelId || "unknown";
|
||||
});
|
||||
|
||||
const activeTaskSessionId = createMemo(() => {
|
||||
@@ -134,6 +158,21 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
|
||||
return task?.taskSessionId || props.sessionId;
|
||||
});
|
||||
|
||||
const activeTaskSession = createMemo(() => {
|
||||
const instanceSessions = sessions().get(props.instanceId);
|
||||
return instanceSessions?.get(activeTaskSessionId());
|
||||
});
|
||||
|
||||
const currentTaskAgent = createMemo(() => activeTaskSession()?.agent || "");
|
||||
const currentTaskModel = createMemo(() => activeTaskSession()?.model || { providerId: "", modelId: "" });
|
||||
|
||||
const compactionSuggestion = createMemo(() => {
|
||||
const sessionId = activeTaskSessionId();
|
||||
return getCompactionSuggestion(props.instanceId, sessionId);
|
||||
});
|
||||
|
||||
const hasCompactionSuggestion = createMemo(() => Boolean(compactionSuggestion()));
|
||||
|
||||
const solo = () => getSoloState(props.instanceId);
|
||||
|
||||
// APEX PRO mode = SOLO + APEX combined (autonomous + auto-approval)
|
||||
@@ -181,8 +220,12 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
|
||||
const streaming = isAgentThinking();
|
||||
if (!streaming) return;
|
||||
|
||||
// During streaming, scroll periodically to keep up with content
|
||||
const interval = setInterval(scrollToBottom, 300);
|
||||
// During streaming, scroll periodically to keep up with content (unless user is scrolling)
|
||||
const interval = setInterval(() => {
|
||||
if (!userScrolling()) {
|
||||
scrollToBottom();
|
||||
}
|
||||
}, 300);
|
||||
return () => clearInterval(interval);
|
||||
});
|
||||
|
||||
@@ -191,14 +234,40 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
|
||||
const ids = filteredMessageIds();
|
||||
const thinking = isAgentThinking();
|
||||
|
||||
// Scroll when message count changes or when thinking starts
|
||||
if (ids.length > 0 || thinking) {
|
||||
// Scroll when message count changes or when thinking starts (unless user is scrolling)
|
||||
if ((ids.length > 0 || thinking) && !userScrolling()) {
|
||||
requestAnimationFrame(() => {
|
||||
setTimeout(scrollToBottom, 50);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Scroll event listener to detect user scrolling
|
||||
onMount(() => {
|
||||
const handleScroll = () => {
|
||||
if (scrollContainer) {
|
||||
const isScrollingUp = scrollContainer.scrollTop < lastScrollTop();
|
||||
const isScrollingDown = scrollContainer.scrollTop > lastScrollTop();
|
||||
setLastScrollTop(scrollContainer.scrollTop);
|
||||
|
||||
// If user scrolls up or scrolls away from bottom, set userScrolling flag
|
||||
if (checkUserScrolling()) {
|
||||
setUserScrolling(true);
|
||||
} else {
|
||||
// User is back at bottom, reset the flag
|
||||
setUserScrolling(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const container = scrollContainer;
|
||||
container?.addEventListener('scroll', handleScroll, { passive: true });
|
||||
|
||||
return () => {
|
||||
container?.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
});
|
||||
|
||||
const handleSendMessage = async () => {
|
||||
const message = chatInput().trim();
|
||||
if (!message || isSending()) return;
|
||||
@@ -253,12 +322,13 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
|
||||
props.instanceId,
|
||||
targetSessionId,
|
||||
message,
|
||||
[],
|
||||
attachments(),
|
||||
taskId || undefined
|
||||
);
|
||||
|
||||
log.info("sendMessage call completed");
|
||||
setChatInput("");
|
||||
setAttachments([]);
|
||||
|
||||
// Auto-scroll to bottom after sending
|
||||
setTimeout(scrollToBottom, 100);
|
||||
@@ -271,6 +341,21 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateTask = async () => {
|
||||
if (isSending()) return;
|
||||
setChatInput("");
|
||||
try {
|
||||
const nextIndex = tasks().length + 1;
|
||||
const title = `Task ${nextIndex}`;
|
||||
const result = await addTask(props.instanceId, props.sessionId, title);
|
||||
setSelectedTaskId(result.id);
|
||||
setTimeout(scrollToBottom, 50);
|
||||
} catch (error) {
|
||||
log.error("handleCreateTask failed", error);
|
||||
console.error("[MultiTaskChat] Task creation failed:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Enter to submit, Shift+Enter for new line
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
@@ -298,8 +383,64 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenAdvancedSettings = () => {
|
||||
// Dispatch custom event to trigger Advanced Settings modal from parent
|
||||
window.dispatchEvent(new CustomEvent(OPEN_ADVANCED_SETTINGS_EVENT, {
|
||||
detail: { instanceId: props.instanceId, sessionId: props.sessionId }
|
||||
}));
|
||||
};
|
||||
|
||||
const handleCompact = async () => {
|
||||
const targetSessionId = activeTaskSessionId();
|
||||
if (isCompacting()) return;
|
||||
|
||||
setIsCompacting(true);
|
||||
log.info("Compacting session", { instanceId: props.instanceId, sessionId: targetSessionId });
|
||||
|
||||
try {
|
||||
clearCompactionSuggestion(props.instanceId, targetSessionId);
|
||||
await compactSession(props.instanceId, targetSessionId);
|
||||
log.info("Session compacted successfully");
|
||||
} catch (error) {
|
||||
log.error("Failed to compact session", error);
|
||||
console.error("[MultiTaskChat] Compact failed:", error);
|
||||
} finally {
|
||||
setIsCompacting(false);
|
||||
log.info("Compact operation finished");
|
||||
}
|
||||
};
|
||||
|
||||
const addAttachment = (attachment: ReturnType<typeof createFileAttachment>) => {
|
||||
setAttachments((prev) => [...prev, attachment]);
|
||||
};
|
||||
|
||||
const removeAttachment = (attachmentId: string) => {
|
||||
setAttachments((prev) => prev.filter((item) => item.id !== attachmentId));
|
||||
};
|
||||
|
||||
const handleFileSelect = (event: Event) => {
|
||||
const input = event.currentTarget as HTMLInputElement;
|
||||
if (!input.files || input.files.length === 0) return;
|
||||
|
||||
Array.from(input.files).forEach((file) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const buffer = reader.result instanceof ArrayBuffer ? reader.result : null;
|
||||
const data = buffer ? new Uint8Array(buffer) : undefined;
|
||||
const attachment = createFileAttachment(file.name, file.name, file.type || "application/octet-stream", data);
|
||||
if (file.type.startsWith("image/") && typeof reader.result === "string") {
|
||||
attachment.url = reader.result;
|
||||
}
|
||||
addAttachment(attachment);
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
|
||||
input.value = "";
|
||||
};
|
||||
|
||||
return (
|
||||
<main class="h-full max-h-full flex flex-col bg-[#0a0a0b] text-zinc-300 font-sans selection:bg-indigo-500/30 overflow-hidden">
|
||||
<main class="absolute inset-0 flex flex-col bg-[#0a0a0b] text-zinc-300 font-sans selection:bg-indigo-500/30 overflow-hidden">
|
||||
{/* Header */}
|
||||
<header class="h-14 px-4 flex items-center justify-between bg-zinc-900/60 backdrop-blur-xl border-b border-white/5 relative z-30 shrink-0">
|
||||
<div class="flex items-center space-x-3">
|
||||
@@ -309,6 +450,14 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
|
||||
<Zap size={10} class="text-white fill-current" />
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => emitSessionSidebarRequest({ instanceId: props.instanceId, action: "show-skills" })}
|
||||
class="flex items-center space-x-1.5 px-2.5 py-1.5 rounded-lg border border-white/10 bg-white/5 text-zinc-400 hover:text-indigo-300 hover:border-indigo-500/30 hover:bg-indigo-500/10 transition-all"
|
||||
title="Open Skills"
|
||||
>
|
||||
<Sparkles size={12} class="text-indigo-400" />
|
||||
<span class="text-[10px] font-black uppercase tracking-tight">Skills</span>
|
||||
</button>
|
||||
|
||||
<Show when={selectedTaskId()}>
|
||||
<div class="flex items-center space-x-2 animate-in fade-in slide-in-from-left-2 duration-300">
|
||||
@@ -351,9 +500,25 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* API Key Manager Button */}
|
||||
{/* Compact Button - Context Compression & Summary */}
|
||||
<button
|
||||
onClick={() => setShowApiManager(true)}
|
||||
onClick={handleCompact}
|
||||
class={`flex items-center space-x-1.5 px-2.5 py-1.5 transition-all rounded-xl active:scale-95 border ${isCompacting()
|
||||
? "text-blue-400 bg-blue-500/15 border-blue-500/40 animate-pulse shadow-[0_0_20px_rgba(59,130,246,0.3)]"
|
||||
: hasCompactionSuggestion()
|
||||
? "text-emerald-300 bg-emerald-500/20 border-emerald-500/50 shadow-[0_0_16px_rgba(34,197,94,0.35)] animate-pulse"
|
||||
: "text-zinc-500 hover:text-blue-400 hover:bg-blue-500/10 border-transparent hover:border-blue-500/30"
|
||||
}`}
|
||||
title={isCompacting() ? "Compacting session (compressing context & creating summary)..." : "Compact session - Compress context & create summary"}
|
||||
disabled={isCompacting()}
|
||||
>
|
||||
<FileArchive size={16} strokeWidth={2} />
|
||||
<span class="text-[10px] font-bold uppercase tracking-tight">{isCompacting() ? "Compacting..." : "Compact"}</span>
|
||||
</button>
|
||||
|
||||
{/* API Key Manager Button - Opens Advanced Settings */}
|
||||
<button
|
||||
onClick={handleOpenAdvancedSettings}
|
||||
class="p-2 text-zinc-500 hover:text-emerald-400 transition-all hover:bg-emerald-500/10 rounded-xl active:scale-90"
|
||||
title="API Key Manager"
|
||||
>
|
||||
@@ -369,7 +534,7 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
|
||||
</header>
|
||||
|
||||
{/* Task Tabs (Horizontal Scroll) */}
|
||||
<Show when={tasks().length > 0}>
|
||||
<Show when={visibleTasks().length > 0}>
|
||||
<div class="flex items-center bg-[#0a0a0b] border-b border-white/5 px-2 py-2 space-x-1.5 overflow-x-auto custom-scrollbar-hidden no-scrollbar shrink-0">
|
||||
<button
|
||||
onClick={() => setSelectedTaskId(null)}
|
||||
@@ -385,7 +550,7 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
|
||||
<div class="w-px h-4 bg-white/10 shrink-0 mx-0.5" />
|
||||
|
||||
<div class="flex items-center space-x-1.5 overflow-x-auto no-scrollbar">
|
||||
<For each={tasks()}>
|
||||
<For each={visibleTasks()}>
|
||||
{(task) => (
|
||||
<button
|
||||
onClick={() => setSelectedTaskId(task.id)}
|
||||
@@ -399,6 +564,18 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
|
||||
"bg-indigo-500 shadow-[0_0_8px_rgba(99,102,241,0.4)] animate-pulse"
|
||||
}`} />
|
||||
<span class="truncate">{task.title}</span>
|
||||
<span
|
||||
role="button"
|
||||
tabindex={0}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
archiveTask(props.instanceId, props.sessionId, task.id);
|
||||
}}
|
||||
class="opacity-0 group-hover:opacity-100 text-zinc-600 hover:text-zinc-200 transition-colors"
|
||||
title="Archive task"
|
||||
>
|
||||
<X size={12} />
|
||||
</span>
|
||||
<Show when={selectedTaskId() === task.id}>
|
||||
<div class="ml-1 w-1 h-1 bg-indigo-400 rounded-full animate-ping" />
|
||||
</Show>
|
||||
@@ -409,8 +586,7 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setChatInput("");
|
||||
setSelectedTaskId(null);
|
||||
handleCreateTask();
|
||||
}}
|
||||
class="flex items-center justify-center w-8 h-8 rounded-xl text-zinc-500 hover:text-indigo-400 hover:bg-indigo-500/10 transition-all shrink-0 ml-1 border border-transparent hover:border-indigo-500/20"
|
||||
title="New Task"
|
||||
@@ -420,6 +596,25 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={selectedTask()}>
|
||||
<div class="px-4 py-3 border-b border-white/5 bg-zinc-950/40">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
<AgentSelector
|
||||
instanceId={props.instanceId}
|
||||
sessionId={activeTaskSessionId()}
|
||||
currentAgent={currentTaskAgent()}
|
||||
onAgentChange={(agent) => updateSessionAgent(props.instanceId, activeTaskSessionId(), agent)}
|
||||
/>
|
||||
<ModelSelector
|
||||
instanceId={props.instanceId}
|
||||
sessionId={activeTaskSessionId()}
|
||||
currentModel={currentTaskModel()}
|
||||
onModelChange={(model) => updateSessionModelForSession(props.instanceId, activeTaskSessionId(), model)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Main Content Area - min-h-0 is critical for flex containers with overflow */}
|
||||
<div class="flex-1 min-h-0 relative overflow-hidden flex">
|
||||
{/* Main chat area */}
|
||||
@@ -428,6 +623,18 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
|
||||
ref={scrollContainer}
|
||||
class="flex-1 min-h-0 overflow-y-auto overflow-x-hidden custom-scrollbar"
|
||||
>
|
||||
<Show when={hasCompactionSuggestion()}>
|
||||
<div class="mx-3 mt-3 mb-1 rounded-xl border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-[11px] text-emerald-200 flex items-center justify-between gap-3">
|
||||
<span class="font-semibold">Compact suggested: {compactionSuggestion()?.reason}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="px-2.5 py-1 rounded-lg text-[10px] font-bold uppercase tracking-wide bg-emerald-500/20 border border-emerald-500/40 text-emerald-200 hover:bg-emerald-500/30 transition-colors"
|
||||
onClick={handleCompact}
|
||||
>
|
||||
Compact now
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!selectedTaskId()} fallback={
|
||||
<div class="p-3 pb-4 overflow-x-hidden">
|
||||
<MessageBlockList
|
||||
@@ -456,12 +663,12 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
|
||||
<span class="text-[10px] font-bold text-zinc-600 uppercase tracking-widest">Active Threads</span>
|
||||
<div class="h-px flex-1 bg-white/5 mx-4" />
|
||||
<span class="text-[10px] font-black text-indigo-400 bg-indigo-500/10 px-2 py-0.5 rounded border border-indigo-500/20">
|
||||
{tasks().length}
|
||||
{visibleTasks().length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3">
|
||||
<For each={tasks()} fallback={
|
||||
<For each={visibleTasks()} fallback={
|
||||
<div class="group relative p-8 rounded-3xl border border-dashed border-white/5 bg-zinc-900/20 flex flex-col items-center justify-center text-center space-y-4 transition-all hover:bg-zinc-900/40 hover:border-white/10">
|
||||
<div class="w-12 h-12 rounded-2xl bg-white/5 flex items-center justify-center text-zinc-600 group-hover:text-indigo-400 group-hover:scale-110 transition-all duration-500">
|
||||
<Plus size={24} strokeWidth={1.5} />
|
||||
@@ -491,7 +698,21 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
|
||||
<span>{task.messageIds?.length || 0} messages</span>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight size={16} class="text-zinc-700 group-hover:text-indigo-400 group-hover:translate-x-1 transition-all" />
|
||||
<div class="flex items-center space-x-2">
|
||||
<span
|
||||
role="button"
|
||||
tabindex={0}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
archiveTask(props.instanceId, props.sessionId, task.id);
|
||||
}}
|
||||
class="text-zinc-600 hover:text-zinc-200 transition-colors"
|
||||
title="Archive task"
|
||||
>
|
||||
<X size={14} />
|
||||
</span>
|
||||
<ChevronRight size={16} class="text-zinc-700 group-hover:text-indigo-400 group-hover:translate-x-1 transition-all" />
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
@@ -572,20 +793,22 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
|
||||
<span class="text-[9px] font-bold text-indigo-400">{isAgentThinking() ? "THINKING" : "SENDING"}</span>
|
||||
</div>
|
||||
</Show>
|
||||
{/* STOP button */}
|
||||
<Show when={isAgentThinking()}>
|
||||
<button
|
||||
onClick={handleStopAgent}
|
||||
class="flex items-center space-x-1 px-2 py-0.5 bg-rose-500/20 hover:bg-rose-500/30 rounded border border-rose-500/40 text-[9px] font-bold text-rose-400 transition-all"
|
||||
title="Stop agent"
|
||||
>
|
||||
<StopCircle size={10} />
|
||||
<span>STOP</span>
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={attachments().length > 0}>
|
||||
<div class="flex flex-wrap gap-2 mb-2">
|
||||
<For each={attachments()}>
|
||||
{(attachment) => (
|
||||
<AttachmentChip
|
||||
attachment={attachment}
|
||||
onRemove={() => removeAttachment(attachment.id)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Text Input */}
|
||||
<textarea
|
||||
value={chatInput()}
|
||||
@@ -601,49 +824,32 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
|
||||
<div class="flex items-center justify-between pt-2 border-t border-white/5 mt-2">
|
||||
<div class="flex items-center space-x-2 flex-wrap gap-y-1">
|
||||
{/* Detailed token stats */}
|
||||
<Show when={tokenStats().input > 0 || tokenStats().output > 0}>
|
||||
<div class="flex items-center space-x-1.5">
|
||||
<span class="text-[8px] font-bold text-zinc-600 uppercase">INPUT</span>
|
||||
<span class="text-[9px] font-bold text-zinc-400">{tokenStats().input.toLocaleString()}</span>
|
||||
</div>
|
||||
<div class="w-px h-3 bg-zinc-800" />
|
||||
<div class="flex items-center space-x-1.5">
|
||||
<span class="text-[8px] font-bold text-zinc-600 uppercase">OUTPUT</span>
|
||||
<span class="text-[9px] font-bold text-zinc-400">{tokenStats().output.toLocaleString()}</span>
|
||||
</div>
|
||||
<Show when={tokenStats().reasoning > 0}>
|
||||
<div class="w-px h-3 bg-zinc-800" />
|
||||
<div class="flex items-center space-x-1.5">
|
||||
<span class="text-[8px] font-bold text-zinc-600 uppercase">REASONING</span>
|
||||
<span class="text-[9px] font-bold text-amber-400">{tokenStats().reasoning.toLocaleString()}</span>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={tokenStats().cacheRead > 0}>
|
||||
<div class="w-px h-3 bg-zinc-800" />
|
||||
<div class="flex items-center space-x-1.5">
|
||||
<span class="text-[8px] font-bold text-zinc-600 uppercase">CACHE READ</span>
|
||||
<span class="text-[9px] font-bold text-emerald-400">{tokenStats().cacheRead.toLocaleString()}</span>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={tokenStats().cacheWrite > 0}>
|
||||
<div class="w-px h-3 bg-zinc-800" />
|
||||
<div class="flex items-center space-x-1.5">
|
||||
<span class="text-[8px] font-bold text-zinc-600 uppercase">CACHE WRITE</span>
|
||||
<span class="text-[9px] font-bold text-cyan-400">{tokenStats().cacheWrite.toLocaleString()}</span>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="w-px h-3 bg-zinc-800" />
|
||||
<div class="flex items-center space-x-1.5">
|
||||
<span class="text-[8px] font-bold text-zinc-600 uppercase">COST</span>
|
||||
<span class="text-[9px] font-bold text-violet-400">${tokenStats().cost.toFixed(4)}</span>
|
||||
</div>
|
||||
<div class="w-px h-3 bg-zinc-800" />
|
||||
<div class="flex items-center space-x-1.5">
|
||||
<span class="text-[8px] font-bold text-zinc-600 uppercase">MODEL</span>
|
||||
<span class="text-[9px] font-bold text-indigo-400">{currentModel()}</span>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!(tokenStats().input > 0 || tokenStats().output > 0)}>
|
||||
{/* Detailed breakdown not available */}
|
||||
<div class="flex items-center space-x-1.5">
|
||||
<span class="text-[8px] font-bold text-zinc-600 uppercase">COST</span>
|
||||
<span class="text-[9px] font-bold text-violet-400">${tokenStats().cost.toFixed(4)}</span>
|
||||
</div>
|
||||
<div class="w-px h-3 bg-zinc-800" />
|
||||
<div class="flex items-center space-x-1.5">
|
||||
<span class="text-[8px] font-bold text-zinc-600 uppercase">MODEL</span>
|
||||
<span class="text-[9px] font-bold text-indigo-400">{currentModel()}</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-1.5">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
class="sr-only"
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef?.click()}
|
||||
class="text-zinc-600 hover:text-indigo-300 transition-colors p-1"
|
||||
title="Attach files"
|
||||
>
|
||||
<Paperclip size={14} />
|
||||
</button>
|
||||
<button class="text-zinc-600 hover:text-zinc-400 transition-colors p-1">
|
||||
<Hash size={14} />
|
||||
</button>
|
||||
@@ -655,23 +861,35 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
|
||||
<kbd class="px-1.5 py-0.5 bg-zinc-800 rounded text-[9px] font-bold border border-white/5">ENTER</kbd>
|
||||
<span class="text-[9px]">to send</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSendMessage}
|
||||
disabled={!chatInput().trim() || isSending()}
|
||||
class="px-4 py-1.5 bg-indigo-500 hover:bg-indigo-400 text-white rounded-lg text-[11px] font-bold uppercase tracking-wide transition-all disabled:opacity-30 disabled:cursor-not-allowed flex items-center space-x-1.5"
|
||||
>
|
||||
<Show when={isSending()} fallback={
|
||||
<>
|
||||
<span>{selectedTaskId() ? "Update Task" : "Launch Task"}</span>
|
||||
<ArrowUp size={12} strokeWidth={3} />
|
||||
</>
|
||||
}>
|
||||
<Loader2 size={12} class="animate-spin" />
|
||||
<div class="flex items-center space-x-2">
|
||||
<Show when={isAgentThinking() || isSending()}>
|
||||
<button
|
||||
onClick={handleStopAgent}
|
||||
class="px-3 py-1.5 bg-rose-500/20 hover:bg-rose-500/30 text-rose-300 rounded-lg text-[10px] font-bold uppercase tracking-wide transition-all border border-rose-500/30"
|
||||
title="Stop response"
|
||||
>
|
||||
<StopCircle size={12} class="inline-block mr-1" />
|
||||
Stop
|
||||
</button>
|
||||
</Show>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSendMessage}
|
||||
disabled={!chatInput().trim() || isSending()}
|
||||
class="px-4 py-1.5 bg-indigo-500 hover:bg-indigo-400 text-white rounded-lg text-[11px] font-bold uppercase tracking-wide transition-all disabled:opacity-30 disabled:cursor-not-allowed flex items-center space-x-1.5"
|
||||
>
|
||||
<Show when={isSending()} fallback={
|
||||
<>
|
||||
<span>{selectedTaskId() ? "Update Task" : "Launch Task"}</span>
|
||||
<ArrowUp size={12} strokeWidth={3} />
|
||||
</>
|
||||
}>
|
||||
<Loader2 size={12} class="animate-spin" />
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -679,30 +897,37 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
|
||||
|
||||
{/* Message Navigation Sidebar - YOU/ASST labels with hover preview */}
|
||||
<Show when={selectedTaskId() && filteredMessageIds().length > 0}>
|
||||
<div class="w-14 shrink-0 bg-zinc-900/40 border-l border-white/5 overflow-y-auto py-2 px-1.5 flex flex-col items-center gap-1">
|
||||
<div class="w-14 shrink-0 bg-zinc-900/40 border-l border-white/5 overflow-hidden py-2 px-1.5 flex flex-col items-center gap-1">
|
||||
<For each={filteredMessageIds()}>
|
||||
{(messageId, index) => {
|
||||
const msg = () => messageStore().getMessage(messageId);
|
||||
const isUser = () => msg()?.role === "user";
|
||||
const [showPreview, setShowPreview] = createSignal(false);
|
||||
|
||||
// Get message preview text (first 100 chars)
|
||||
// Get message preview text (first 150 chars)
|
||||
const previewText = () => {
|
||||
const message = msg();
|
||||
if (!message) return "";
|
||||
const content = message.parts?.[0]?.content || message.content || "";
|
||||
const content = (message.parts?.[0] as any)?.text || (message.parts?.[0] as any)?.content || (message as any).content || "";
|
||||
const text = typeof content === "string" ? content : JSON.stringify(content);
|
||||
return text.length > 100 ? text.substring(0, 100) + "..." : text;
|
||||
return text.length > 150 ? text.substring(0, 150) + "..." : text;
|
||||
};
|
||||
|
||||
const handleTabClick = () => {
|
||||
const anchorId = getMessageAnchorId(messageId);
|
||||
const element = scrollContainer?.querySelector(`#${anchorId}`);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
// Highlight the message briefly
|
||||
element.classList.add("message-highlight");
|
||||
setTimeout(() => element.classList.remove("message-highlight"), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="relative group">
|
||||
<button
|
||||
onClick={() => {
|
||||
// Scroll to message
|
||||
const element = document.getElementById(`msg-${messageId}`);
|
||||
element?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}}
|
||||
onClick={handleTabClick}
|
||||
onMouseEnter={() => setShowPreview(true)}
|
||||
onMouseLeave={() => setShowPreview(false)}
|
||||
class={`w-10 py-1.5 rounded text-[8px] font-black uppercase transition-all cursor-pointer ${isUser()
|
||||
@@ -715,11 +940,16 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
|
||||
|
||||
{/* Hover Preview Tooltip */}
|
||||
<Show when={showPreview()}>
|
||||
<div class="absolute right-full mr-2 top-0 w-64 max-h-32 overflow-hidden bg-zinc-900 border border-white/10 rounded-lg shadow-xl p-2 z-50 animate-in fade-in slide-in-from-right-2 duration-150">
|
||||
<div class={`text-[9px] font-bold uppercase mb-1 ${isUser() ? "text-indigo-400" : "text-emerald-400"}`}>
|
||||
{isUser() ? "You" : "Assistant"} • Message {index() + 1}
|
||||
<div class="absolute right-full mr-2 top-0 w-72 max-h-40 overflow-y-auto bg-zinc-900 border border-white/10 rounded-lg shadow-xl p-3 z-50 animate-in fade-in slide-in-from-right-2 duration-150 custom-scrollbar">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class={`text-[9px] font-bold uppercase ${isUser() ? "text-indigo-400" : "text-emerald-400"}`}>
|
||||
{isUser() ? "You" : "Assistant"} • Msg {index() + 1}
|
||||
</div>
|
||||
<div class="text-[8px] text-zinc-600">
|
||||
{msg()?.status === "streaming" ? "• Streaming" : ""}
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-[11px] text-zinc-300 leading-relaxed line-clamp-4">
|
||||
<p class="text-[10px] text-zinc-300 leading-relaxed whitespace-pre-wrap">
|
||||
{previewText()}
|
||||
</p>
|
||||
</div>
|
||||
@@ -732,79 +962,7 @@ export default function MultiTaskChat(props: MultiTaskChatProps) {
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* API Key Manager Modal */}
|
||||
<Show when={showApiManager()}>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={() => setShowApiManager(false)}>
|
||||
<div class="w-full max-w-2xl bg-zinc-900 border border-white/10 rounded-2xl shadow-2xl overflow-hidden" onClick={(e) => e.stopPropagation()}>
|
||||
<header class="px-6 py-4 border-b border-white/10 flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-emerald-500 to-teal-600 flex items-center justify-center">
|
||||
<Key size={20} class="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-white">API Key Manager</h2>
|
||||
<p class="text-xs text-zinc-500">Manage your access tokens for various AI providers</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => setShowApiManager(false)} class="p-2 hover:bg-white/10 rounded-lg transition-colors">
|
||||
<X size={20} class="text-zinc-400" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="flex h-[400px]">
|
||||
{/* Sidebar */}
|
||||
<div class="w-48 bg-zinc-950/50 border-r border-white/5 p-3 space-y-1">
|
||||
<div class="text-[10px] font-bold text-zinc-600 uppercase tracking-widest px-2 py-1">Built-in</div>
|
||||
<button class="w-full text-left px-3 py-2 rounded-lg bg-emerald-500/20 border border-emerald-500/30 text-emerald-400 text-sm font-medium">
|
||||
NomadArch (Free)
|
||||
</button>
|
||||
<button class="w-full text-left px-3 py-2 rounded-lg hover:bg-white/5 text-zinc-400 hover:text-white text-sm font-medium transition-colors">
|
||||
Ollama Cloud
|
||||
</button>
|
||||
<button class="w-full text-left px-3 py-2 rounded-lg hover:bg-white/5 text-zinc-400 hover:text-white text-sm font-medium transition-colors">
|
||||
OpenAI
|
||||
</button>
|
||||
<button class="w-full text-left px-3 py-2 rounded-lg hover:bg-white/5 text-zinc-400 hover:text-white text-sm font-medium transition-colors">
|
||||
Anthropic
|
||||
</button>
|
||||
<button class="w-full text-left px-3 py-2 rounded-lg hover:bg-white/5 text-zinc-400 hover:text-white text-sm font-medium transition-colors">
|
||||
OpenRouter
|
||||
</button>
|
||||
<div class="text-[10px] font-bold text-zinc-600 uppercase tracking-widest px-2 py-1 mt-4">Custom</div>
|
||||
<button class="w-full text-left px-3 py-2 rounded-lg hover:bg-white/5 text-zinc-400 hover:text-white text-sm font-medium transition-colors flex items-center space-x-2">
|
||||
<Plus size={14} />
|
||||
<span>Add Custom Provider</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div class="flex-1 p-6 flex flex-col items-center justify-center">
|
||||
<div class="w-16 h-16 rounded-2xl bg-emerald-500/20 flex items-center justify-center mb-4">
|
||||
<Shield size={32} class="text-emerald-400" />
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-white mb-2">NomadArch Managed Models</h3>
|
||||
<p class="text-sm text-zinc-400 text-center max-w-sm mb-6">
|
||||
These models are provided free of charge as part of the NomadArch platform. No API key or configuration is required to use them.
|
||||
</p>
|
||||
<div class="bg-zinc-800/50 rounded-xl p-4 w-full max-w-sm space-y-3">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-zinc-500">Providers</span>
|
||||
<span class="text-white font-medium">Qwen, DeepSeek, Google</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-zinc-500">Rate Limit</span>
|
||||
<span class="text-white font-medium">Generous / Unlimited</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-zinc-500">Status</span>
|
||||
<span class="text-emerald-400 font-bold">ACTIVE</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</main>
|
||||
</main >
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,8 +5,9 @@ import AdvancedSettingsModal from "./advanced-settings-modal"
|
||||
import DirectoryBrowserDialog from "./directory-browser-dialog"
|
||||
import Kbd from "./kbd"
|
||||
import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions"
|
||||
import { users, activeUser, refreshUsers, createUser, updateUser, deleteUser, loginUser, createGuest } from "../stores/users"
|
||||
|
||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||
const nomadArchLogo = new URL("../images/NomadArch-Icon.png", import.meta.url).href
|
||||
|
||||
|
||||
interface FolderSelectionViewProps {
|
||||
@@ -24,9 +25,15 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
|
||||
const [selectedBinary, setSelectedBinary] = createSignal(preferences().lastUsedBinary || "opencode")
|
||||
const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false)
|
||||
const [showUserModal, setShowUserModal] = createSignal(false)
|
||||
const [newUserName, setNewUserName] = createSignal("")
|
||||
const [newUserPassword, setNewUserPassword] = createSignal("")
|
||||
const [loginPassword, setLoginPassword] = createSignal("")
|
||||
const [loginTargetId, setLoginTargetId] = createSignal<string | null>(null)
|
||||
const [userError, setUserError] = createSignal<string | null>(null)
|
||||
const nativeDialogsAvailable = supportsNativeDialogs()
|
||||
let recentListRef: HTMLDivElement | undefined
|
||||
|
||||
|
||||
const folders = () => recentFolders()
|
||||
const isLoading = () => Boolean(props.isLoading)
|
||||
|
||||
@@ -153,6 +160,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
|
||||
onMount(() => {
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
refreshUsers()
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("keydown", handleKeyDown)
|
||||
})
|
||||
@@ -174,7 +182,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
if (isLoading()) return
|
||||
props.onSelectFolder(path, selectedBinary())
|
||||
}
|
||||
|
||||
|
||||
async function handleBrowse() {
|
||||
if (isLoading()) return
|
||||
setFocusMode("new")
|
||||
@@ -191,17 +199,48 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
}
|
||||
setIsFolderBrowserOpen(true)
|
||||
}
|
||||
|
||||
|
||||
function handleBrowserSelect(path: string) {
|
||||
setIsFolderBrowserOpen(false)
|
||||
handleFolderSelect(path)
|
||||
}
|
||||
|
||||
|
||||
function handleBinaryChange(binary: string) {
|
||||
|
||||
setSelectedBinary(binary)
|
||||
}
|
||||
|
||||
async function handleCreateUser() {
|
||||
const name = newUserName().trim()
|
||||
const password = newUserPassword()
|
||||
if (!name || password.length < 4) {
|
||||
setUserError("Provide a name and a 4+ character password.")
|
||||
return
|
||||
}
|
||||
setUserError(null)
|
||||
await createUser(name, password)
|
||||
setNewUserName("")
|
||||
setNewUserPassword("")
|
||||
}
|
||||
|
||||
async function handleLogin(userId: string) {
|
||||
const password = loginTargetId() === userId ? loginPassword() : ""
|
||||
const ok = await loginUser(userId, password)
|
||||
if (!ok) {
|
||||
setUserError("Invalid password.")
|
||||
return
|
||||
}
|
||||
setUserError(null)
|
||||
setLoginPassword("")
|
||||
setLoginTargetId(null)
|
||||
setShowUserModal(false)
|
||||
}
|
||||
|
||||
async function handleGuest() {
|
||||
await createGuest()
|
||||
setShowUserModal(false)
|
||||
}
|
||||
|
||||
function handleRemove(path: string, e?: Event) {
|
||||
if (isLoading()) return
|
||||
e?.stopPropagation()
|
||||
@@ -231,6 +270,15 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
class="w-full max-w-3xl h-full px-4 sm:px-8 pb-2 flex flex-col overflow-hidden"
|
||||
aria-busy={isLoading() ? "true" : "false"}
|
||||
>
|
||||
<div class="absolute top-4 left-6">
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary"
|
||||
onClick={() => setShowUserModal(true)}
|
||||
>
|
||||
Users
|
||||
</button>
|
||||
</div>
|
||||
<Show when={props.onOpenRemoteAccess}>
|
||||
<div class="absolute top-4 right-6">
|
||||
<button
|
||||
@@ -242,15 +290,23 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="mb-6 text-center shrink-0">
|
||||
<div class="mb-3 flex justify-center">
|
||||
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-32 w-auto sm:h-48" loading="lazy" />
|
||||
<div class="mb-6 text-center shrink-0">
|
||||
<div class="mb-3 flex justify-center">
|
||||
<img src={nomadArchLogo} alt="NomadArch logo" class="h-32 w-auto sm:h-48" loading="lazy" />
|
||||
</div>
|
||||
<h1 class="mb-2 text-3xl font-semibold text-primary">NomadArch</h1>
|
||||
<p class="text-xs text-muted mb-1">Forked from OpenCode</p>
|
||||
<Show when={activeUser()}>
|
||||
{(user) => (
|
||||
<p class="text-xs text-muted mb-1">
|
||||
Active user: <span class="text-secondary font-medium">{user().name}</span>
|
||||
</p>
|
||||
)}
|
||||
</Show>
|
||||
<p class="text-base text-secondary">Select a folder to start coding with AI</p>
|
||||
</div>
|
||||
<h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1>
|
||||
<p class="text-base text-secondary">Select a folder to start coding with AI</p>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="space-y-4 flex-1 min-h-0 overflow-hidden flex flex-col">
|
||||
|
||||
<Show
|
||||
@@ -419,6 +475,104 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
onClose={() => setIsFolderBrowserOpen(false)}
|
||||
onSelect={handleBrowserSelect}
|
||||
/>
|
||||
|
||||
<Show when={showUserModal()}>
|
||||
<div class="modal-overlay">
|
||||
<div class="fixed inset-0 flex items-center justify-center p-4">
|
||||
<div class="modal-surface w-full max-w-lg p-5 flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-primary">Users</h2>
|
||||
<button class="selector-button selector-button-secondary" onClick={() => setShowUserModal(false)}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={userError()}>
|
||||
{(msg) => <div class="text-sm text-red-400">{msg()}</div>}
|
||||
</Show>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="text-xs uppercase tracking-wide text-muted">Available</div>
|
||||
<For each={users()}>
|
||||
{(user) => (
|
||||
<div class="flex items-center justify-between gap-3 px-3 py-2 rounded border border-base bg-surface-secondary">
|
||||
<div class="text-sm text-primary">
|
||||
{user.name}
|
||||
<Show when={user.isGuest}>
|
||||
<span class="ml-2 text-[10px] uppercase text-amber-400">Guest</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Show when={!user.isGuest && loginTargetId() === user.id}>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
value={loginPassword()}
|
||||
onInput={(event) => setLoginPassword(event.currentTarget.value)}
|
||||
class="rounded-md bg-white/5 border border-white/10 px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:border-blue-500/60"
|
||||
/>
|
||||
</Show>
|
||||
<button
|
||||
class="selector-button selector-button-primary"
|
||||
onClick={() => {
|
||||
if (user.isGuest) {
|
||||
void handleLogin(user.id)
|
||||
return
|
||||
}
|
||||
if (loginTargetId() !== user.id) {
|
||||
setLoginTargetId(user.id)
|
||||
setLoginPassword("")
|
||||
return
|
||||
}
|
||||
void handleLogin(user.id)
|
||||
}}
|
||||
>
|
||||
{activeUser()?.id === user.id ? "Active" : loginTargetId() === user.id ? "Unlock" : "Login"}
|
||||
</button>
|
||||
<button
|
||||
class="selector-button selector-button-secondary"
|
||||
onClick={() => void deleteUser(user.id)}
|
||||
disabled={user.isGuest}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="text-xs uppercase tracking-wide text-muted">Create User</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Name"
|
||||
value={newUserName()}
|
||||
onInput={(event) => setNewUserName(event.currentTarget.value)}
|
||||
class="rounded-md bg-white/5 border border-white/10 px-3 py-2 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/60"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
value={newUserPassword()}
|
||||
onInput={(event) => setNewUserPassword(event.currentTarget.value)}
|
||||
class="rounded-md bg-white/5 border border-white/10 px-3 py-2 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/60"
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<button class="selector-button selector-button-primary" onClick={() => void handleCreateUser()}>
|
||||
Create
|
||||
</button>
|
||||
<button class="selector-button selector-button-secondary" onClick={() => void handleGuest()}>
|
||||
Guest Mode
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Component, For, Show, createMemo } from "solid-js"
|
||||
import { Component, For, Show, createMemo, createSignal } from "solid-js"
|
||||
import type { Instance } from "../types/instance"
|
||||
import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context"
|
||||
import InstanceServiceStatus from "./instance-service-status"
|
||||
import DirectoryBrowserDialog from "./directory-browser-dialog"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
import { showToastNotification } from "../lib/notifications"
|
||||
|
||||
interface InstanceInfoProps {
|
||||
instance: Instance
|
||||
@@ -22,6 +25,68 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
const env = environmentVariables()
|
||||
return env ? Object.entries(env) : []
|
||||
})
|
||||
const [showExportDialog, setShowExportDialog] = createSignal(false)
|
||||
const [showImportSourceDialog, setShowImportSourceDialog] = createSignal(false)
|
||||
const [showImportDestinationDialog, setShowImportDestinationDialog] = createSignal(false)
|
||||
const [importSourcePath, setImportSourcePath] = createSignal<string | null>(null)
|
||||
const [includeConfig, setIncludeConfig] = createSignal(false)
|
||||
const [isExporting, setIsExporting] = createSignal(false)
|
||||
const [isImporting, setIsImporting] = createSignal(false)
|
||||
|
||||
const handleExport = async (destination: string) => {
|
||||
if (isExporting()) return
|
||||
setIsExporting(true)
|
||||
try {
|
||||
const response = await serverApi.exportWorkspace(currentInstance().id, {
|
||||
destination,
|
||||
includeConfig: includeConfig(),
|
||||
})
|
||||
showToastNotification({
|
||||
title: "Workspace exported",
|
||||
message: `Export saved to ${response.destination}`,
|
||||
variant: "success",
|
||||
duration: 7000,
|
||||
})
|
||||
} catch (error) {
|
||||
showToastNotification({
|
||||
title: "Export failed",
|
||||
message: error instanceof Error ? error.message : "Unable to export workspace",
|
||||
variant: "error",
|
||||
duration: 8000,
|
||||
})
|
||||
} finally {
|
||||
setIsExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleImportDestination = async (destination: string) => {
|
||||
const source = importSourcePath()
|
||||
if (!source || isImporting()) return
|
||||
setIsImporting(true)
|
||||
try {
|
||||
const response = await serverApi.importWorkspace({
|
||||
source,
|
||||
destination,
|
||||
includeConfig: includeConfig(),
|
||||
})
|
||||
showToastNotification({
|
||||
title: "Workspace imported",
|
||||
message: `Imported workspace into ${response.path}`,
|
||||
variant: "success",
|
||||
duration: 7000,
|
||||
})
|
||||
} catch (error) {
|
||||
showToastNotification({
|
||||
title: "Import failed",
|
||||
message: error instanceof Error ? error.message : "Unable to import workspace",
|
||||
variant: "error",
|
||||
duration: 8000,
|
||||
})
|
||||
} finally {
|
||||
setIsImporting(false)
|
||||
setImportSourcePath(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="panel">
|
||||
@@ -116,6 +181,39 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
|
||||
<InstanceServiceStatus initialInstance={props.instance} class="space-y-3" />
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="text-xs font-medium text-muted uppercase tracking-wide">Workspace Export / Import</div>
|
||||
<label class="flex items-center gap-2 text-xs text-secondary">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeConfig()}
|
||||
onChange={(event) => setIncludeConfig(event.currentTarget.checked)}
|
||||
/>
|
||||
Include user config (settings, keys)
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="button-secondary"
|
||||
disabled={isExporting()}
|
||||
onClick={() => setShowExportDialog(true)}
|
||||
>
|
||||
{isExporting() ? "Exporting..." : "Export Workspace"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="button-secondary"
|
||||
disabled={isImporting()}
|
||||
onClick={() => setShowImportSourceDialog(true)}
|
||||
>
|
||||
{isImporting() ? "Importing..." : "Import Workspace"}
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-[11px] text-muted">
|
||||
Export creates a portable folder. Import restores the workspace into a chosen destination.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={isLoadingMetadata()}>
|
||||
<div class="text-xs text-muted py-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
@@ -155,6 +253,37 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DirectoryBrowserDialog
|
||||
open={showExportDialog()}
|
||||
title="Export workspace to folder"
|
||||
description="Choose a destination folder for the export package."
|
||||
onClose={() => setShowExportDialog(false)}
|
||||
onSelect={(destination) => {
|
||||
setShowExportDialog(false)
|
||||
void handleExport(destination)
|
||||
}}
|
||||
/>
|
||||
<DirectoryBrowserDialog
|
||||
open={showImportSourceDialog()}
|
||||
title="Select export folder"
|
||||
description="Pick the export folder that contains the workspace package."
|
||||
onClose={() => setShowImportSourceDialog(false)}
|
||||
onSelect={(source) => {
|
||||
setShowImportSourceDialog(false)
|
||||
setImportSourcePath(source)
|
||||
setShowImportDestinationDialog(true)
|
||||
}}
|
||||
/>
|
||||
<DirectoryBrowserDialog
|
||||
open={showImportDestinationDialog()}
|
||||
title="Select destination folder"
|
||||
description="Choose the folder where the workspace should be imported."
|
||||
onClose={() => setShowImportDestinationDialog(false)}
|
||||
onSelect={(destination) => {
|
||||
setShowImportDestinationDialog(false)
|
||||
void handleImportDestination(destination)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ import { formatTokenTotal } from "../../lib/formatters"
|
||||
import { sseManager } from "../../lib/sse-manager"
|
||||
import { getLogger } from "../../lib/logger"
|
||||
import AdvancedSettingsModal from "../advanced-settings-modal"
|
||||
import { showConfirmDialog } from "../../stores/alerts"
|
||||
import {
|
||||
getSoloState,
|
||||
toggleAutonomous,
|
||||
@@ -103,6 +104,7 @@ const LEFT_DRAWER_STORAGE_KEY = "opencode-session-sidebar-width-v8"
|
||||
const RIGHT_DRAWER_STORAGE_KEY = "opencode-session-right-drawer-width-v1"
|
||||
const LEFT_PIN_STORAGE_KEY = "opencode-session-left-drawer-pinned-v1"
|
||||
const RIGHT_PIN_STORAGE_KEY = "opencode-session-right-drawer-pinned-v1"
|
||||
const BUILD_PREVIEW_EVENT = "opencode:build-preview"
|
||||
|
||||
|
||||
|
||||
@@ -150,6 +152,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
const [terminalOpen, setTerminalOpen] = createSignal(false)
|
||||
const [rightPanelExpandedItems, setRightPanelExpandedItems] = createSignal<string[]>(["lsp", "mcp", "plan"])
|
||||
const [currentFile, setCurrentFile] = createSignal<FileNode | null>(null)
|
||||
const [centerTab, setCenterTab] = createSignal<"code" | "preview">("code")
|
||||
const [previewUrl, setPreviewUrl] = createSignal<string | null>(null)
|
||||
const [isSoloOpen, setIsSoloOpen] = createSignal(true)
|
||||
const [showAdvancedSettings, setShowAdvancedSettings] = createSignal(false)
|
||||
const [selectedBinary, setSelectedBinary] = createSignal("opencode")
|
||||
@@ -284,6 +288,25 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
onCleanup(() => window.removeEventListener("open-advanced-settings", handler))
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
if (typeof window === "undefined") return
|
||||
const handler = async (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ url?: string; instanceId?: string }>).detail
|
||||
if (!detail || detail.instanceId !== props.instance.id || !detail.url) return
|
||||
setPreviewUrl(detail.url)
|
||||
const confirmed = await showConfirmDialog(`Preview available at ${detail.url}. Open now?`, {
|
||||
title: "Preview ready",
|
||||
confirmLabel: "Open preview",
|
||||
cancelLabel: "Later",
|
||||
})
|
||||
if (confirmed) {
|
||||
setCenterTab("preview")
|
||||
}
|
||||
}
|
||||
window.addEventListener(BUILD_PREVIEW_EVENT, handler)
|
||||
onCleanup(() => window.removeEventListener(BUILD_PREVIEW_EVENT, handler))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
window.localStorage.setItem(LEFT_DRAWER_STORAGE_KEY, sessionSidebarWidth().toString())
|
||||
@@ -449,6 +472,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
|
||||
let sidebarActionId = 0
|
||||
const [pendingSidebarAction, setPendingSidebarAction] = createSignal<PendingSidebarAction | null>(null)
|
||||
const [sidebarRequestedTab, setSidebarRequestedTab] = createSignal<string | null>(null)
|
||||
|
||||
const triggerKeyboardEvent = (target: HTMLElement, options: { key: string; code: string; keyCode: number }) => {
|
||||
target.dispatchEvent(
|
||||
@@ -499,6 +523,9 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
|
||||
const handleSidebarRequest = (action: SessionSidebarRequestAction) => {
|
||||
setPendingSidebarAction({ action, id: sidebarActionId++ })
|
||||
if (action === "show-skills") {
|
||||
setSidebarRequestedTab("skills")
|
||||
}
|
||||
if (!leftPinned() && !leftOpen()) {
|
||||
setLeftOpen(true)
|
||||
measureDrawerHost()
|
||||
@@ -902,6 +929,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
onToggleTerminal={() => setTerminalOpen((current) => !current)}
|
||||
isTerminalOpen={terminalOpen()}
|
||||
onOpenAdvancedSettings={() => setShowAdvancedSettings(true)}
|
||||
requestedTab={sidebarRequestedTab()}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1243,18 +1271,18 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* SOLO Mode & Auto-Approval Toggles */}
|
||||
{/* APEX PRO Mode & Auto-Approval Toggles */}
|
||||
<div class="flex items-center bg-white/5 border border-white/5 rounded-full px-1.5 py-1 space-x-1">
|
||||
<button
|
||||
onClick={() => toggleAutonomous(props.instance.id)}
|
||||
title="Autonomous Mode (SOLO): Enable autonomous AI agent operations"
|
||||
title="Autonomous Mode (APEX PRO): Enable autonomous AI agent operations"
|
||||
class={`flex items-center space-x-1.5 px-2 py-0.5 rounded-full transition-all ${getSoloState(props.instance.id).isAutonomous
|
||||
? "bg-blue-500/20 text-blue-400 border border-blue-500/30"
|
||||
: "text-zinc-500 hover:text-zinc-300"
|
||||
}`}
|
||||
>
|
||||
<Zap size={12} class={getSoloState(props.instance.id).isAutonomous ? "animate-pulse" : ""} />
|
||||
<span class="text-[9px] font-black uppercase tracking-tighter">SOLO</span>
|
||||
<span class="text-[9px] font-black uppercase tracking-tighter">APEX PRO</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toggleAutoApproval(props.instance.id)}
|
||||
@@ -1305,7 +1333,65 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
>
|
||||
<div class="flex-1 flex overflow-hidden min-h-0">
|
||||
<Show when={!isPhoneLayout()}>
|
||||
<Editor file={currentFile()} />
|
||||
<div class="flex-1 flex flex-col min-h-0 bg-[#0d0d0d]">
|
||||
<div class="h-10 glass border-b border-white/5 flex items-center justify-between px-4 shrink-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class={`px-2.5 py-1 rounded-md text-[11px] font-semibold uppercase tracking-wide border ${
|
||||
centerTab() === "code"
|
||||
? "bg-white/10 border-white/20 text-white"
|
||||
: "border-transparent text-zinc-400 hover:text-zinc-200 hover:bg-white/5"
|
||||
}`}
|
||||
onClick={() => setCenterTab("code")}
|
||||
>
|
||||
Code
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={`px-2.5 py-1 rounded-md text-[11px] font-semibold uppercase tracking-wide border ${
|
||||
centerTab() === "preview"
|
||||
? "bg-white/10 border-white/20 text-white"
|
||||
: "border-transparent text-zinc-400 hover:text-zinc-200 hover:bg-white/5"
|
||||
}`}
|
||||
onClick={() => setCenterTab("preview")}
|
||||
disabled={!previewUrl()}
|
||||
title={previewUrl() ? previewUrl() : "Run build to enable preview"}
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
<Show when={previewUrl()}>
|
||||
{(url) => (
|
||||
<div class="text-[10px] text-zinc-500 truncate max-w-[50%]" title={url()}>
|
||||
{url()}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={centerTab() === "preview"} fallback={<Editor file={currentFile()} />}>
|
||||
<Show
|
||||
when={previewUrl()}
|
||||
fallback={
|
||||
<div class="flex-1 flex items-center justify-center text-zinc-500">
|
||||
<div class="text-center">
|
||||
<p>No preview available yet.</p>
|
||||
<p class="text-sm mt-2 opacity-60">Run build to detect a preview URL.</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(url) => (
|
||||
<iframe
|
||||
class="flex-1 w-full h-full border-none bg-black"
|
||||
src={url()}
|
||||
title="App Preview"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-pointer-lock allow-popups"
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, createSignal, For, Show, createEffect, createMemo } from "solid-js"
|
||||
import { Component, createSignal, For, Show, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import {
|
||||
Files,
|
||||
Search,
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from "lucide-solid"
|
||||
import { serverApi } from "../../lib/api-client"
|
||||
import InstanceServiceStatus from "../instance-service-status"
|
||||
import McpManager from "../mcp-manager"
|
||||
import { catalog, catalogLoading, catalogError, loadCatalog } from "../../stores/skills"
|
||||
import { getSessionSkills, setSessionSkills } from "../../stores/session-state"
|
||||
|
||||
@@ -41,6 +42,7 @@ interface SidebarProps {
|
||||
onToggleTerminal?: () => void
|
||||
isTerminalOpen?: boolean
|
||||
onOpenAdvancedSettings?: () => void
|
||||
requestedTab?: string | null
|
||||
}
|
||||
|
||||
const getFileIcon = (fileName: string) => {
|
||||
@@ -128,6 +130,7 @@ const FileTree: Component<{
|
||||
export const Sidebar: Component<SidebarProps> = (props) => {
|
||||
const [activeTab, setActiveTab] = createSignal("files")
|
||||
const [rootFiles, setRootFiles] = createSignal<FileNode[]>([])
|
||||
const [lastRequestedTab, setLastRequestedTab] = createSignal<string | null>(null)
|
||||
const [searchQuery, setSearchQuery] = createSignal("")
|
||||
const [searchResults, setSearchResults] = createSignal<FileNode[]>([])
|
||||
const [searchLoading, setSearchLoading] = createSignal(false)
|
||||
@@ -141,20 +144,40 @@ export const Sidebar: Component<SidebarProps> = (props) => {
|
||||
} | null>(null)
|
||||
const [gitLoading, setGitLoading] = createSignal(false)
|
||||
const [skillsFilter, setSkillsFilter] = createSignal("")
|
||||
const FILE_CHANGE_EVENT = "opencode:workspace-files-changed"
|
||||
|
||||
createEffect(async () => {
|
||||
if (props.instanceId) {
|
||||
try {
|
||||
const entries = await serverApi.listWorkspaceFiles(props.instanceId, ".")
|
||||
setRootFiles(entries.map(e => ({
|
||||
name: e.name,
|
||||
type: e.type,
|
||||
path: e.path
|
||||
})))
|
||||
} catch (e) {
|
||||
console.error("Failed to load root files", e)
|
||||
}
|
||||
const openExternal = (url: string) => {
|
||||
if (typeof window === "undefined") return
|
||||
window.open(url, "_blank", "noopener,noreferrer")
|
||||
}
|
||||
|
||||
const refreshRootFiles = async () => {
|
||||
if (!props.instanceId) return
|
||||
try {
|
||||
const entries = await serverApi.listWorkspaceFiles(props.instanceId, ".")
|
||||
setRootFiles(entries.map(e => ({
|
||||
name: e.name,
|
||||
type: e.type,
|
||||
path: e.path
|
||||
})))
|
||||
} catch (e) {
|
||||
console.error("Failed to load root files", e)
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
void refreshRootFiles()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
const handler = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ instanceId?: string }>).detail
|
||||
if (!detail || detail.instanceId !== props.instanceId) return
|
||||
void refreshRootFiles()
|
||||
}
|
||||
window.addEventListener(FILE_CHANGE_EVENT, handler)
|
||||
onCleanup(() => window.removeEventListener(FILE_CHANGE_EVENT, handler))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
@@ -163,6 +186,13 @@ export const Sidebar: Component<SidebarProps> = (props) => {
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const nextTab = props.requestedTab ?? null
|
||||
if (!nextTab || nextTab === lastRequestedTab()) return
|
||||
setActiveTab(nextTab)
|
||||
setLastRequestedTab(nextTab)
|
||||
})
|
||||
|
||||
const filteredSkills = createMemo(() => {
|
||||
const term = skillsFilter().trim().toLowerCase()
|
||||
if (!term) return catalog()
|
||||
@@ -410,10 +440,7 @@ export const Sidebar: Component<SidebarProps> = (props) => {
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={activeTab() === "mcp"}>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="text-xs uppercase tracking-wide text-zinc-500">MCP Servers</div>
|
||||
<InstanceServiceStatus sections={["mcp"]} />
|
||||
</div>
|
||||
<McpManager instanceId={props.instanceId} />
|
||||
</Show>
|
||||
<Show when={activeTab() === "skills"}>
|
||||
<div class="flex flex-col gap-3">
|
||||
|
||||
501
packages/ui/src/components/mcp-manager.tsx
Normal file
501
packages/ui/src/components/mcp-manager.tsx
Normal file
@@ -0,0 +1,501 @@
|
||||
import { Dialog } from "@kobalte/core/dialog"
|
||||
import { ChevronDown, ExternalLink, Plus, RefreshCw, Search, Settings } from "lucide-solid"
|
||||
import { Component, For, Show, createEffect, createMemo, createSignal } from "solid-js"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import InstanceServiceStatus from "./instance-service-status"
|
||||
import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context"
|
||||
|
||||
type McpServerConfig = {
|
||||
command?: string
|
||||
args?: string[]
|
||||
env?: Record<string, string>
|
||||
}
|
||||
|
||||
type McpConfig = {
|
||||
mcpServers?: Record<string, McpServerConfig>
|
||||
}
|
||||
|
||||
type McpMarketplaceEntry = {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
config: McpServerConfig
|
||||
tags?: string[]
|
||||
source?: string
|
||||
}
|
||||
|
||||
interface McpManagerProps {
|
||||
instanceId: string
|
||||
}
|
||||
|
||||
const log = getLogger("mcp-manager")
|
||||
|
||||
const MCP_LINKER_RELEASES = "https://github.com/milisp/mcp-linker/releases"
|
||||
const MCP_LINKER_MARKET = "https://github.com/milisp/mcp-linker"
|
||||
const MARKETPLACE_ENTRIES: McpMarketplaceEntry[] = [
|
||||
{
|
||||
id: "sequential-thinking",
|
||||
name: "Sequential Thinking",
|
||||
description: "Step-by-step reasoning scratchpad for complex tasks.",
|
||||
config: { command: "npx", args: ["-y", "@modelcontextprotocol/server-sequential-thinking"] },
|
||||
tags: ["reasoning", "planning"],
|
||||
source: "curated",
|
||||
},
|
||||
{
|
||||
id: "desktop-commander",
|
||||
name: "Desktop Commander",
|
||||
description: "Control local desktop actions and automation.",
|
||||
config: { command: "npx", args: ["-y", "@modelcontextprotocol/server-desktop-commander"] },
|
||||
tags: ["automation", "local"],
|
||||
source: "curated",
|
||||
},
|
||||
{
|
||||
id: "web-reader",
|
||||
name: "Web Reader",
|
||||
description: "Fetch and summarize web pages with structured metadata.",
|
||||
config: { command: "npx", args: ["-y", "@modelcontextprotocol/server-web-reader"] },
|
||||
tags: ["web", "search"],
|
||||
source: "curated",
|
||||
},
|
||||
{
|
||||
id: "github",
|
||||
name: "GitHub",
|
||||
description: "Query GitHub repos, issues, and pull requests.",
|
||||
config: { command: "npx", args: ["-y", "@modelcontextprotocol/server-github"] },
|
||||
tags: ["git", "productivity"],
|
||||
source: "curated",
|
||||
},
|
||||
{
|
||||
id: "postgres",
|
||||
name: "PostgreSQL",
|
||||
description: "Inspect PostgreSQL schemas and run safe queries.",
|
||||
config: { command: "npx", args: ["-y", "@modelcontextprotocol/server-postgres"] },
|
||||
tags: ["database"],
|
||||
source: "curated",
|
||||
},
|
||||
]
|
||||
|
||||
const McpManager: Component<McpManagerProps> = (props) => {
|
||||
const [config, setConfig] = createSignal<McpConfig>({ mcpServers: {} })
|
||||
const [isLoading, setIsLoading] = createSignal(false)
|
||||
const [error, setError] = createSignal<string | null>(null)
|
||||
const [menuOpen, setMenuOpen] = createSignal(false)
|
||||
const [showManual, setShowManual] = createSignal(false)
|
||||
const [showMarketplace, setShowMarketplace] = createSignal(false)
|
||||
const [marketplaceQuery, setMarketplaceQuery] = createSignal("")
|
||||
const [marketplaceLoading, setMarketplaceLoading] = createSignal(false)
|
||||
const [marketplaceEntries, setMarketplaceEntries] = createSignal<McpMarketplaceEntry[]>([])
|
||||
const [rawMode, setRawMode] = createSignal(false)
|
||||
const [serverName, setServerName] = createSignal("")
|
||||
const [serverJson, setServerJson] = createSignal("")
|
||||
const [saving, setSaving] = createSignal(false)
|
||||
|
||||
const metadataContext = useOptionalInstanceMetadataContext()
|
||||
const metadata = createMemo(() => metadataContext?.metadata?.() ?? null)
|
||||
const mcpStatus = createMemo(() => metadata()?.mcpStatus ?? {})
|
||||
|
||||
const servers = createMemo(() => Object.entries(config().mcpServers ?? {}))
|
||||
const filteredMarketplace = createMemo(() => {
|
||||
const combined = [...MARKETPLACE_ENTRIES, ...marketplaceEntries()]
|
||||
const query = marketplaceQuery().trim().toLowerCase()
|
||||
if (!query) return combined
|
||||
return combined.filter((entry) => {
|
||||
const haystack = `${entry.name} ${entry.description} ${entry.id} ${(entry.tags || []).join(" ")}`.toLowerCase()
|
||||
return haystack.includes(query)
|
||||
})
|
||||
})
|
||||
|
||||
const loadConfig = async () => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await serverApi.fetchWorkspaceMcpConfig(props.instanceId)
|
||||
setConfig(data.config ?? { mcpServers: {} })
|
||||
} catch (err) {
|
||||
log.error("Failed to load MCP config", err)
|
||||
setError("Failed to load MCP configuration.")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
void loadConfig()
|
||||
})
|
||||
|
||||
const openExternal = (url: string) => {
|
||||
window.open(url, "_blank", "noopener")
|
||||
}
|
||||
|
||||
const resetManualForm = () => {
|
||||
setServerName("")
|
||||
setServerJson("")
|
||||
setRawMode(false)
|
||||
}
|
||||
|
||||
const handleManualSave = async () => {
|
||||
if (saving()) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const parsed = JSON.parse(serverJson() || "{}")
|
||||
const nextConfig: McpConfig = { ...(config() ?? {}) }
|
||||
const mcpServers = { ...(nextConfig.mcpServers ?? {}) }
|
||||
|
||||
if (rawMode()) {
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
throw new Error("Raw config must be a JSON object.")
|
||||
}
|
||||
setConfig(parsed as McpConfig)
|
||||
await serverApi.updateWorkspaceMcpConfig(props.instanceId, parsed)
|
||||
} else {
|
||||
const name = serverName().trim()
|
||||
if (!name) {
|
||||
throw new Error("Server name is required.")
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
throw new Error("Server config must be a JSON object.")
|
||||
}
|
||||
mcpServers[name] = parsed as McpServerConfig
|
||||
nextConfig.mcpServers = mcpServers
|
||||
setConfig(nextConfig)
|
||||
await serverApi.updateWorkspaceMcpConfig(props.instanceId, nextConfig)
|
||||
}
|
||||
|
||||
resetManualForm()
|
||||
setShowManual(false)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Invalid MCP configuration."
|
||||
setError(message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMarketplaceInstall = async (entry: McpMarketplaceEntry) => {
|
||||
if (saving()) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const nextConfig: McpConfig = { ...(config() ?? {}) }
|
||||
const mcpServers = { ...(nextConfig.mcpServers ?? {}) }
|
||||
mcpServers[entry.id] = entry.config
|
||||
nextConfig.mcpServers = mcpServers
|
||||
setConfig(nextConfig)
|
||||
await serverApi.updateWorkspaceMcpConfig(props.instanceId, nextConfig)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to install MCP server."
|
||||
setError(message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchNpmEntries = async (query: string, sourceLabel: string): Promise<McpMarketplaceEntry[]> => {
|
||||
const url = `https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(query)}&size=50`
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${sourceLabel} MCP entries`)
|
||||
}
|
||||
const data = await response.json() as {
|
||||
objects?: Array<{ package?: { name?: string; description?: string; keywords?: string[] } }>
|
||||
}
|
||||
const objects = Array.isArray(data.objects) ? data.objects : []
|
||||
return objects
|
||||
.map((entry) => entry.package)
|
||||
.filter((pkg): pkg is { name: string; description?: string; keywords?: string[] } => Boolean(pkg?.name))
|
||||
.map((pkg) => ({
|
||||
id: pkg.name,
|
||||
name: pkg.name.replace(/^@modelcontextprotocol\/server-/, ""),
|
||||
description: pkg.description || "Community MCP server package",
|
||||
config: { command: "npx", args: ["-y", pkg.name] },
|
||||
tags: pkg.keywords,
|
||||
source: sourceLabel,
|
||||
}))
|
||||
}
|
||||
|
||||
const loadMarketplace = async () => {
|
||||
if (marketplaceLoading()) return
|
||||
setMarketplaceLoading(true)
|
||||
try {
|
||||
const [official, community] = await Promise.allSettled([
|
||||
fetchNpmEntries("@modelcontextprotocol/server", "npm:official"),
|
||||
fetchNpmEntries("mcp server", "npm:community"),
|
||||
])
|
||||
|
||||
const next: McpMarketplaceEntry[] = []
|
||||
if (official.status === "fulfilled") next.push(...official.value)
|
||||
if (community.status === "fulfilled") next.push(...community.value)
|
||||
|
||||
const deduped = new Map<string, McpMarketplaceEntry>()
|
||||
for (const entry of next) {
|
||||
if (!deduped.has(entry.id)) deduped.set(entry.id, entry)
|
||||
}
|
||||
setMarketplaceEntries(Array.from(deduped.values()))
|
||||
} catch (err) {
|
||||
log.error("Failed to load marketplace", err)
|
||||
setError("Failed to load marketplace sources.")
|
||||
} finally {
|
||||
setMarketplaceLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="mcp-manager">
|
||||
<div class="mcp-manager-header">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs uppercase tracking-wide text-zinc-500">MCP Servers</span>
|
||||
<button
|
||||
onClick={loadConfig}
|
||||
class="mcp-icon-button"
|
||||
title="Refresh MCP servers"
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<div class="mcp-manager-actions">
|
||||
<div class="relative">
|
||||
<button
|
||||
onClick={() => setMenuOpen((prev) => !prev)}
|
||||
class="mcp-action-button"
|
||||
title="Add MCP"
|
||||
>
|
||||
<Plus size={12} />
|
||||
<span>Add</span>
|
||||
<ChevronDown size={12} />
|
||||
</button>
|
||||
<Show when={menuOpen()}>
|
||||
<div class="mcp-menu">
|
||||
<button
|
||||
class="mcp-menu-item"
|
||||
onClick={() => {
|
||||
setMenuOpen(false)
|
||||
void loadMarketplace()
|
||||
setShowMarketplace(true)
|
||||
}}
|
||||
>
|
||||
Add from Marketplace
|
||||
<ExternalLink size={12} />
|
||||
</button>
|
||||
<button
|
||||
class="mcp-menu-item"
|
||||
onClick={() => {
|
||||
setMenuOpen(false)
|
||||
resetManualForm()
|
||||
setShowManual(true)
|
||||
}}
|
||||
>
|
||||
Add Manually
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => openExternal(MCP_LINKER_RELEASES)}
|
||||
class="mcp-link-button"
|
||||
title="Install MCP Linker"
|
||||
>
|
||||
MCP Market
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={error()}>
|
||||
{(err) => <div class="text-[11px] text-amber-400">{err()}</div>}
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={!isLoading() && servers().length > 0}
|
||||
fallback={<div class="text-[11px] text-zinc-500 italic">{isLoading() ? "Loading MCP servers..." : "No MCP servers configured."}</div>}
|
||||
>
|
||||
<div class="mcp-server-list">
|
||||
<For each={servers()}>
|
||||
{([name, server]) => (
|
||||
<div class="mcp-server-card">
|
||||
<div class="mcp-server-row">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-xs font-semibold text-zinc-100">{name}</span>
|
||||
<span class="text-[11px] text-zinc-500 truncate">
|
||||
{server.command ? `${server.command} ${(server.args ?? []).join(" ")}` : "Custom config"}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Show when={mcpStatus()?.[name]?.status}>
|
||||
<span class="mcp-status-chip">
|
||||
{mcpStatus()?.[name]?.status}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={mcpStatus()?.[name]?.error}>
|
||||
<span class="mcp-status-error" title={String(mcpStatus()?.[name]?.error)}>
|
||||
error
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="mt-3">
|
||||
<InstanceServiceStatus sections={["mcp"]} />
|
||||
</div>
|
||||
|
||||
<Dialog open={showManual()} onOpenChange={setShowManual} modal>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="modal-overlay" />
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<Dialog.Content class="modal-surface w-full max-w-2xl p-5 flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<Dialog.Title class="text-sm font-semibold text-white">Configure MCP Server</Dialog.Title>
|
||||
<Dialog.Description class="text-xs text-zinc-500">
|
||||
Paste the MCP server config JSON. Use marketplace via MCP Linker for curated servers.
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
<button
|
||||
class="text-xs px-2 py-1 rounded border border-white/10 text-zinc-400 hover:text-white"
|
||||
onClick={() => setRawMode((prev) => !prev)}
|
||||
>
|
||||
{rawMode() ? "Server Mode" : "Raw Config (JSON)"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={!rawMode()}>
|
||||
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||
Server Name
|
||||
<input
|
||||
value={serverName()}
|
||||
onInput={(e) => setServerName(e.currentTarget.value)}
|
||||
class="rounded-md bg-white/5 border border-white/10 px-3 py-2 text-xs text-zinc-200 focus:outline-none focus:border-blue-500/60"
|
||||
placeholder="example-server"
|
||||
/>
|
||||
</label>
|
||||
</Show>
|
||||
|
||||
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||
Config JSON
|
||||
<textarea
|
||||
value={serverJson()}
|
||||
onInput={(e) => setServerJson(e.currentTarget.value)}
|
||||
class="min-h-[200px] rounded-md bg-white/5 border border-white/10 px-3 py-2 text-xs text-zinc-200 font-mono focus:outline-none focus:border-blue-500/60"
|
||||
placeholder='{"command":"npx","args":["-y","mcp-server-example"]}'
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
resetManualForm()
|
||||
setShowManual(false)
|
||||
}}
|
||||
class="px-3 py-1.5 text-xs rounded-md border border-white/10 text-zinc-300 hover:text-white"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleManualSave}
|
||||
disabled={saving()}
|
||||
class="px-3 py-1.5 text-xs rounded-md bg-blue-500/20 border border-blue-500/40 text-blue-200 hover:text-white disabled:opacity-60"
|
||||
>
|
||||
{saving() ? "Saving..." : "Confirm"}
|
||||
</button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={showMarketplace()} onOpenChange={setShowMarketplace} modal>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="modal-overlay" />
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<Dialog.Content class="modal-surface w-full max-w-3xl p-5 flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<Dialog.Title class="text-sm font-semibold text-white">MCP Marketplace</Dialog.Title>
|
||||
<Dialog.Description class="text-xs text-zinc-500">
|
||||
Curated entries inspired by mcp-linker. Install writes to this workspace's .mcp.json.
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
<button
|
||||
class="mcp-link-button"
|
||||
onClick={() => openExternal(MCP_LINKER_MARKET)}
|
||||
>
|
||||
Open MCP Linker
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mcp-market-search">
|
||||
<Search size={14} class="text-zinc-500" />
|
||||
<input
|
||||
value={marketplaceQuery()}
|
||||
onInput={(e) => setMarketplaceQuery(e.currentTarget.value)}
|
||||
placeholder="Search MCP servers..."
|
||||
class="mcp-market-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mcp-market-list">
|
||||
<Show
|
||||
when={!marketplaceLoading()}
|
||||
fallback={<div class="text-[11px] text-zinc-500 italic">Loading marketplace sources...</div>}
|
||||
>
|
||||
<For each={filteredMarketplace()}>
|
||||
{(entry) => (
|
||||
<div class="mcp-market-card">
|
||||
<div class="mcp-market-card-info">
|
||||
<div class="mcp-market-card-title">
|
||||
{entry.name}
|
||||
<Show when={entry.source}>
|
||||
{(source) => <span class="mcp-market-source">{source()}</span>}
|
||||
</Show>
|
||||
</div>
|
||||
<div class="mcp-market-card-desc">{entry.description}</div>
|
||||
<Show when={entry.tags && entry.tags.length > 0}>
|
||||
<div class="mcp-market-tags">
|
||||
<For each={entry.tags}>
|
||||
{(tag) => <span class="mcp-market-tag">{tag}</span>}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="mcp-market-card-actions">
|
||||
<button
|
||||
class="mcp-icon-button"
|
||||
title="View config"
|
||||
onClick={() => {
|
||||
setShowManual(true)
|
||||
setRawMode(false)
|
||||
setServerName(entry.id)
|
||||
setServerJson(JSON.stringify(entry.config, null, 2))
|
||||
setShowMarketplace(false)
|
||||
}}
|
||||
>
|
||||
<Settings size={14} />
|
||||
</button>
|
||||
<button
|
||||
class="mcp-market-install"
|
||||
onClick={() => handleMarketplaceInstall(entry)}
|
||||
disabled={saving()}
|
||||
>
|
||||
<Plus size={12} />
|
||||
Install
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default McpManager
|
||||
@@ -2,6 +2,8 @@ import { For, Show, createSignal } from "solid-js"
|
||||
import type { MessageInfo, ClientPart } from "../types/message"
|
||||
import { partHasRenderableText } from "../types/message"
|
||||
import type { MessageRecord } from "../stores/message-v2/types"
|
||||
import { compactSession } from "../stores/session-actions"
|
||||
import { clearCompactionSuggestion } from "../stores/session-compaction"
|
||||
import MessagePart from "./message-part"
|
||||
|
||||
interface MessageItemProps {
|
||||
@@ -125,6 +127,27 @@ interface MessageItemProps {
|
||||
return null
|
||||
}
|
||||
|
||||
const isContextError = () => {
|
||||
const info = props.messageInfo
|
||||
if (!info) return false
|
||||
const errorMessage = (info as any).error?.data?.message || (info as any).error?.message || ""
|
||||
return (
|
||||
errorMessage.includes("maximum context length") ||
|
||||
errorMessage.includes("context_length_exceeded") ||
|
||||
errorMessage.includes("token count exceeds") ||
|
||||
errorMessage.includes("token limit")
|
||||
)
|
||||
}
|
||||
|
||||
const handleCompact = async () => {
|
||||
try {
|
||||
clearCompactionSuggestion(props.instanceId, props.sessionId)
|
||||
await compactSession(props.instanceId, props.sessionId)
|
||||
} catch (error) {
|
||||
console.error("Failed to compact session:", error)
|
||||
}
|
||||
}
|
||||
|
||||
const hasContent = () => {
|
||||
if (errorMessage() !== null) {
|
||||
return true
|
||||
@@ -138,6 +161,19 @@ interface MessageItemProps {
|
||||
return !hasContent() && info && info.role === "assistant" && info.time.completed !== undefined && info.time.completed === 0
|
||||
}
|
||||
|
||||
const isStreaming = () => {
|
||||
return props.record.status === "streaming"
|
||||
}
|
||||
|
||||
const currentTokenCount = () => {
|
||||
if (!isStreaming()) return null
|
||||
const textParts = props.parts.filter(p => p.type === "text")
|
||||
return textParts.reduce((sum, p) => {
|
||||
const text = (p as { text?: string }).text || ""
|
||||
return sum + text.length
|
||||
}, 0)
|
||||
}
|
||||
|
||||
const handleRevert = () => {
|
||||
if (props.onRevert && isUser()) {
|
||||
props.onRevert(props.record.id)
|
||||
@@ -185,7 +221,7 @@ interface MessageItemProps {
|
||||
const modelID = info.modelID || ""
|
||||
const providerID = info.providerID || ""
|
||||
if (modelID && providerID) return `${providerID}/${modelID}`
|
||||
return modelID
|
||||
return modelID || "unknown"
|
||||
}
|
||||
|
||||
const agentMeta = () => {
|
||||
@@ -202,6 +238,20 @@ interface MessageItemProps {
|
||||
return segments.join(" • ")
|
||||
}
|
||||
|
||||
const modelBadge = () => {
|
||||
if (isUser()) return null
|
||||
const model = modelIdentifier()
|
||||
if (!model) return null
|
||||
return (
|
||||
<span class="message-model-badge" title={`Model: ${model}`}>
|
||||
<svg class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span class="text-xs font-medium text-zinc-400">{model}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div class={containerClass()}>
|
||||
@@ -259,6 +309,11 @@ interface MessageItemProps {
|
||||
</Show>
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={modelBadge()}>
|
||||
{(badge) => (
|
||||
<span class="ml-2">{badge()}</span>
|
||||
)}
|
||||
</Show>
|
||||
<time class="message-timestamp" dateTime={timestampIso()}>{timestamp()}</time>
|
||||
</div>
|
||||
|
||||
@@ -266,13 +321,45 @@ interface MessageItemProps {
|
||||
|
||||
<div class="pt-1 whitespace-pre-wrap break-words leading-[1.1]">
|
||||
|
||||
<Show when={isStreaming()}>
|
||||
<div class="message-streaming-indicator">
|
||||
<span class="streaming-status">
|
||||
<span class="streaming-pulse"></span>
|
||||
<span class="streaming-text">Thinking</span>
|
||||
</span>
|
||||
<Show when={currentTokenCount() !== null}>
|
||||
{(count) => (
|
||||
<span class="streaming-tokens">
|
||||
<span class="streaming-token-count">{count()}</span>
|
||||
<span class="streaming-token-label">tokens</span>
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.isQueued && isUser()}>
|
||||
<div class="message-queued-badge">QUEUED</div>
|
||||
</Show>
|
||||
|
||||
<Show when={errorMessage()}>
|
||||
<div class="message-error-block">⚠️ {errorMessage()}</div>
|
||||
<div class="message-error-block">
|
||||
<div class="flex items-start gap-2">
|
||||
<span>⚠️ {errorMessage()}</span>
|
||||
<Show when={isContextError()}>
|
||||
<button
|
||||
onClick={handleCompact}
|
||||
class="compact-button"
|
||||
title="Compact session to reduce context usage"
|
||||
>
|
||||
<svg class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v16l6-6-6 6M4 20l6-6 6-6" />
|
||||
</svg>
|
||||
Compact
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={isGenerating()}>
|
||||
|
||||
@@ -7,6 +7,9 @@ import { getSessionInfo } from "../stores/sessions"
|
||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||
import { useScrollCache } from "../lib/hooks/use-scroll-cache"
|
||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||
import { getSessionStatus } from "../stores/session-status"
|
||||
import { compactSession } from "../stores/session-actions"
|
||||
import { clearCompactionSuggestion, getCompactionSuggestion } from "../stores/session-compaction"
|
||||
|
||||
const SCROLL_SCOPE = "session"
|
||||
const SCROLL_SENTINEL_MARGIN_PX = 48
|
||||
@@ -51,6 +54,10 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
contextAvailableTokens: null,
|
||||
},
|
||||
)
|
||||
const isCompacting = createMemo(() => getSessionStatus(props.instanceId, props.sessionId) === "compacting")
|
||||
const compactionSuggestion = createMemo(() =>
|
||||
getCompactionSuggestion(props.instanceId, props.sessionId),
|
||||
)
|
||||
|
||||
const tokenStats = createMemo(() => {
|
||||
const usage = usageSnapshot()
|
||||
@@ -747,6 +754,30 @@ export default function MessageSection(props: MessageSectionProps) {
|
||||
<div class="message-stream-container">
|
||||
<div class={`message-layout${hasTimelineSegments() ? " message-layout--with-timeline" : ""}`}>
|
||||
<div class="message-stream-shell" ref={setShellElement}>
|
||||
<Show when={isCompacting()}>
|
||||
<div class="compaction-banner" role="status" aria-live="polite">
|
||||
<span class="spinner compaction-banner-spinner" aria-hidden="true" />
|
||||
<span>Compacting context…</span>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!isCompacting() && compactionSuggestion()}>
|
||||
<div class="compaction-suggestion" role="status" aria-live="polite">
|
||||
<div class="compaction-suggestion-text">
|
||||
<span class="compaction-suggestion-label">Compact suggested</span>
|
||||
<span class="compaction-suggestion-message">{compactionSuggestion()!.reason}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="compaction-suggestion-action"
|
||||
onClick={() => {
|
||||
clearCompactionSuggestion(props.instanceId, props.sessionId)
|
||||
void compactSession(props.instanceId, props.sessionId)
|
||||
}}
|
||||
>
|
||||
Compact now
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="message-stream" ref={setContainerRef} onScroll={handleScroll} onMouseUp={handleStreamMouseUp}>
|
||||
<div ref={setTopSentinel} aria-hidden="true" style={{ height: "1px" }} />
|
||||
<Show when={!props.loading && messageIds().length === 0}>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { providers, fetchProviders } from "../stores/sessions"
|
||||
import { ChevronDown } from "lucide-solid"
|
||||
import type { Model } from "../types/session"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { getUserScopedKey } from "../lib/user-storage"
|
||||
const log = getLogger("session")
|
||||
|
||||
const OPENCODE_ZEN_OFFLINE_STORAGE_KEY = "opencode-zen-offline-models"
|
||||
@@ -40,7 +41,7 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
||||
const readOfflineModels = () => {
|
||||
if (typeof window === "undefined") return new Set<string>()
|
||||
try {
|
||||
const raw = window.localStorage.getItem(OPENCODE_ZEN_OFFLINE_STORAGE_KEY)
|
||||
const raw = window.localStorage.getItem(getUserScopedKey(OPENCODE_ZEN_OFFLINE_STORAGE_KEY))
|
||||
const parsed = raw ? JSON.parse(raw) : []
|
||||
return new Set(Array.isArray(parsed) ? parsed.filter((id) => typeof id === "string") : [])
|
||||
} catch {
|
||||
@@ -57,7 +58,7 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
||||
if (typeof window === "undefined") return
|
||||
const handleCustom = () => refreshOfflineModels()
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
if (event.key === OPENCODE_ZEN_OFFLINE_STORAGE_KEY) {
|
||||
if (event.key === getUserScopedKey(OPENCODE_ZEN_OFFLINE_STORAGE_KEY)) {
|
||||
refreshOfflineModels()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1169,6 +1169,12 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
</div>
|
||||
|
||||
<div class="prompt-input-actions">
|
||||
<Show when={props.isSessionBusy}>
|
||||
<div class="thinking-indicator" aria-live="polite">
|
||||
<span class="thinking-spinner" aria-hidden="true" />
|
||||
<span>Thinking…</span>
|
||||
</div>
|
||||
</Show>
|
||||
<button
|
||||
type="button"
|
||||
class="stop-button"
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Component, createSignal, onMount, Show } from 'solid-js'
|
||||
import toast from 'solid-toast'
|
||||
import { Button } from '@suid/material'
|
||||
import { Cloud, CheckCircle, XCircle, Loader } from 'lucide-solid'
|
||||
import { instances } from '../../stores/instances'
|
||||
import { fetchProviders } from '../../stores/session-api'
|
||||
|
||||
interface OllamaCloudConfig {
|
||||
enabled: boolean
|
||||
@@ -12,9 +14,11 @@ interface OllamaCloudConfig {
|
||||
interface OllamaCloudModelsResponse {
|
||||
models: Array<{
|
||||
name: string
|
||||
size: string
|
||||
digest: string
|
||||
modified_at: string
|
||||
model?: string
|
||||
size?: string | number
|
||||
digest?: string
|
||||
modified_at?: string
|
||||
details?: any
|
||||
}>
|
||||
}
|
||||
|
||||
@@ -25,14 +29,20 @@ const OllamaCloudSettings: Component = () => {
|
||||
const [connectionStatus, setConnectionStatus] = createSignal<'idle' | 'testing' | 'connected' | 'failed'>('idle')
|
||||
const [models, setModels] = createSignal<string[]>([])
|
||||
const [isLoadingModels, setIsLoadingModels] = createSignal(false)
|
||||
const [hasStoredApiKey, setHasStoredApiKey] = createSignal(false)
|
||||
|
||||
// Load config on mount
|
||||
onMount(async () => {
|
||||
try {
|
||||
const response = await fetch('http://localhost:6149/api/ollama/config')
|
||||
const response = await fetch('/api/ollama/config')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setConfig(data.config)
|
||||
const maskedKey = typeof data.config?.apiKey === "string" && /^\*+$/.test(data.config.apiKey)
|
||||
setHasStoredApiKey(Boolean(data.config?.apiKey) && maskedKey)
|
||||
setConfig({
|
||||
...data.config,
|
||||
apiKey: maskedKey ? "" : data.config?.apiKey,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load Ollama config:', error)
|
||||
@@ -47,10 +57,15 @@ const OllamaCloudSettings: Component = () => {
|
||||
const saveConfig = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const response = await fetch('http://localhost:6149/api/ollama/config', {
|
||||
const payload: OllamaCloudConfig = { ...config() }
|
||||
if (!payload.apiKey && hasStoredApiKey()) {
|
||||
delete payload.apiKey
|
||||
}
|
||||
|
||||
const response = await fetch('/api/ollama/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config())
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
@@ -58,6 +73,16 @@ const OllamaCloudSettings: Component = () => {
|
||||
duration: 3000,
|
||||
icon: <CheckCircle class="w-4 h-4 text-green-500" />
|
||||
})
|
||||
|
||||
// Refresh providers for all instances so models appear in selector
|
||||
const instanceList = Array.from(instances().values())
|
||||
for (const instance of instanceList) {
|
||||
try {
|
||||
await fetchProviders(instance.id)
|
||||
} catch (error) {
|
||||
console.error(`Failed to refresh providers for instance ${instance.id}:`, error)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new Error('Failed to save config')
|
||||
}
|
||||
@@ -74,22 +99,22 @@ const OllamaCloudSettings: Component = () => {
|
||||
const testConnection = async () => {
|
||||
setIsTesting(true)
|
||||
setConnectionStatus('testing')
|
||||
|
||||
|
||||
try {
|
||||
const response = await fetch('http://localhost:6149/api/ollama/test', {
|
||||
const response = await fetch('/api/ollama/test', {
|
||||
method: 'POST'
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setConnectionStatus(data.connected ? 'connected' : 'failed')
|
||||
|
||||
|
||||
if (data.connected) {
|
||||
toast.success('Successfully connected to Ollama Cloud', {
|
||||
duration: 3000,
|
||||
icon: <CheckCircle class="w-4 h-4 text-green-500" />
|
||||
})
|
||||
|
||||
|
||||
// Load models after successful connection
|
||||
loadModels()
|
||||
} else {
|
||||
@@ -115,13 +140,32 @@ const OllamaCloudSettings: Component = () => {
|
||||
const loadModels = async () => {
|
||||
setIsLoadingModels(true)
|
||||
try {
|
||||
const response = await fetch('http://localhost:6149/api/ollama/models/cloud')
|
||||
const response = await fetch('/api/ollama/models')
|
||||
if (response.ok) {
|
||||
const data: OllamaCloudModelsResponse = await response.json()
|
||||
setModels(data.models.map(model => model.name))
|
||||
const data = await response.json()
|
||||
// Handle different response formats
|
||||
if (data.models && Array.isArray(data.models)) {
|
||||
setModels(data.models.map((model: any) => model.name || model.model || 'unknown'))
|
||||
if (data.models.length > 0) {
|
||||
toast.success(`Loaded ${data.models.length} models`, { duration: 2000 })
|
||||
}
|
||||
} else {
|
||||
console.warn('Unexpected models response format:', data)
|
||||
setModels([])
|
||||
}
|
||||
} else {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
toast.error(`Failed to load models: ${errorData.error || response.statusText}`, {
|
||||
duration: 5000,
|
||||
icon: <XCircle class="w-4 h-4 text-red-500" />
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load models:', error)
|
||||
toast.error('Failed to load models - network error', {
|
||||
duration: 5000,
|
||||
icon: <XCircle class="w-4 h-4 text-red-500" />
|
||||
})
|
||||
} finally {
|
||||
setIsLoadingModels(false)
|
||||
}
|
||||
@@ -164,12 +208,13 @@ const OllamaCloudSettings: Component = () => {
|
||||
<label class="block font-medium mb-2">API Key</label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Enter your Ollama Cloud API key"
|
||||
placeholder={hasStoredApiKey() ? "API key stored (leave empty to keep)" : "Enter your Ollama Cloud API key"}
|
||||
value={config().apiKey || ''}
|
||||
onChange={(e) => handleConfigChange('apiKey', e.target.value)}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
disabled={!config().enabled}
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">Get your API key from <a href="https://ollama.com/settings/keys" target="_blank" class="text-blue-500 underline">ollama.com/settings/keys</a></p>
|
||||
</div>
|
||||
|
||||
{/* Endpoint */}
|
||||
@@ -183,6 +228,7 @@ const OllamaCloudSettings: Component = () => {
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
disabled={!config().enabled}
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">Default: https://ollama.com (for local Ollama use: http://localhost:11434)</p>
|
||||
</div>
|
||||
|
||||
{/* Test Connection */}
|
||||
@@ -196,7 +242,7 @@ const OllamaCloudSettings: Component = () => {
|
||||
{getStatusIcon()}
|
||||
{isTesting() ? 'Testing...' : 'Test Connection'}
|
||||
</Button>
|
||||
|
||||
|
||||
<Show when={connectionStatus() === 'connected'}>
|
||||
<span class="text-green-600 text-sm">Connected successfully</span>
|
||||
</Show>
|
||||
@@ -208,8 +254,8 @@ const OllamaCloudSettings: Component = () => {
|
||||
{/* Available Models */}
|
||||
<Show when={models().length > 0}>
|
||||
<div>
|
||||
<label class="block font-medium mb-2">Available Cloud Models</label>
|
||||
<div class="grid grid-cols-1 gap-2">
|
||||
<label class="block font-medium mb-2">Available Models</label>
|
||||
<div class="grid grid-cols-1 gap-2 max-h-48 overflow-y-auto">
|
||||
{models().map(model => (
|
||||
<div class="p-3 border border-gray-200 rounded-md bg-gray-50">
|
||||
<code class="text-sm font-mono">{model}</code>
|
||||
@@ -236,4 +282,4 @@ const OllamaCloudSettings: Component = () => {
|
||||
)
|
||||
}
|
||||
|
||||
export default OllamaCloudSettings
|
||||
export default OllamaCloudSettings
|
||||
|
||||
@@ -3,6 +3,8 @@ import toast from 'solid-toast'
|
||||
import { Button } from '@suid/material'
|
||||
import { User, CheckCircle, XCircle, Loader, LogOut, ExternalLink } from 'lucide-solid'
|
||||
import { useQwenOAuth } from '../../lib/integrations/qwen-oauth'
|
||||
import { instances } from '../../stores/instances'
|
||||
import { fetchProviders } from '../../stores/session-api'
|
||||
|
||||
interface QwenUser {
|
||||
id: string
|
||||
@@ -17,7 +19,7 @@ interface QwenUser {
|
||||
}
|
||||
|
||||
const QwenCodeSettings: Component = () => {
|
||||
const { isAuthenticated, user, isLoading, signIn, signOut, createApiClient } = useQwenOAuth()
|
||||
const { isAuthenticated, user, isLoading, signIn, signOut, tokenInfo } = useQwenOAuth()
|
||||
const [isSigningOut, setIsSigningOut] = createSignal(false)
|
||||
|
||||
const handleSignIn = async () => {
|
||||
@@ -27,6 +29,13 @@ const QwenCodeSettings: Component = () => {
|
||||
duration: 3000,
|
||||
icon: <CheckCircle class="w-4 h-4 text-green-500" />
|
||||
})
|
||||
for (const instance of instances().values()) {
|
||||
try {
|
||||
await fetchProviders(instance.id)
|
||||
} catch (error) {
|
||||
console.error(`Failed to refresh providers for instance ${instance.id}:`, error)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to authenticate with Qwen Code', {
|
||||
duration: 5000,
|
||||
@@ -59,6 +68,32 @@ const QwenCodeSettings: Component = () => {
|
||||
return `${user.limits.requests_per_day} requests/day, ${user.limits.requests_per_minute}/min`
|
||||
}
|
||||
|
||||
const formatTokenExpiry = () => {
|
||||
const token = tokenInfo()
|
||||
if (!token) return "Token not available"
|
||||
const createdAt = token.created_at > 1e12 ? Math.floor(token.created_at / 1000) : token.created_at
|
||||
const expiresAt = (createdAt + token.expires_in) * 1000
|
||||
const remainingMs = Math.max(0, expiresAt - Date.now())
|
||||
const remainingMin = Math.floor(remainingMs / 60000)
|
||||
return `${remainingMin} min remaining`
|
||||
}
|
||||
|
||||
const tokenStatus = () => {
|
||||
const token = tokenInfo()
|
||||
if (!token) return "Unknown"
|
||||
const createdAt = token.created_at > 1e12 ? Math.floor(token.created_at / 1000) : token.created_at
|
||||
const expiresAt = (createdAt + token.expires_in) * 1000
|
||||
return Date.now() < expiresAt ? "Active" : "Expired"
|
||||
}
|
||||
|
||||
const tokenId = () => {
|
||||
const token = tokenInfo()
|
||||
if (!token?.access_token) return "Unavailable"
|
||||
const value = token.access_token
|
||||
if (value.length <= 12) return value
|
||||
return `${value.slice(0, 6)}...${value.slice(-4)}`
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="space-y-6 p-6">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
@@ -128,6 +163,16 @@ const QwenCodeSettings: Component = () => {
|
||||
{formatRemainingRequests(user()!)}
|
||||
</span>
|
||||
</Show>
|
||||
<span class="text-xs text-green-600 dark:text-green-400">
|
||||
{formatTokenExpiry()}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-2 text-xs text-green-700 dark:text-green-300">
|
||||
<span class="font-semibold">Token ID:</span>
|
||||
<span class="font-mono">{tokenId()}</span>
|
||||
<span class="px-2 py-0.5 rounded-full bg-green-200 dark:bg-green-800 text-green-800 dark:text-green-200">
|
||||
{tokenStatus()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -188,4 +233,4 @@ const QwenCodeSettings: Component = () => {
|
||||
)
|
||||
}
|
||||
|
||||
export default QwenCodeSettings
|
||||
export default QwenCodeSettings
|
||||
|
||||
@@ -137,7 +137,7 @@ const ZAISettings: Component = () => {
|
||||
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 mb-4">
|
||||
<h3 class="font-semibold text-blue-900 dark:text-blue-100 mb-2">GLM Coding Plan</h3>
|
||||
<p class="text-sm text-blue-800 dark:text-blue-200">
|
||||
Z.AI provides access to Claude models through their GLM Coding Plan. Get your API key from the{' '}
|
||||
Z.AI provides access to GLM-4.7, GLM-4.6, GLM-4.5, and other GLM models through their PaaS/v4 API. Get your API key from the{' '}
|
||||
<a
|
||||
href="https://z.ai/manage-apikey/apikey-list"
|
||||
target="_blank"
|
||||
@@ -182,12 +182,11 @@ const ZAISettings: Component = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Endpoint */}
|
||||
<div>
|
||||
<label class="block font-medium mb-2">Endpoint</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="https://api.z.ai/api/anthropic"
|
||||
placeholder="https://api.z.ai/api/paas/v4"
|
||||
value={config().endpoint || ''}
|
||||
onChange={(e) => handleConfigChange('endpoint', e.target.value)}
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-800"
|
||||
|
||||
@@ -29,6 +29,7 @@ const TOOL_CALL_CACHE_SCOPE = "tool-call"
|
||||
const TOOL_SCROLL_SENTINEL_MARGIN_PX = 48
|
||||
const TOOL_SCROLL_INTENT_WINDOW_MS = 600
|
||||
const TOOL_SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"])
|
||||
const FILE_CHANGE_EVENT = "opencode:workspace-files-changed"
|
||||
|
||||
function makeRenderCacheKey(
|
||||
toolCallId?: string | null,
|
||||
@@ -304,6 +305,7 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
let toolCallRootRef: HTMLDivElement | undefined
|
||||
let scrollContainerRef: HTMLDivElement | undefined
|
||||
let detachScrollIntentListeners: (() => void) | undefined
|
||||
let lastFileEventKey = ""
|
||||
|
||||
let pendingScrollFrame: number | null = null
|
||||
let pendingAnchorScroll: number | null = null
|
||||
@@ -493,6 +495,19 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const state = toolState()
|
||||
if (!state || state.status !== "completed") return
|
||||
const tool = toolName()
|
||||
if (!["write", "edit", "patch"].includes(tool)) return
|
||||
const key = `${toolCallIdentifier()}:${tool}:${state.status}`
|
||||
if (key === lastFileEventKey) return
|
||||
lastFileEventKey = key
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent(FILE_CHANGE_EVENT, { detail: { instanceId: props.instanceId } }))
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const activeKey = activePermissionKey()
|
||||
if (!activeKey) return
|
||||
|
||||
BIN
packages/ui/src/images/NomadArch-Icon.png
Normal file
BIN
packages/ui/src/images/NomadArch-Icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 471 KiB |
@@ -6,6 +6,7 @@
|
||||
@import './styles/markdown.css';
|
||||
@import './styles/tabs.css';
|
||||
@import './styles/antigravity.css';
|
||||
@import './styles/responsive.css';
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -18,6 +19,7 @@
|
||||
|
||||
|
||||
|
||||
html,
|
||||
body {
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-base);
|
||||
@@ -27,12 +29,18 @@ body {
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: var(--surface-base);
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--surface-base);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +69,5 @@ body {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
390
packages/ui/src/lib/__tests__/compaction-schema.test.ts
Normal file
390
packages/ui/src/lib/__tests__/compaction-schema.test.ts
Normal file
@@ -0,0 +1,390 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { describe, it } from "node:test"
|
||||
import {
|
||||
validateStructuredSummary,
|
||||
validateCompactionEvent,
|
||||
validateCompactionResult,
|
||||
sanitizeStructuredSummary,
|
||||
type StructuredSummary,
|
||||
type CompactionEvent,
|
||||
type CompactionResult,
|
||||
} from "../compaction-schema.js"
|
||||
|
||||
describe("compaction schema", () => {
|
||||
describe("validateStructuredSummary", () => {
|
||||
it("validates tierA summary", () => {
|
||||
const summary: StructuredSummary = {
|
||||
timestamp: new Date().toISOString(),
|
||||
summary_type: "tierA_short",
|
||||
what_was_done: ["Created API endpoint", "Added error handling"],
|
||||
files: [{ path: "src/api.ts", notes: "API endpoint file", decision_id: "decision-1" }],
|
||||
current_state: "API endpoint implemented with error handling",
|
||||
key_decisions: [],
|
||||
next_steps: [],
|
||||
blockers: [],
|
||||
artifacts: [],
|
||||
tags: [],
|
||||
provenance: {
|
||||
model: "claude-3.5-sonnet",
|
||||
token_count: 1000,
|
||||
redactions: [],
|
||||
},
|
||||
aggressive: false,
|
||||
}
|
||||
const result = validateStructuredSummary(summary)
|
||||
assert.ok(result.success)
|
||||
assert.equal(result.data.summary_type, "tierA_short")
|
||||
})
|
||||
|
||||
it("validates tierB summary", () => {
|
||||
const summary: StructuredSummary = {
|
||||
timestamp: new Date().toISOString(),
|
||||
summary_type: "tierB_detailed",
|
||||
what_was_done: ["Created API endpoint", "Added error handling", "Wrote unit tests"],
|
||||
files: [
|
||||
{ path: "src/api.ts", notes: "API endpoint file", decision_id: "decision-1" },
|
||||
{ path: "src/api.test.ts", notes: "Test file", decision_id: "decision-2" },
|
||||
],
|
||||
current_state: "API endpoint implemented with error handling and full test coverage",
|
||||
key_decisions: [
|
||||
{
|
||||
id: "decision-1",
|
||||
decision: "Use Fastify for performance",
|
||||
rationale: "Fastify provides better performance than Express",
|
||||
actor: "agent",
|
||||
},
|
||||
],
|
||||
next_steps: ["Add authentication", "Implement rate limiting"],
|
||||
blockers: [],
|
||||
artifacts: [],
|
||||
tags: ["api", "fastify"],
|
||||
provenance: {
|
||||
model: "claude-3.5-sonnet",
|
||||
token_count: 1500,
|
||||
redactions: [],
|
||||
},
|
||||
aggressive: false,
|
||||
}
|
||||
const result = validateStructuredSummary(summary)
|
||||
assert.ok(result.success)
|
||||
assert.equal(result.data.summary_type, "tierB_detailed")
|
||||
assert.ok(result.data.key_decisions)
|
||||
assert.equal(result.data.key_decisions.length, 1)
|
||||
})
|
||||
|
||||
it("rejects invalid timestamp", () => {
|
||||
const summary = {
|
||||
timestamp: "invalid-date",
|
||||
summary_type: "tierA_short" as const,
|
||||
what_was_done: ["Created API endpoint"],
|
||||
files: [],
|
||||
current_state: "API endpoint implemented",
|
||||
key_decisions: [],
|
||||
next_steps: [],
|
||||
blockers: [],
|
||||
artifacts: [],
|
||||
tags: [],
|
||||
provenance: {
|
||||
model: "claude-3.5-sonnet",
|
||||
token_count: 1000,
|
||||
redactions: [],
|
||||
},
|
||||
aggressive: false,
|
||||
}
|
||||
const result = validateStructuredSummary(summary)
|
||||
assert.ok(!result.success)
|
||||
assert.ok(result.errors.length > 0)
|
||||
})
|
||||
|
||||
it("rejects empty what_was_done array", () => {
|
||||
const summary = {
|
||||
timestamp: new Date().toISOString(),
|
||||
summary_type: "tierA_short" as const,
|
||||
what_was_done: [],
|
||||
files: [],
|
||||
current_state: "API endpoint implemented",
|
||||
key_decisions: [],
|
||||
next_steps: [],
|
||||
blockers: [],
|
||||
artifacts: [],
|
||||
tags: [],
|
||||
provenance: {
|
||||
model: "claude-3.5-sonnet",
|
||||
token_count: 1000,
|
||||
redactions: [],
|
||||
},
|
||||
aggressive: false,
|
||||
}
|
||||
const result = validateStructuredSummary(summary)
|
||||
assert.ok(!result.success)
|
||||
assert.ok(result.errors.some((e) => e.includes("what_was_done")))
|
||||
})
|
||||
|
||||
it("rejects empty current_state", () => {
|
||||
const summary = {
|
||||
timestamp: new Date().toISOString(),
|
||||
summary_type: "tierA_short" as const,
|
||||
what_was_done: ["Created API endpoint"],
|
||||
files: [],
|
||||
current_state: "",
|
||||
key_decisions: [],
|
||||
next_steps: [],
|
||||
blockers: [],
|
||||
artifacts: [],
|
||||
tags: [],
|
||||
provenance: {
|
||||
model: "claude-3.5-sonnet",
|
||||
token_count: 1000,
|
||||
redactions: [],
|
||||
},
|
||||
aggressive: false,
|
||||
}
|
||||
const result = validateStructuredSummary(summary)
|
||||
assert.ok(!result.success)
|
||||
assert.ok(result.errors.some((e) => e.includes("current_state")))
|
||||
})
|
||||
|
||||
it("rejects invalid actor in key_decisions", () => {
|
||||
const summary = {
|
||||
timestamp: new Date().toISOString(),
|
||||
summary_type: "tierA_short" as const,
|
||||
what_was_done: ["Created API endpoint"],
|
||||
files: [],
|
||||
current_state: "API endpoint implemented",
|
||||
key_decisions: [
|
||||
{
|
||||
id: "decision-1",
|
||||
decision: "Use Fastify",
|
||||
rationale: "Performance",
|
||||
actor: "invalid" as any,
|
||||
},
|
||||
],
|
||||
next_steps: [],
|
||||
blockers: [],
|
||||
artifacts: [],
|
||||
tags: [],
|
||||
provenance: {
|
||||
model: "claude-3.5-sonnet",
|
||||
token_count: 1000,
|
||||
redactions: [],
|
||||
},
|
||||
aggressive: false,
|
||||
}
|
||||
const result = validateStructuredSummary(summary)
|
||||
assert.ok(!result.success)
|
||||
})
|
||||
})
|
||||
|
||||
describe("validateCompactionEvent", () => {
|
||||
it("validates user-triggered compaction", () => {
|
||||
const event: CompactionEvent = {
|
||||
event_id: "comp_1234567890",
|
||||
timestamp: new Date().toISOString(),
|
||||
actor: "user",
|
||||
trigger_reason: "manual",
|
||||
token_before: 10000,
|
||||
token_after: 3000,
|
||||
model_used: "claude-3.5-sonnet",
|
||||
cost_estimate: 0.05,
|
||||
}
|
||||
const result = validateCompactionEvent(event)
|
||||
assert.ok(result.success)
|
||||
assert.equal(result.data.actor, "user")
|
||||
})
|
||||
|
||||
it("validates auto-triggered compaction", () => {
|
||||
const event: CompactionEvent = {
|
||||
event_id: "auto_1234567890",
|
||||
timestamp: new Date().toISOString(),
|
||||
actor: "auto",
|
||||
trigger_reason: "overflow",
|
||||
token_before: 15000,
|
||||
token_after: 5000,
|
||||
model_used: "claude-3.5-sonnet",
|
||||
cost_estimate: 0.07,
|
||||
}
|
||||
const result = validateCompactionEvent(event)
|
||||
assert.ok(result.success)
|
||||
assert.equal(result.data.actor, "auto")
|
||||
assert.equal(result.data.trigger_reason, "overflow")
|
||||
})
|
||||
|
||||
it("rejects negative token values", () => {
|
||||
const event = {
|
||||
event_id: "comp_1234567890",
|
||||
timestamp: new Date().toISOString(),
|
||||
actor: "user" as const,
|
||||
trigger_reason: "manual" as const,
|
||||
token_before: -1000,
|
||||
token_after: 3000,
|
||||
model_used: "claude-3.5-sonnet",
|
||||
cost_estimate: 0.05,
|
||||
}
|
||||
const result = validateCompactionEvent(event)
|
||||
assert.ok(!result.success)
|
||||
})
|
||||
|
||||
it("rejects empty event_id", () => {
|
||||
const event = {
|
||||
event_id: "",
|
||||
timestamp: new Date().toISOString(),
|
||||
actor: "user" as const,
|
||||
trigger_reason: "manual" as const,
|
||||
token_before: 10000,
|
||||
token_after: 3000,
|
||||
model_used: "claude-3.5-sonnet",
|
||||
cost_estimate: 0.05,
|
||||
}
|
||||
const result = validateCompactionEvent(event)
|
||||
assert.ok(!result.success)
|
||||
})
|
||||
|
||||
it("rejects invalid actor", () => {
|
||||
const event = {
|
||||
event_id: "comp_1234567890",
|
||||
timestamp: new Date().toISOString(),
|
||||
actor: "invalid" as any,
|
||||
trigger_reason: "manual" as const,
|
||||
token_before: 10000,
|
||||
token_after: 3000,
|
||||
model_used: "claude-3.5-sonnet",
|
||||
cost_estimate: 0.05,
|
||||
}
|
||||
const result = validateCompactionEvent(event)
|
||||
assert.ok(!result.success)
|
||||
})
|
||||
})
|
||||
|
||||
describe("validateCompactionResult", () => {
|
||||
it("validates successful compaction", () => {
|
||||
const result: CompactionResult = {
|
||||
success: true,
|
||||
mode: "compact",
|
||||
human_summary: "Compacted 100 messages",
|
||||
detailed_summary: {
|
||||
timestamp: new Date().toISOString(),
|
||||
summary_type: "tierA_short",
|
||||
what_was_done: ["Compacted 100 messages"],
|
||||
files: [],
|
||||
current_state: "Session compacted",
|
||||
key_decisions: [],
|
||||
next_steps: [],
|
||||
blockers: [],
|
||||
artifacts: [],
|
||||
tags: [],
|
||||
provenance: {
|
||||
model: "claude-3.5-sonnet",
|
||||
token_count: 1000,
|
||||
redactions: [],
|
||||
},
|
||||
aggressive: false,
|
||||
},
|
||||
token_before: 10000,
|
||||
token_after: 3000,
|
||||
token_reduction_pct: 70,
|
||||
}
|
||||
const validation = validateCompactionResult(result)
|
||||
assert.ok(validation.success)
|
||||
})
|
||||
|
||||
it("validates failed compaction", () => {
|
||||
const result: CompactionResult = {
|
||||
success: false,
|
||||
mode: "compact",
|
||||
human_summary: "Compaction failed",
|
||||
token_before: 10000,
|
||||
token_after: 10000,
|
||||
token_reduction_pct: 0,
|
||||
}
|
||||
const validation = validateCompactionResult(result)
|
||||
assert.ok(validation.success)
|
||||
assert.equal(validation.data.success, false)
|
||||
})
|
||||
|
||||
it("rejects invalid token reduction percentage", () => {
|
||||
const result = {
|
||||
success: true,
|
||||
mode: "compact" as const,
|
||||
human_summary: "Compacted 100 messages",
|
||||
token_before: 10000,
|
||||
token_after: 3000,
|
||||
token_reduction_pct: 150,
|
||||
}
|
||||
const validation = validateCompactionResult(result)
|
||||
assert.ok(!validation.success)
|
||||
})
|
||||
|
||||
it("rejects negative token reduction percentage", () => {
|
||||
const result = {
|
||||
success: true,
|
||||
mode: "compact" as const,
|
||||
human_summary: "Compacted 100 messages",
|
||||
token_before: 10000,
|
||||
token_after: 3000,
|
||||
token_reduction_pct: -10,
|
||||
}
|
||||
const validation = validateCompactionResult(result)
|
||||
assert.ok(!validation.success)
|
||||
})
|
||||
})
|
||||
|
||||
describe("sanitizeStructuredSummary", () => {
|
||||
it("sanitizes summary by removing extra fields", () => {
|
||||
const dirtySummary = {
|
||||
timestamp: new Date().toISOString(),
|
||||
summary_type: "tierA_short" as const,
|
||||
what_was_done: ["Created API endpoint"],
|
||||
files: [],
|
||||
current_state: "API endpoint implemented",
|
||||
key_decisions: [],
|
||||
next_steps: [],
|
||||
blockers: [],
|
||||
artifacts: [],
|
||||
tags: [],
|
||||
provenance: {
|
||||
model: "claude-3.5-sonnet",
|
||||
token_count: 1000,
|
||||
redactions: [],
|
||||
},
|
||||
aggressive: false,
|
||||
extraField: "should be removed",
|
||||
anotherExtra: { nested: "data" },
|
||||
}
|
||||
const clean = sanitizeStructuredSummary(dirtySummary)
|
||||
assert.ok(clean)
|
||||
assert.ok(!("extraField" in clean))
|
||||
assert.ok(!("anotherExtra" in clean))
|
||||
assert.equal(clean?.summary_type, "tierA_short")
|
||||
})
|
||||
|
||||
it("preserves all valid fields", () => {
|
||||
const summary: StructuredSummary = {
|
||||
timestamp: new Date().toISOString(),
|
||||
summary_type: "tierA_short",
|
||||
what_was_done: ["Created API endpoint"],
|
||||
files: [{ path: "src/api.ts", notes: "API endpoint file", decision_id: "decision-1" }],
|
||||
current_state: "API endpoint implemented",
|
||||
key_decisions: [],
|
||||
next_steps: ["Add tests"],
|
||||
blockers: [],
|
||||
artifacts: [],
|
||||
tags: ["api"],
|
||||
provenance: {
|
||||
model: "claude-3.5-sonnet",
|
||||
token_count: 1000,
|
||||
redactions: [],
|
||||
},
|
||||
aggressive: false,
|
||||
}
|
||||
const clean = sanitizeStructuredSummary(summary)
|
||||
assert.ok(clean)
|
||||
assert.equal(clean?.what_was_done.length, 1)
|
||||
assert.ok(clean?.files)
|
||||
assert.equal(clean.files.length, 1)
|
||||
assert.ok(clean?.next_steps)
|
||||
assert.equal(clean.next_steps.length, 1)
|
||||
assert.ok(clean?.tags)
|
||||
assert.equal(clean.tags.length, 1)
|
||||
})
|
||||
})
|
||||
})
|
||||
158
packages/ui/src/lib/__tests__/secrets-detector.test.ts
Normal file
158
packages/ui/src/lib/__tests__/secrets-detector.test.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { describe, it } from "node:test"
|
||||
import { redactSecrets, hasSecrets, redactObject } from "../secrets-detector.js"
|
||||
|
||||
describe("secrets detector", () => {
|
||||
describe("redactSecrets", () => {
|
||||
it("redacts API keys", () => {
|
||||
const content = "My API key is sk-1234567890abcdef"
|
||||
const result = redactSecrets(content, "test")
|
||||
assert.ok(result.redactions.length > 0)
|
||||
assert.ok(!result.clean.includes("sk-1234567890abcdef"))
|
||||
})
|
||||
|
||||
it("redacts AWS access keys", () => {
|
||||
const content = "AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE"
|
||||
const result = redactSecrets(content, "test")
|
||||
assert.ok(result.redactions.length > 0)
|
||||
assert.ok(!result.clean.includes("AKIAIOSFODNN7EXAMPLE"))
|
||||
})
|
||||
|
||||
it("redacts bearer tokens", () => {
|
||||
const content = "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
|
||||
const result = redactSecrets(content, "test")
|
||||
assert.ok(result.redactions.length > 0)
|
||||
assert.ok(!result.clean.includes("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"))
|
||||
})
|
||||
|
||||
it("redacts GitHub tokens", () => {
|
||||
const content = "github_pat_11AAAAAAAAAAAAAAAAAAAAAA"
|
||||
const result = redactSecrets(content, "test")
|
||||
assert.ok(result.redactions.length > 0)
|
||||
assert.ok(!result.clean.includes("github_pat_11AAAAAAAAAAAAAAAAAAAAAA"))
|
||||
})
|
||||
|
||||
it("redacts npm tokens", () => {
|
||||
const content = "npm_1234567890abcdef1234567890abcdef1234"
|
||||
const result = redactSecrets(content, "test")
|
||||
assert.ok(result.redactions.length > 0)
|
||||
assert.ok(!result.clean.includes("npm_1234567890abcdef1234567890abcdef1234"))
|
||||
})
|
||||
|
||||
it("preserves non-sensitive content", () => {
|
||||
const content = "This is a normal message without any secrets"
|
||||
const result = redactSecrets(content, "test")
|
||||
assert.equal(result.clean, content)
|
||||
assert.equal(result.redactions.length, 0)
|
||||
})
|
||||
|
||||
it("handles empty content", () => {
|
||||
const content = ""
|
||||
const result = redactSecrets(content, "test")
|
||||
assert.equal(result.clean, "")
|
||||
assert.equal(result.redactions.length, 0)
|
||||
})
|
||||
|
||||
it("provides redaction reasons", () => {
|
||||
const content = "API key: sk-1234567890abcdef"
|
||||
const result = redactSecrets(content, "test")
|
||||
assert.ok(result.redactions.length > 0)
|
||||
assert.ok(result.redactions[0].reason.length > 0)
|
||||
})
|
||||
|
||||
it("tracks redaction paths", () => {
|
||||
const content = "sk-1234567890abcdef"
|
||||
const result = redactSecrets(content, "test")
|
||||
assert.ok(result.redactions.length > 0)
|
||||
assert.equal(typeof result.redactions[0].path, "string")
|
||||
assert.ok(result.redactions[0].path.length > 0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("hasSecrets", () => {
|
||||
it("detects API keys", () => {
|
||||
const content = "sk-1234567890abcdef"
|
||||
assert.ok(hasSecrets(content))
|
||||
})
|
||||
|
||||
it("detects bearer tokens", () => {
|
||||
const content = "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
|
||||
assert.ok(hasSecrets(content))
|
||||
})
|
||||
|
||||
it("returns false for normal content", () => {
|
||||
const content = "This is a normal message"
|
||||
assert.ok(!hasSecrets(content))
|
||||
})
|
||||
|
||||
it("returns false for empty content", () => {
|
||||
const content = ""
|
||||
assert.ok(!hasSecrets(content))
|
||||
})
|
||||
})
|
||||
|
||||
describe("redactObject", () => {
|
||||
it("redacts secrets in nested objects", () => {
|
||||
const obj = {
|
||||
apiKey: "sk-1234567890abcdef",
|
||||
nested: {
|
||||
token: "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9",
|
||||
},
|
||||
}
|
||||
const result = redactObject(obj, "test")
|
||||
assert.ok(!result.apiKey.includes("sk-1234567890abcdef"))
|
||||
assert.ok(!result.nested.token.includes("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"))
|
||||
})
|
||||
|
||||
it("redacts secrets in arrays", () => {
|
||||
const obj = {
|
||||
messages: [
|
||||
{ content: "Use sk-1234567890abcdef" },
|
||||
{ content: "Normal message" },
|
||||
],
|
||||
}
|
||||
const result = redactObject(obj, "test")
|
||||
assert.ok(!result.messages[0].content.includes("sk-1234567890abcdef"))
|
||||
assert.equal(result.messages[1].content, "Normal message")
|
||||
})
|
||||
|
||||
it("preserves non-sensitive fields", () => {
|
||||
const obj = {
|
||||
name: "John Doe",
|
||||
age: 30,
|
||||
message: "Hello world",
|
||||
}
|
||||
const result = redactObject(obj, "test")
|
||||
assert.equal(result.name, "John Doe")
|
||||
assert.equal(result.age, 30)
|
||||
assert.equal(result.message, "Hello world")
|
||||
})
|
||||
|
||||
it("handles null and undefined values", () => {
|
||||
const obj = {
|
||||
value: null,
|
||||
undefined: undefined,
|
||||
message: "sk-1234567890abcdef",
|
||||
}
|
||||
const result = redactObject(obj, "test")
|
||||
assert.equal(result.value, null)
|
||||
assert.equal(result.undefined, undefined)
|
||||
assert.ok(!result.message.includes("sk-1234567890abcdef"))
|
||||
})
|
||||
|
||||
it("preserves object structure", () => {
|
||||
const obj = {
|
||||
level1: {
|
||||
level2: {
|
||||
level3: {
|
||||
secret: "sk-1234567890abcdef",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
const result = redactObject(obj, "test")
|
||||
assert.ok(result.level1.level2.level3.secret)
|
||||
assert.ok(!result.level1.level2.level3.secret.includes("sk-1234567890abcdef"))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -19,6 +19,13 @@ import type {
|
||||
WorkspaceEventPayload,
|
||||
WorkspaceEventType,
|
||||
WorkspaceGitStatus,
|
||||
WorkspaceExportRequest,
|
||||
WorkspaceExportResponse,
|
||||
WorkspaceImportRequest,
|
||||
WorkspaceImportResponse,
|
||||
WorkspaceMcpConfigRequest,
|
||||
WorkspaceMcpConfigResponse,
|
||||
PortAvailabilityResponse,
|
||||
} from "../../../server/src/api-types"
|
||||
import { getLogger } from "./logger"
|
||||
|
||||
@@ -158,6 +165,27 @@ export const serverApi = {
|
||||
fetchWorkspaceGitStatus(id: string): Promise<WorkspaceGitStatus> {
|
||||
return request<WorkspaceGitStatus>(`/api/workspaces/${encodeURIComponent(id)}/git/status`)
|
||||
},
|
||||
exportWorkspace(id: string, payload: WorkspaceExportRequest): Promise<WorkspaceExportResponse> {
|
||||
return request<WorkspaceExportResponse>(`/api/workspaces/${encodeURIComponent(id)}/export`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
},
|
||||
importWorkspace(payload: WorkspaceImportRequest): Promise<WorkspaceImportResponse> {
|
||||
return request<WorkspaceImportResponse>("/api/workspaces/import", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
},
|
||||
fetchWorkspaceMcpConfig(id: string): Promise<WorkspaceMcpConfigResponse> {
|
||||
return request<WorkspaceMcpConfigResponse>(`/api/workspaces/${encodeURIComponent(id)}/mcp-config`)
|
||||
},
|
||||
updateWorkspaceMcpConfig(id: string, config: WorkspaceMcpConfigRequest["config"]): Promise<WorkspaceMcpConfigResponse> {
|
||||
return request<WorkspaceMcpConfigResponse>(`/api/workspaces/${encodeURIComponent(id)}/mcp-config`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ config }),
|
||||
})
|
||||
},
|
||||
|
||||
fetchConfig(): Promise<AppConfig> {
|
||||
return request<AppConfig>("/api/config/app")
|
||||
@@ -241,6 +269,9 @@ export const serverApi = {
|
||||
const params = new URLSearchParams({ id })
|
||||
return request<SkillDetail>(`/api/skills/detail?${params.toString()}`)
|
||||
},
|
||||
fetchAvailablePort(): Promise<PortAvailabilityResponse> {
|
||||
return request<PortAvailabilityResponse>("/api/ports/available")
|
||||
},
|
||||
}
|
||||
|
||||
export type { WorkspaceDescriptor, WorkspaceLogEntry, WorkspaceEventPayload, WorkspaceEventType }
|
||||
|
||||
168
packages/ui/src/lib/compaction-schema.ts
Normal file
168
packages/ui/src/lib/compaction-schema.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { z } from "zod"
|
||||
import { getLogger } from "./logger.js"
|
||||
|
||||
const log = getLogger("compaction-schema")
|
||||
|
||||
export const SecretRedactionSchema = z.object({
|
||||
path: z.string(),
|
||||
reason: z.string(),
|
||||
})
|
||||
|
||||
export const ProvenanceSchema = z.object({
|
||||
model: z.string().min(1, "Model name is required"),
|
||||
token_count: z.number().int().nonnegative(),
|
||||
redactions: z.array(SecretRedactionSchema),
|
||||
})
|
||||
|
||||
export const KeyDecisionSchema = z.object({
|
||||
id: z.string().min(1, "Decision ID is required"),
|
||||
decision: z.string().min(1, "Decision is required"),
|
||||
rationale: z.string().min(1, "Rationale is required"),
|
||||
actor: z.enum(["agent", "user"], { errorMap: () => ({ message: "Actor must be 'agent' or 'user'" }) }),
|
||||
})
|
||||
|
||||
export const ArtifactSchema = z.object({
|
||||
type: z.string().min(1, "Artifact type is required"),
|
||||
uri: z.string().min(1, "Artifact URI is required"),
|
||||
notes: z.string(),
|
||||
})
|
||||
|
||||
export const FileReferenceSchema = z.object({
|
||||
path: z.string().min(1, "File path is required"),
|
||||
notes: z.string(),
|
||||
decision_id: z.string().min(1, "Decision ID is required"),
|
||||
})
|
||||
|
||||
export const StructuredSummarySchema = z.object({
|
||||
timestamp: z.string().datetime(),
|
||||
summary_type: z.enum(["tierA_short", "tierB_detailed"]),
|
||||
what_was_done: z.array(z.string()).min(1, "At least one 'what_was_done' entry is required"),
|
||||
files: z.array(FileReferenceSchema).optional(),
|
||||
current_state: z.string().min(1, "Current state is required"),
|
||||
key_decisions: z.array(KeyDecisionSchema).optional(),
|
||||
next_steps: z.array(z.string()).optional(),
|
||||
blockers: z.array(z.string()).optional(),
|
||||
artifacts: z.array(ArtifactSchema).optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
provenance: ProvenanceSchema,
|
||||
aggressive: z.boolean(),
|
||||
})
|
||||
|
||||
export const CompactionEventSchema = z.object({
|
||||
event_id: z.string().min(1, "Event ID is required"),
|
||||
timestamp: z.string().datetime(),
|
||||
actor: z.enum(["user", "auto"], { errorMap: () => ({ message: "Actor must be 'user' or 'auto'" }) }),
|
||||
trigger_reason: z.enum(["overflow", "scheduled", "manual"]),
|
||||
token_before: z.number().int().nonnegative(),
|
||||
token_after: z.number().int().nonnegative(),
|
||||
model_used: z.string().min(1, "Model name is required"),
|
||||
cost_estimate: z.number().nonnegative(),
|
||||
snapshot_id: z.string().optional(),
|
||||
})
|
||||
|
||||
export const CompactionConfigSchema = z.object({
|
||||
autoCompactEnabled: z.boolean(),
|
||||
autoCompactThreshold: z.number().int().min(1).max(100),
|
||||
compactPreserveWindow: z.number().int().positive(),
|
||||
pruneReclaimThreshold: z.number().int().positive(),
|
||||
userPreference: z.enum(["auto", "ask", "never"]),
|
||||
undoRetentionWindow: z.number().int().positive(),
|
||||
recentMessagesToKeep: z.number().int().positive().optional(),
|
||||
systemMessagesToKeep: z.number().int().positive().optional(),
|
||||
incrementalChunkSize: z.number().int().positive().optional(),
|
||||
// ADK-style sliding window settings
|
||||
compactionInterval: z.number().int().positive().optional(),
|
||||
overlapSize: z.number().int().nonnegative().optional(),
|
||||
enableAiSummarization: z.boolean().optional(),
|
||||
summaryMaxTokens: z.number().int().positive().optional(),
|
||||
preserveFileOperations: z.boolean().optional(),
|
||||
preserveDecisions: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export const CompactionResultSchema = z.object({
|
||||
success: z.boolean(),
|
||||
mode: z.enum(["prune", "compact"]),
|
||||
human_summary: z.string().min(1, "Human summary is required"),
|
||||
detailed_summary: StructuredSummarySchema.optional(),
|
||||
token_before: z.number().int().nonnegative(),
|
||||
token_after: z.number().int().nonnegative(),
|
||||
token_reduction_pct: z.number().int().min(0).max(100),
|
||||
compaction_event: CompactionEventSchema.optional(),
|
||||
preview: z.string().optional(),
|
||||
})
|
||||
|
||||
export type SecretRedaction = z.infer<typeof SecretRedactionSchema>
|
||||
export type Provenance = z.infer<typeof ProvenanceSchema>
|
||||
export type KeyDecision = z.infer<typeof KeyDecisionSchema>
|
||||
export type Artifact = z.infer<typeof ArtifactSchema>
|
||||
export type FileReference = z.infer<typeof FileReferenceSchema>
|
||||
export type StructuredSummary = z.infer<typeof StructuredSummarySchema>
|
||||
export type CompactionEvent = z.infer<typeof CompactionEventSchema>
|
||||
export type CompactionConfig = z.infer<typeof CompactionConfigSchema>
|
||||
export type CompactionResult = z.infer<typeof CompactionResultSchema>
|
||||
|
||||
export function validateStructuredSummary(data: unknown): { success: true; data: StructuredSummary } | { success: false; errors: string[] } {
|
||||
const result = StructuredSummarySchema.safeParse(data)
|
||||
if (!result.success) {
|
||||
const errors = result.error.errors.map((e) => `${e.path.join(".")}: ${e.message}`)
|
||||
return { success: false, errors }
|
||||
}
|
||||
return { success: true, data: result.data }
|
||||
}
|
||||
|
||||
export function validateCompactionEvent(data: unknown): { success: true; data: CompactionEvent } | { success: false; errors: string[] } {
|
||||
const result = CompactionEventSchema.safeParse(data)
|
||||
if (!result.success) {
|
||||
const errors = result.error.errors.map((e) => `${e.path.join(".")}: ${e.message}`)
|
||||
return { success: false, errors }
|
||||
}
|
||||
return { success: true, data: result.data }
|
||||
}
|
||||
|
||||
export function validateCompactionResult(data: unknown): { success: true; data: CompactionResult } | { success: false; errors: string[] } {
|
||||
const result = CompactionResultSchema.safeParse(data)
|
||||
if (!result.success) {
|
||||
const errors = result.error.errors.map((e) => `${e.path.join(".")}: ${e.message}`)
|
||||
return { success: false, errors }
|
||||
}
|
||||
return { success: true, data: result.data }
|
||||
}
|
||||
|
||||
export function validateCompactionConfig(data: unknown): { success: true; data: CompactionConfig } | { success: false; errors: string[] } {
|
||||
const result = CompactionConfigSchema.safeParse(data)
|
||||
if (!result.success) {
|
||||
const errors = result.error.errors.map((e) => `${e.path.join(".")}: ${e.message}`)
|
||||
return { success: false, errors }
|
||||
}
|
||||
return { success: true, data: result.data }
|
||||
}
|
||||
|
||||
export function sanitizeStructuredSummary(input: unknown): StructuredSummary | null {
|
||||
const result = validateStructuredSummary(input)
|
||||
if (!result.success) {
|
||||
log.warn("Invalid structured summary, using fallback", { errors: result.errors })
|
||||
return null
|
||||
}
|
||||
return result.data
|
||||
}
|
||||
|
||||
export function createDefaultStructuredSummary(aggressive: boolean = false): StructuredSummary {
|
||||
return {
|
||||
timestamp: new Date().toISOString(),
|
||||
summary_type: "tierA_short",
|
||||
what_was_done: ["Session compaction completed"],
|
||||
files: [],
|
||||
current_state: "Session context has been compacted",
|
||||
key_decisions: [],
|
||||
next_steps: [],
|
||||
blockers: [],
|
||||
artifacts: [],
|
||||
tags: [],
|
||||
provenance: {
|
||||
model: "system",
|
||||
token_count: 0,
|
||||
redactions: [],
|
||||
},
|
||||
aggressive,
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
getSessions,
|
||||
setActiveSession,
|
||||
} from "../../stores/sessions"
|
||||
import { setSessionCompactionState } from "../../stores/session-compaction"
|
||||
import { compactSession } from "../../stores/session-actions"
|
||||
import { showAlertDialog } from "../../stores/alerts"
|
||||
import type { Instance } from "../../types/instance"
|
||||
import type { MessageRecord } from "../../stores/message-v2/types"
|
||||
@@ -235,21 +235,9 @@ export function useCommands(options: UseCommandsOptions) {
|
||||
const sessionId = activeSessionIdForInstance()
|
||||
if (!instance || !instance.client || !sessionId || sessionId === "info") return
|
||||
|
||||
const sessions = getSessions(instance.id)
|
||||
const session = sessions.find((s) => s.id === sessionId)
|
||||
if (!session) return
|
||||
|
||||
try {
|
||||
setSessionCompactionState(instance.id, sessionId, true)
|
||||
await instance.client.session.summarize({
|
||||
path: { id: sessionId },
|
||||
body: {
|
||||
providerID: session.model.providerId,
|
||||
modelID: session.model.modelId,
|
||||
},
|
||||
})
|
||||
await compactSession(instance.id, sessionId)
|
||||
} catch (error) {
|
||||
setSessionCompactionState(instance.id, sessionId, false)
|
||||
log.error("Failed to compact session", error)
|
||||
const message = error instanceof Error ? error.message : "Failed to compact session"
|
||||
showAlertDialog(`Compact failed: ${message}`, {
|
||||
|
||||
286
packages/ui/src/lib/integrations/qwen-chat.ts
Normal file
286
packages/ui/src/lib/integrations/qwen-chat.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* Qwen OAuth Chat Service
|
||||
* Routes chat requests through the Qwen API using OAuth tokens
|
||||
* Based on the qwen-code implementation from QwenLM/qwen-code
|
||||
*/
|
||||
|
||||
import { getUserScopedKey } from "../user-storage"
|
||||
|
||||
const QWEN_TOKEN_STORAGE_KEY = 'qwen_oauth_token'
|
||||
const DEFAULT_QWEN_ENDPOINT = 'https://dashscope-intl.aliyuncs.com'
|
||||
|
||||
export interface QwenToken {
|
||||
access_token: string
|
||||
token_type: string
|
||||
expires_in: number
|
||||
refresh_token?: string
|
||||
resource_url?: string
|
||||
created_at: number
|
||||
}
|
||||
|
||||
export interface QwenChatMessage {
|
||||
role: 'user' | 'assistant' | 'system'
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface QwenChatRequest {
|
||||
model: string
|
||||
messages: QwenChatMessage[]
|
||||
stream?: boolean
|
||||
temperature?: number
|
||||
max_tokens?: number
|
||||
}
|
||||
|
||||
export interface QwenChatResponse {
|
||||
id: string
|
||||
object: string
|
||||
created: number
|
||||
model: string
|
||||
choices: Array<{
|
||||
index: number
|
||||
message: {
|
||||
role: string
|
||||
content: string
|
||||
}
|
||||
finish_reason: string | null
|
||||
}>
|
||||
usage?: {
|
||||
prompt_tokens: number
|
||||
completion_tokens: number
|
||||
total_tokens: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface QwenStreamChunk {
|
||||
id: string
|
||||
object: string
|
||||
created: number
|
||||
model: string
|
||||
choices: Array<{
|
||||
index: number
|
||||
delta: {
|
||||
role?: string
|
||||
content?: string
|
||||
}
|
||||
finish_reason: string | null
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stored Qwen OAuth token from localStorage
|
||||
*/
|
||||
export function getStoredQwenToken(): QwenToken | null {
|
||||
try {
|
||||
const stored = localStorage.getItem(getUserScopedKey(QWEN_TOKEN_STORAGE_KEY))
|
||||
return stored ? JSON.parse(stored) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Qwen OAuth token is valid and not expired
|
||||
*/
|
||||
export function isQwenTokenValid(token: QwenToken | null): boolean {
|
||||
if (!token || !token.access_token) return false
|
||||
|
||||
const createdAt = token.created_at > 1e12 ? Math.floor(token.created_at / 1000) : token.created_at
|
||||
const expiresAt = (createdAt + token.expires_in) * 1000 - 300000 // 5 min buffer
|
||||
return Date.now() < expiresAt
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the API endpoint URL for Qwen
|
||||
* Uses resource_url from token if available, otherwise falls back to default
|
||||
*/
|
||||
export function getQwenEndpoint(token: QwenToken | null): string {
|
||||
const baseEndpoint = token?.resource_url || DEFAULT_QWEN_ENDPOINT
|
||||
|
||||
// Normalize URL: add protocol if missing
|
||||
const normalizedUrl = baseEndpoint.startsWith('http')
|
||||
? baseEndpoint
|
||||
: `https://${baseEndpoint}`
|
||||
|
||||
// Ensure /v1 suffix for OpenAI-compatible API
|
||||
return normalizedUrl.endsWith('/v1')
|
||||
? normalizedUrl
|
||||
: `${normalizedUrl}/v1`
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a chat completion request to Qwen API
|
||||
*/
|
||||
export async function sendQwenChatRequest(
|
||||
request: QwenChatRequest
|
||||
): Promise<QwenChatResponse> {
|
||||
const token = getStoredQwenToken()
|
||||
|
||||
if (!isQwenTokenValid(token)) {
|
||||
throw new Error('Qwen OAuth token is invalid or expired. Please re-authenticate.')
|
||||
}
|
||||
|
||||
const endpoint = getQwenEndpoint(token)
|
||||
const url = `${endpoint}/chat/completions`
|
||||
|
||||
console.log(`[QwenChat] Sending request to: ${url}`)
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token!.access_token}`,
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: request.model || 'qwen-coder-plus-latest',
|
||||
messages: request.messages,
|
||||
stream: false,
|
||||
temperature: request.temperature,
|
||||
max_tokens: request.max_tokens
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
console.error(`[QwenChat] Request failed: ${response.status}`, errorText)
|
||||
|
||||
// Check for auth errors that require re-authentication
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new Error('Qwen OAuth token expired. Please re-authenticate using /auth.')
|
||||
}
|
||||
|
||||
throw new Error(`Qwen chat request failed: ${response.status} - ${errorText}`)
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a streaming chat completion request to Qwen API
|
||||
*/
|
||||
export async function* sendQwenChatStreamRequest(
|
||||
request: QwenChatRequest
|
||||
): AsyncGenerator<QwenStreamChunk> {
|
||||
const token = getStoredQwenToken()
|
||||
|
||||
if (!isQwenTokenValid(token)) {
|
||||
throw new Error('Qwen OAuth token is invalid or expired. Please re-authenticate.')
|
||||
}
|
||||
|
||||
const endpoint = getQwenEndpoint(token)
|
||||
const url = `${endpoint}/chat/completions`
|
||||
|
||||
console.log(`[QwenChat] Sending streaming request to: ${url}`)
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token!.access_token}`,
|
||||
'Accept': 'text/event-stream'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: request.model || 'qwen-coder-plus-latest',
|
||||
messages: request.messages,
|
||||
stream: true,
|
||||
temperature: request.temperature,
|
||||
max_tokens: request.max_tokens
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
console.error(`[QwenChat] Stream request failed: ${response.status}`, errorText)
|
||||
throw new Error(`Qwen chat request failed: ${response.status} - ${errorText}`)
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error('Response body is missing')
|
||||
}
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
try {
|
||||
let buffer = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
|
||||
// Keep the last incomplete line in buffer
|
||||
buffer = lines.pop() || ''
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
|
||||
if (!trimmed || trimmed === 'data: [DONE]') {
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('data: ')) {
|
||||
try {
|
||||
const data = JSON.parse(trimmed.slice(6))
|
||||
yield data as QwenStreamChunk
|
||||
} catch (e) {
|
||||
console.warn('[QwenChat] Failed to parse SSE chunk:', trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available Qwen models
|
||||
*/
|
||||
export async function getQwenModels(): Promise<{ id: string; name: string }[]> {
|
||||
const token = getStoredQwenToken()
|
||||
|
||||
if (!isQwenTokenValid(token)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const endpoint = getQwenEndpoint(token)
|
||||
const url = `${endpoint}/models`
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token!.access_token}`,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn(`[QwenChat] Failed to fetch models: ${response.status}`)
|
||||
return getDefaultQwenModels()
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return (data.data || []).map((model: any) => ({
|
||||
id: model.id,
|
||||
name: model.id
|
||||
}))
|
||||
} catch (error) {
|
||||
console.warn('[QwenChat] Error fetching models:', error)
|
||||
return getDefaultQwenModels()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default Qwen models when API call fails
|
||||
*/
|
||||
function getDefaultQwenModels(): { id: string; name: string }[] {
|
||||
return [
|
||||
{ id: 'qwen-coder-plus-latest', name: 'Qwen Coder Plus' },
|
||||
{ id: 'qwen-turbo-latest', name: 'Qwen Turbo' },
|
||||
{ id: 'qwen-plus-latest', name: 'Qwen Plus' },
|
||||
{ id: 'qwen-max-latest', name: 'Qwen Max' }
|
||||
]
|
||||
}
|
||||
@@ -4,8 +4,8 @@
|
||||
*/
|
||||
|
||||
import { nanoid } from 'nanoid'
|
||||
import type { AxiosInstance, AxiosResponse } from 'axios'
|
||||
import { createSignal, onMount } from 'solid-js'
|
||||
import { getUserScopedKey } from "../user-storage"
|
||||
|
||||
// Configuration schema
|
||||
export interface QwenConfig {
|
||||
@@ -13,6 +13,7 @@ export interface QwenConfig {
|
||||
redirectUri?: string
|
||||
scope?: string
|
||||
baseUrl?: string
|
||||
apiBaseUrl?: string
|
||||
}
|
||||
|
||||
export interface QwenAuthToken {
|
||||
@@ -22,6 +23,7 @@ export interface QwenAuthToken {
|
||||
refresh_token?: string
|
||||
scope?: string
|
||||
created_at: number
|
||||
resource_url?: string
|
||||
}
|
||||
|
||||
export interface QwenUser {
|
||||
@@ -43,82 +45,77 @@ export interface QwenOAuthState {
|
||||
redirect_uri: string
|
||||
}
|
||||
|
||||
function toBase64Url(bytes: Uint8Array): string {
|
||||
let binary = ''
|
||||
for (let i = 0; i < bytes.length; i += 1) {
|
||||
binary += String.fromCharCode(bytes[i])
|
||||
}
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
export class QwenOAuthManager {
|
||||
private config: Required<QwenConfig>
|
||||
private tokenStorageKey = 'qwen_oauth_token'
|
||||
private userStorageKey = 'qwen_user_info'
|
||||
private config: { clientId: string; redirectUri: string; scope: string; baseUrl: string }
|
||||
private tokenStorageKey = getUserScopedKey('qwen_oauth_token')
|
||||
private userStorageKey = getUserScopedKey('qwen_user_info')
|
||||
|
||||
constructor(config: QwenConfig = {}) {
|
||||
this.config = {
|
||||
clientId: config.clientId || 'qwen-code-client',
|
||||
redirectUri: config.redirectUri || `${window.location.origin}/auth/qwen/callback`,
|
||||
scope: config.scope || 'read write',
|
||||
baseUrl: config.baseUrl || 'https://qwen.ai'
|
||||
scope: config.scope || 'openid profile email model.completion',
|
||||
baseUrl: config.apiBaseUrl || config.baseUrl || ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate OAuth URL for authentication
|
||||
* Request device authorization for Qwen OAuth
|
||||
*/
|
||||
async generateAuthUrl(): Promise<{ url: string; state: QwenOAuthState }> {
|
||||
const state = await this.generateOAuthState()
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: this.config.clientId,
|
||||
redirect_uri: this.config.redirectUri,
|
||||
scope: this.config.scope,
|
||||
state: state.state,
|
||||
code_challenge: state.code_challenge,
|
||||
code_challenge_method: 'S256'
|
||||
async requestDeviceAuthorization(codeChallenge: string): Promise<{
|
||||
device_code: string
|
||||
user_code: string
|
||||
verification_uri: string
|
||||
verification_uri_complete: string
|
||||
expires_in: number
|
||||
}> {
|
||||
const response = await fetch(`${this.config.baseUrl}/api/qwen/oauth/device`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: 'S256'
|
||||
})
|
||||
})
|
||||
|
||||
const authUrl = `${this.config.baseUrl}/oauth/authorize?${params.toString()}`
|
||||
|
||||
return {
|
||||
url: authUrl,
|
||||
state
|
||||
if (!response.ok) {
|
||||
const message = await response.text()
|
||||
throw new Error(`Device authorization failed: ${message}`)
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange authorization code for access token
|
||||
* Poll device token endpoint
|
||||
*/
|
||||
async exchangeCodeForToken(code: string, state: string): Promise<QwenAuthToken> {
|
||||
const storedState = this.getOAuthState(state)
|
||||
if (!storedState) {
|
||||
throw new Error('Invalid OAuth state')
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.config.baseUrl}/oauth/token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: this.config.clientId,
|
||||
code,
|
||||
redirect_uri: this.config.redirectUri,
|
||||
code_verifier: storedState.code_verifier
|
||||
})
|
||||
async pollDeviceToken(deviceCode: string, codeVerifier: string): Promise<any> {
|
||||
const response = await fetch(`${this.config.baseUrl}/api/qwen/oauth/token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
device_code: deviceCode,
|
||||
code_verifier: codeVerifier
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Token exchange failed: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const tokenData = await response.json()
|
||||
const token = this.parseTokenResponse(tokenData)
|
||||
|
||||
// Store token
|
||||
this.storeToken(token)
|
||||
this.clearOAuthState(state)
|
||||
|
||||
return token
|
||||
} catch (error) {
|
||||
this.clearOAuthState(state)
|
||||
throw error
|
||||
const rawText = await response.text()
|
||||
try {
|
||||
return JSON.parse(rawText)
|
||||
} catch {
|
||||
throw new Error(`Token poll failed: ${rawText}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,14 +129,12 @@ export class QwenOAuthManager {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.config.baseUrl}/oauth/token`, {
|
||||
const response = await fetch(`${this.config.baseUrl}/api/qwen/oauth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
client_id: this.config.clientId,
|
||||
body: JSON.stringify({
|
||||
refresh_token: currentToken.refresh_token
|
||||
})
|
||||
})
|
||||
@@ -151,7 +146,7 @@ export class QwenOAuthManager {
|
||||
const tokenData = await response.json()
|
||||
const token = this.parseTokenResponse(tokenData)
|
||||
this.storeToken(token)
|
||||
|
||||
|
||||
return token
|
||||
} catch (error) {
|
||||
// If refresh fails, clear stored token
|
||||
@@ -169,17 +164,28 @@ export class QwenOAuthManager {
|
||||
throw new Error('Not authenticated')
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.config.baseUrl}/api/user`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token.access_token}`
|
||||
try {
|
||||
const response = await fetch(`/api/qwen/user`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token.access_token}`
|
||||
}
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch user info: ${response.statusText}`)
|
||||
}
|
||||
const data = await response.json()
|
||||
return data.user || data
|
||||
} catch {
|
||||
return {
|
||||
id: 'qwen-oauth',
|
||||
username: 'Qwen OAuth',
|
||||
tier: 'Free',
|
||||
limits: {
|
||||
requests_per_day: 0,
|
||||
requests_per_minute: 0
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch user info: ${response.statusText}`)
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -191,11 +197,7 @@ export class QwenOAuthManager {
|
||||
return null
|
||||
}
|
||||
|
||||
// Check if token is expired (with 5-minute buffer)
|
||||
const now = Date.now()
|
||||
const expiresAt = (token.created_at + token.expires_in) * 1000 - 300000 // 5 min buffer
|
||||
|
||||
if (now >= expiresAt) {
|
||||
if (this.isTokenExpired(token)) {
|
||||
try {
|
||||
return await this.refreshToken()
|
||||
} catch (error) {
|
||||
@@ -207,37 +209,6 @@ export class QwenOAuthManager {
|
||||
return token
|
||||
}
|
||||
|
||||
/**
|
||||
* Create authenticated HTTP client
|
||||
*/
|
||||
createApiClient(): AxiosInstance {
|
||||
const axios = require('axios') as any
|
||||
|
||||
return axios.create({
|
||||
baseURL: `${this.config.baseUrl}/api`,
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Make authenticated API request
|
||||
*/
|
||||
async makeAuthenticatedRequest<T>(
|
||||
client: AxiosInstance,
|
||||
config: any
|
||||
): Promise<AxiosResponse<T>> {
|
||||
const token = await this.getValidToken()
|
||||
if (!token) {
|
||||
throw new Error('Authentication required')
|
||||
}
|
||||
|
||||
client.defaults.headers.common['Authorization'] = `Bearer ${token.access_token}`
|
||||
return client.request(config)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign out user
|
||||
*/
|
||||
@@ -250,8 +221,9 @@ export class QwenOAuthManager {
|
||||
* Check if user is authenticated
|
||||
*/
|
||||
isAuthenticated(): boolean {
|
||||
const token = this.getValidToken()
|
||||
return token !== null
|
||||
const token = this.getStoredToken()
|
||||
if (!token) return false
|
||||
return !this.isTokenExpired(token)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -269,7 +241,7 @@ export class QwenOAuthManager {
|
||||
/**
|
||||
* Store user info
|
||||
*/
|
||||
private storeUserInfo(user: QwenUser): void {
|
||||
storeUserInfo(user: QwenUser): void {
|
||||
localStorage.setItem(this.userStorageKey, JSON.stringify(user))
|
||||
}
|
||||
|
||||
@@ -297,7 +269,7 @@ export class QwenOAuthManager {
|
||||
|
||||
// Store state temporarily
|
||||
sessionStorage.setItem(`qwen_oauth_${state}`, JSON.stringify(oauthState))
|
||||
|
||||
|
||||
return oauthState
|
||||
}
|
||||
|
||||
@@ -323,38 +295,34 @@ export class QwenOAuthManager {
|
||||
/**
|
||||
* Generate code verifier for PKCE
|
||||
*/
|
||||
private generateCodeVerifier(): string {
|
||||
generateCodeVerifier(): string {
|
||||
const array = new Uint8Array(32)
|
||||
crypto.getRandomValues(array)
|
||||
return Array.from(array, byte => String.fromCharCode(byte)).join('')
|
||||
return toBase64Url(array)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate code challenge for PKCE
|
||||
*/
|
||||
private async generateCodeChallenge(verifier: string): Promise<string> {
|
||||
async generateCodeChallenge(verifier: string): Promise<string> {
|
||||
const encoder = new TextEncoder()
|
||||
const data = encoder.encode(verifier)
|
||||
const digest = await crypto.subtle.digest('SHA-256', data)
|
||||
return Array.from(new Uint8Array(digest))
|
||||
.map(b => String.fromCharCode(b))
|
||||
.join('')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '')
|
||||
return toBase64Url(new Uint8Array(digest))
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse token response
|
||||
*/
|
||||
private parseTokenResponse(data: any): QwenAuthToken {
|
||||
parseTokenResponse(data: any): QwenAuthToken {
|
||||
const token: QwenAuthToken = {
|
||||
access_token: data.access_token,
|
||||
token_type: data.token_type,
|
||||
expires_in: data.expires_in,
|
||||
refresh_token: data.refresh_token,
|
||||
scope: data.scope,
|
||||
created_at: Date.now()
|
||||
resource_url: data.resource_url,
|
||||
created_at: Math.floor(Date.now() / 1000)
|
||||
}
|
||||
|
||||
return token
|
||||
@@ -363,7 +331,7 @@ export class QwenOAuthManager {
|
||||
/**
|
||||
* Store token
|
||||
*/
|
||||
private storeToken(token: QwenAuthToken): void {
|
||||
storeToken(token: QwenAuthToken): void {
|
||||
localStorage.setItem(this.tokenStorageKey, JSON.stringify(token))
|
||||
}
|
||||
|
||||
@@ -379,6 +347,16 @@ export class QwenOAuthManager {
|
||||
}
|
||||
}
|
||||
|
||||
getTokenInfo(): QwenAuthToken | null {
|
||||
return this.getStoredToken()
|
||||
}
|
||||
|
||||
private isTokenExpired(token: QwenAuthToken): boolean {
|
||||
const createdAt = token.created_at > 1e12 ? Math.floor(token.created_at / 1000) : token.created_at
|
||||
const expiresAt = (createdAt + token.expires_in) * 1000 - 300000
|
||||
return Date.now() >= expiresAt
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear token
|
||||
*/
|
||||
@@ -393,70 +371,82 @@ export function useQwenOAuth(config?: QwenConfig) {
|
||||
const [isAuthenticated, setIsAuthenticated] = createSignal(false)
|
||||
const [user, setUser] = createSignal<QwenUser | null>(null)
|
||||
const [isLoading, setIsLoading] = createSignal(false)
|
||||
const [tokenInfo, setTokenInfo] = createSignal<QwenAuthToken | null>(null)
|
||||
|
||||
// Check authentication status on mount
|
||||
onMount(() => {
|
||||
const manager = authManager()
|
||||
if (manager.isAuthenticated()) {
|
||||
manager.getValidToken().then((token) => {
|
||||
if (!token) return
|
||||
setIsAuthenticated(true)
|
||||
setTokenInfo(manager.getTokenInfo())
|
||||
const userInfo = manager.getUserInfo()
|
||||
if (userInfo) {
|
||||
setUser(userInfo)
|
||||
}
|
||||
}
|
||||
}).catch(() => {
|
||||
setIsAuthenticated(false)
|
||||
})
|
||||
})
|
||||
|
||||
const signIn = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const manager = authManager()
|
||||
const { url, state } = await manager.generateAuthUrl()
|
||||
|
||||
// Open popup window for OAuth
|
||||
const codeVerifier = manager.generateCodeVerifier()
|
||||
const codeChallenge = await manager.generateCodeChallenge(codeVerifier)
|
||||
const deviceAuth = await manager.requestDeviceAuthorization(codeChallenge)
|
||||
|
||||
const popup = window.open(
|
||||
url,
|
||||
deviceAuth.verification_uri_complete,
|
||||
'qwen-oauth',
|
||||
'width=500,height=600,scrollbars=yes,resizable=yes'
|
||||
)
|
||||
|
||||
if (!popup) {
|
||||
throw new Error('Failed to open OAuth popup')
|
||||
window.alert(
|
||||
`Open this URL to authenticate: ${deviceAuth.verification_uri_complete}\n\nUser code: ${deviceAuth.user_code}`,
|
||||
)
|
||||
}
|
||||
|
||||
// Listen for popup close
|
||||
const checkClosed = setInterval(() => {
|
||||
if (popup.closed) {
|
||||
clearInterval(checkClosed)
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, 1000)
|
||||
const expiresAt = Date.now() + deviceAuth.expires_in * 1000
|
||||
let pollInterval = 2000
|
||||
|
||||
// Listen for message from popup
|
||||
const messageListener = async (event: MessageEvent) => {
|
||||
if (event.origin !== window.location.origin) return
|
||||
|
||||
if (event.data.type === 'QWEN_OAUTH_SUCCESS') {
|
||||
const { code, state } = event.data
|
||||
await manager.exchangeCodeForToken(code, state)
|
||||
while (Date.now() < expiresAt) {
|
||||
const tokenData = await manager.pollDeviceToken(deviceAuth.device_code, codeVerifier)
|
||||
|
||||
if (tokenData?.access_token) {
|
||||
const token = manager.parseTokenResponse(tokenData)
|
||||
manager.storeToken(token)
|
||||
setTokenInfo(manager.getTokenInfo())
|
||||
const userInfo = await manager.fetchUserInfo()
|
||||
setUser(userInfo)
|
||||
if (userInfo) {
|
||||
manager.storeUserInfo(userInfo)
|
||||
setUser(userInfo)
|
||||
} else {
|
||||
setUser(null)
|
||||
}
|
||||
setIsAuthenticated(true)
|
||||
setIsLoading(false)
|
||||
popup.close()
|
||||
} else if (event.data.type === 'QWEN_OAUTH_ERROR') {
|
||||
setIsLoading(false)
|
||||
popup.close()
|
||||
popup?.close()
|
||||
return
|
||||
}
|
||||
|
||||
if (tokenData?.error === 'authorization_pending') {
|
||||
await new Promise((resolve) => setTimeout(resolve, pollInterval))
|
||||
continue
|
||||
}
|
||||
|
||||
if (tokenData?.error === 'slow_down') {
|
||||
pollInterval = Math.min(Math.ceil(pollInterval * 1.5), 10000)
|
||||
await new Promise((resolve) => setTimeout(resolve, pollInterval))
|
||||
continue
|
||||
}
|
||||
|
||||
throw new Error(tokenData?.error_description || tokenData?.error || 'OAuth failed')
|
||||
}
|
||||
|
||||
window.addEventListener('message', messageListener)
|
||||
|
||||
// Cleanup
|
||||
setTimeout(() => {
|
||||
clearInterval(checkClosed)
|
||||
window.removeEventListener('message', messageListener)
|
||||
setIsLoading(false)
|
||||
}, 300000) // 5 minute timeout
|
||||
throw new Error('OAuth timed out')
|
||||
|
||||
} catch (error) {
|
||||
setIsLoading(false)
|
||||
@@ -469,18 +459,15 @@ export function useQwenOAuth(config?: QwenConfig) {
|
||||
manager.signOut()
|
||||
setIsAuthenticated(false)
|
||||
setUser(null)
|
||||
}
|
||||
|
||||
const createApiClient = () => {
|
||||
return authManager().createApiClient()
|
||||
setTokenInfo(null)
|
||||
}
|
||||
|
||||
return {
|
||||
isAuthenticated: () => isAuthenticated(),
|
||||
user: () => user(),
|
||||
isLoading: () => isLoading(),
|
||||
tokenInfo: () => tokenInfo(),
|
||||
signIn,
|
||||
signOut,
|
||||
createApiClient
|
||||
signOut
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
225
packages/ui/src/lib/secrets-detector.ts
Normal file
225
packages/ui/src/lib/secrets-detector.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import { getLogger } from "./logger.js"
|
||||
|
||||
const log = getLogger("secrets-detector")
|
||||
|
||||
export interface SecretMatch {
|
||||
type: string
|
||||
value: string
|
||||
start: number
|
||||
end: number
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface RedactionResult {
|
||||
clean: string
|
||||
redactions: { path: string; reason: string }[]
|
||||
}
|
||||
|
||||
export interface SecretPattern {
|
||||
name: string
|
||||
pattern: RegExp
|
||||
reason: string
|
||||
}
|
||||
|
||||
const SECRET_PATTERNS: SecretPattern[] = [
|
||||
{
|
||||
name: "api_key",
|
||||
pattern: /['"]?api[_-]?key['"]?\s*[:=]\s*['"]?([a-zA-Z0-9_-]{20,})['"]?/gi,
|
||||
reason: "API key detected",
|
||||
},
|
||||
{
|
||||
name: "bearer_token",
|
||||
pattern: /bearer\s+([a-zA-Z0-9_-]{30,})/gi,
|
||||
reason: "Bearer token detected",
|
||||
},
|
||||
{
|
||||
name: "jwt_token",
|
||||
pattern: /eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+/g,
|
||||
reason: "JWT token detected",
|
||||
},
|
||||
{
|
||||
name: "aws_access_key",
|
||||
pattern: /AKIA[0-9A-Z]{16}/g,
|
||||
reason: "AWS access key detected",
|
||||
},
|
||||
{
|
||||
name: "aws_secret_key",
|
||||
pattern: /['"]?aws[_-]?secret[_-]?access[_-]?key['"]?\s*[:=]\s*['"]?([a-zA-Z0-9/+]{40})['"]?/gi,
|
||||
reason: "AWS secret key detected",
|
||||
},
|
||||
{
|
||||
name: "private_key",
|
||||
pattern: /-----BEGIN\s+(RSA\s+)?PRIVATE\s+KEY-----[\s\S]*?-----END\s+(RSA\s+)?PRIVATE\s+KEY-----/gi,
|
||||
reason: "Private key detected",
|
||||
},
|
||||
{
|
||||
name: "password",
|
||||
pattern: /['"]?(password|passwd|pwd)['"]?\s*[:=]\s*['"]?([^'\s"]{8,})['"]?/gi,
|
||||
reason: "Password field detected",
|
||||
},
|
||||
{
|
||||
name: "secret",
|
||||
pattern: /['"]?(secret|api[_-]?secret)['"]?\s*[:=]\s*['"]?([a-zA-Z0-9_-]{16,})['"]?/gi,
|
||||
reason: "Secret field detected",
|
||||
},
|
||||
{
|
||||
name: "token",
|
||||
pattern: /['"]?(token|access[_-]?token|auth[_-]?token)['"]?\s*[:=]\s*['"]?([a-zA-Z0-9_-]{30,})['"]?/gi,
|
||||
reason: "Auth token detected",
|
||||
},
|
||||
{
|
||||
name: "github_token",
|
||||
pattern: /gh[pous]_[a-zA-Z0-9]{36}/g,
|
||||
reason: "GitHub token detected",
|
||||
},
|
||||
{
|
||||
name: "openai_key",
|
||||
pattern: /sk-[a-zA-Z0-9]{48}/g,
|
||||
reason: "OpenAI API key detected",
|
||||
},
|
||||
{
|
||||
name: "database_url",
|
||||
pattern: /(mongodb|postgres|mysql|redis):\/\/[^\s'"]+/gi,
|
||||
reason: "Database connection URL detected",
|
||||
},
|
||||
{
|
||||
name: "credit_card",
|
||||
pattern: /\b(?:\d[ -]*?){13,16}\b/g,
|
||||
reason: "Potential credit card number detected",
|
||||
},
|
||||
{
|
||||
name: "email",
|
||||
pattern: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g,
|
||||
reason: "Email address detected",
|
||||
},
|
||||
{
|
||||
name: "ip_address",
|
||||
pattern: /\b(?:\d{1,3}\.){3}\d{1,3}\b/g,
|
||||
reason: "IP address detected",
|
||||
},
|
||||
]
|
||||
|
||||
const REPLACEMENT_PLACEHOLDER = "[REDACTED]"
|
||||
|
||||
function detectSecrets(content: string): SecretMatch[] {
|
||||
const matches: SecretMatch[] = []
|
||||
|
||||
for (const pattern of SECRET_PATTERNS) {
|
||||
let match
|
||||
const regex = new RegExp(pattern.pattern.source, pattern.pattern.flags)
|
||||
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
matches.push({
|
||||
type: pattern.name,
|
||||
value: match[0],
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
reason: pattern.reason,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return matches.sort((a, b) => a.start - b.start)
|
||||
}
|
||||
|
||||
function mergeOverlappingMatches(matches: SecretMatch[]): SecretMatch[] {
|
||||
if (matches.length === 0) return []
|
||||
|
||||
const merged: SecretMatch[] = [matches[0]]
|
||||
|
||||
for (let i = 1; i < matches.length; i++) {
|
||||
const current = matches[i]
|
||||
const last = merged[merged.length - 1]
|
||||
|
||||
if (current.start <= last.end) {
|
||||
last.end = Math.max(last.end, current.end)
|
||||
if (!last.reason.includes(current.reason)) {
|
||||
last.reason += ` | ${current.reason}`
|
||||
}
|
||||
} else {
|
||||
merged.push(current)
|
||||
}
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
export function redactSecrets(content: string, contextPath: string = "unknown"): RedactionResult {
|
||||
if (!content || typeof content !== "string") {
|
||||
return { clean: content, redactions: [] }
|
||||
}
|
||||
|
||||
const rawMatches = detectSecrets(content)
|
||||
const mergedMatches = mergeOverlappingMatches(rawMatches)
|
||||
|
||||
if (mergedMatches.length === 0) {
|
||||
return { clean: content, redactions: [] }
|
||||
}
|
||||
|
||||
let result = ""
|
||||
let lastIndex = 0
|
||||
const redactions: { path: string; reason: string }[] = []
|
||||
|
||||
for (const match of mergedMatches) {
|
||||
result += content.slice(lastIndex, match.start)
|
||||
result += REPLACEMENT_PLACEHOLDER
|
||||
lastIndex = match.end
|
||||
|
||||
redactions.push({
|
||||
path: `${contextPath}[${match.start}:${match.end}]`,
|
||||
reason: match.reason,
|
||||
})
|
||||
}
|
||||
|
||||
result += content.slice(lastIndex)
|
||||
|
||||
log.info("Redacted secrets", { contextPath, count: redactions.length, types: mergedMatches.map((m) => m.type) })
|
||||
|
||||
return { clean: result, redactions }
|
||||
}
|
||||
|
||||
export function hasSecrets(content: string): boolean {
|
||||
if (!content || typeof content !== "string") {
|
||||
return false
|
||||
}
|
||||
|
||||
return SECRET_PATTERNS.some((pattern) => pattern.pattern.test(content))
|
||||
}
|
||||
|
||||
export function redactObject(obj: any, contextPath: string = "root"): any {
|
||||
if (obj === null || obj === undefined) {
|
||||
return obj
|
||||
}
|
||||
|
||||
if (typeof obj === "string") {
|
||||
const result = redactSecrets(obj, contextPath)
|
||||
return result.clean
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map((item, index) => redactObject(item, `${contextPath}[${index}]`))
|
||||
}
|
||||
|
||||
if (typeof obj === "object") {
|
||||
const result: any = {}
|
||||
for (const key in obj) {
|
||||
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
||||
result[key] = redactObject(obj[key], `${contextPath}.${key}`)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
export function getSecretsReport(content: string): { total: number; byType: Record<string, number> } {
|
||||
const matches = detectSecrets(content)
|
||||
const byType: Record<string, number> = {}
|
||||
|
||||
for (const match of matches) {
|
||||
byType[match.type] = (byType[match.type] || 0) + 1
|
||||
}
|
||||
|
||||
return { total: matches.length, byType }
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
export type SessionSidebarRequestAction = "focus-agent-selector" | "focus-model-selector" | "show-session-list"
|
||||
export type SessionSidebarRequestAction =
|
||||
| "focus-agent-selector"
|
||||
| "focus-model-selector"
|
||||
| "show-session-list"
|
||||
| "show-skills"
|
||||
|
||||
export interface SessionSidebarRequestDetail {
|
||||
instanceId: string
|
||||
|
||||
7
packages/ui/src/lib/user-storage.ts
Normal file
7
packages/ui/src/lib/user-storage.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { activeUser } from "../stores/users"
|
||||
|
||||
export function getUserScopedKey(baseKey: string): string {
|
||||
const userId = activeUser()?.id
|
||||
if (!userId) return baseKey
|
||||
return `${baseKey}:${userId}`
|
||||
}
|
||||
@@ -1,32 +1,36 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CodeNomad</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>NomadArch</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
background-color: #1a1a1a;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
; (function () {
|
||||
try {
|
||||
document.documentElement.setAttribute('data-theme', 'dark')
|
||||
} catch (error) {
|
||||
const rawConsole = globalThis?.["console"]
|
||||
rawConsole?.warn?.('Failed to apply initial theme', error)
|
||||
}
|
||||
html,
|
||||
body {
|
||||
background-color: #1a1a1a;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
;(function () {
|
||||
try {
|
||||
document.documentElement.setAttribute('data-theme', 'dark')
|
||||
} catch (error) {
|
||||
const rawConsole = globalThis?.["console"]
|
||||
rawConsole?.warn?.('Failed to apply initial theme', error)
|
||||
}
|
||||
})()
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
})()
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -29,12 +29,14 @@ button {
|
||||
max-width: 520px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
animation: fadeIn 0.4s ease-out;
|
||||
}
|
||||
|
||||
.loading-logo {
|
||||
width: 180px;
|
||||
height: auto;
|
||||
filter: drop-shadow(0 20px 60px rgba(0, 0, 0, 0.45));
|
||||
animation: logoPulse 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.loading-heading {
|
||||
@@ -54,6 +56,7 @@ button {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
color: var(--text-muted, #aeb3c4);
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.loading-card {
|
||||
@@ -64,7 +67,13 @@ button {
|
||||
border-radius: 18px;
|
||||
background: rgba(13, 16, 24, 0.85);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.55);
|
||||
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(108, 227, 255, 0.05);
|
||||
transition: border-color 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.loading-card:hover {
|
||||
border-color: rgba(108, 227, 255, 0.15);
|
||||
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(108, 227, 255, 0.1);
|
||||
}
|
||||
|
||||
.loading-row {
|
||||
@@ -81,7 +90,8 @@ button {
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(255, 255, 255, 0.18);
|
||||
border-top-color: #6ce3ff;
|
||||
animation: spin 0.9s linear infinite;
|
||||
animation: spin 0.9s cubic-bezier(0.5, 0, 0.5, 1) infinite;
|
||||
box-shadow: 0 0 10px rgba(108, 227, 255, 0.3);
|
||||
}
|
||||
|
||||
.phrase-controls {
|
||||
@@ -93,12 +103,29 @@ button {
|
||||
.phrase-controls button {
|
||||
color: #8fb5ff;
|
||||
cursor: pointer;
|
||||
padding: 4px 12px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.phrase-controls button:hover {
|
||||
background: rgba(143, 181, 255, 0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.phrase-controls button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.loading-error {
|
||||
margin-top: 12px;
|
||||
padding: 12px 16px;
|
||||
background: rgba(255, 94, 109, 0.1);
|
||||
border: 1px solid rgba(255, 94, 109, 0.2);
|
||||
border-radius: 8px;
|
||||
color: #ff9ea9;
|
||||
font-size: 0.95rem;
|
||||
font-size: 0.9rem;
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
@@ -109,3 +136,23 @@ button {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes logoPulse {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Show, createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { render } from "solid-js/web"
|
||||
import iconUrl from "../../images/CodeNomad-Icon.png"
|
||||
import iconUrl from "../../images/NomadArch-Icon.png"
|
||||
import { runtimeEnv, isTauriHost } from "../../lib/runtime-env"
|
||||
import "../../index.css"
|
||||
import "./loading.css"
|
||||
@@ -202,7 +202,7 @@ function LoadingApp() {
|
||||
<img src={iconUrl} alt="NomadArch" class="loading-logo" width="180" height="180" />
|
||||
<div class="loading-heading">
|
||||
<h1 class="loading-title">NomadArch 1.0</h1>
|
||||
<p class="loading-subtitle" style={{ fontSize: '14px', color: '#666', marginTop: '4px' }}>A fork of OpenCode</p>
|
||||
<p class="loading-subtitle" style={{ "font-size": '14px', "color": '#666', "margin-top": '4px' }}>A fork of OpenCode</p>
|
||||
<Show when={status()}>{(statusText) => <p class="loading-status">{statusText()}</p>}</Show>
|
||||
</div>
|
||||
<div class="loading-card">
|
||||
|
||||
273
packages/ui/src/stores/__tests__/session-compaction.test.ts
Normal file
273
packages/ui/src/stores/__tests__/session-compaction.test.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { beforeEach, describe, it, mock } from "node:test"
|
||||
import type { CompactionResult } from "../session-compaction.js"
|
||||
import {
|
||||
getCompactionConfig,
|
||||
updateCompactionConfig,
|
||||
undoCompaction,
|
||||
rehydrateSession,
|
||||
checkAndTriggerAutoCompact,
|
||||
setSessionCompactionState,
|
||||
getSessionCompactionState,
|
||||
estimateTokenReduction,
|
||||
executeCompactionWrapper,
|
||||
} from "../session-compaction.js"
|
||||
import type { CompactionEvent, StructuredSummary } from "../../lib/compaction-schema.js"
|
||||
|
||||
const MOCK_INSTANCE_ID = "test-instance-123"
|
||||
const MOCK_SESSION_ID = "test-session-456"
|
||||
const MOCK_MESSAGE_ID = "msg-789"
|
||||
|
||||
function createMockMessage(id: string, content: string = "Test message"): any {
|
||||
return {
|
||||
id,
|
||||
sessionId: MOCK_SESSION_ID,
|
||||
role: "user",
|
||||
content,
|
||||
status: "complete",
|
||||
parts: [{ id: `part-${id}`, type: "text", text: content, sessionID: MOCK_SESSION_ID, messageID: id }],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
function createMockUsage(tokens: number = 10000): any {
|
||||
return {
|
||||
totalInputTokens: Math.floor(tokens * 0.7),
|
||||
totalOutputTokens: Math.floor(tokens * 0.2),
|
||||
totalReasoningTokens: Math.floor(tokens * 0.1),
|
||||
}
|
||||
}
|
||||
|
||||
describe("session compaction", () => {
|
||||
beforeEach(() => {
|
||||
updateCompactionConfig({
|
||||
autoCompactEnabled: true,
|
||||
autoCompactThreshold: 90,
|
||||
compactPreserveWindow: 5000,
|
||||
pruneReclaimThreshold: 10000,
|
||||
userPreference: "auto",
|
||||
undoRetentionWindow: 5,
|
||||
})
|
||||
})
|
||||
|
||||
describe("getCompactionConfig", () => {
|
||||
it("returns default config", () => {
|
||||
const config = getCompactionConfig()
|
||||
assert.equal(typeof config.autoCompactEnabled, "boolean")
|
||||
assert.equal(typeof config.autoCompactThreshold, "number")
|
||||
assert.equal(typeof config.compactPreserveWindow, "number")
|
||||
assert.equal(typeof config.pruneReclaimThreshold, "number")
|
||||
assert.equal(typeof config.userPreference, "string")
|
||||
assert.equal(typeof config.undoRetentionWindow, "number")
|
||||
})
|
||||
|
||||
it("allows config updates", () => {
|
||||
updateCompactionConfig({
|
||||
autoCompactEnabled: false,
|
||||
autoCompactThreshold: 80,
|
||||
compactPreserveWindow: 4000,
|
||||
pruneReclaimThreshold: 8000,
|
||||
userPreference: "ask",
|
||||
undoRetentionWindow: 10,
|
||||
})
|
||||
const config = getCompactionConfig()
|
||||
assert.equal(config.autoCompactEnabled, false)
|
||||
assert.equal(config.autoCompactThreshold, 80)
|
||||
assert.equal(config.userPreference, "ask")
|
||||
assert.equal(config.undoRetentionWindow, 10)
|
||||
})
|
||||
})
|
||||
|
||||
describe("setSessionCompactionState and getSessionCompactionState", () => {
|
||||
it("tracks compaction state for sessions", () => {
|
||||
setSessionCompactionState(MOCK_INSTANCE_ID, MOCK_SESSION_ID, true)
|
||||
const isCompacting = getSessionCompactionState(MOCK_INSTANCE_ID, MOCK_SESSION_ID)
|
||||
assert.ok(isCompacting)
|
||||
})
|
||||
|
||||
it("returns undefined for unknown sessions", () => {
|
||||
const isCompacting = getSessionCompactionState("unknown-instance", "unknown-session")
|
||||
assert.equal(isCompacting, undefined)
|
||||
})
|
||||
|
||||
it("clears compaction state", () => {
|
||||
setSessionCompactionState(MOCK_INSTANCE_ID, MOCK_SESSION_ID, true)
|
||||
setSessionCompactionState(MOCK_INSTANCE_ID, MOCK_SESSION_ID, false)
|
||||
const isCompacting = getSessionCompactionState(MOCK_INSTANCE_ID, MOCK_SESSION_ID)
|
||||
assert.ok(!isCompacting)
|
||||
})
|
||||
})
|
||||
|
||||
describe("estimateTokenReduction", () => {
|
||||
it("calculates correct percentage reduction", () => {
|
||||
const reduction = estimateTokenReduction(10000, 3000)
|
||||
assert.equal(reduction, 70)
|
||||
})
|
||||
|
||||
it("returns 0 when no reduction", () => {
|
||||
const reduction = estimateTokenReduction(10000, 10000)
|
||||
assert.equal(reduction, 0)
|
||||
})
|
||||
|
||||
it("handles zero tokens", () => {
|
||||
const reduction = estimateTokenReduction(0, 0)
|
||||
assert.equal(reduction, 0)
|
||||
})
|
||||
|
||||
it("caps at 100%", () => {
|
||||
const reduction = estimateTokenReduction(10000, -5000)
|
||||
assert.equal(reduction, 100)
|
||||
})
|
||||
|
||||
it("handles small values", () => {
|
||||
const reduction = estimateTokenReduction(100, 50)
|
||||
assert.equal(reduction, 50)
|
||||
})
|
||||
})
|
||||
|
||||
describe("executeCompactionWrapper", () => {
|
||||
it("compacts session successfully", async () => {
|
||||
const mockStore = {
|
||||
getSessionMessageIds: () => [MOCK_MESSAGE_ID],
|
||||
getSessionUsage: () => createMockUsage(10000),
|
||||
getMessage: (id: string) => createMockMessage(id, "Test content"),
|
||||
upsertMessage: () => {},
|
||||
setMessageInfo: () => {},
|
||||
}
|
||||
|
||||
const getInstanceMock = mock.fn(() => mockStore)
|
||||
const originalBus = (globalThis as any).messageStoreBus
|
||||
;(globalThis as any).messageStoreBus = { getInstance: getInstanceMock }
|
||||
|
||||
const result = await executeCompactionWrapper(MOCK_INSTANCE_ID, MOCK_SESSION_ID, "compact")
|
||||
|
||||
assert.ok(result.success)
|
||||
assert.equal(result.mode, "compact")
|
||||
assert.ok(result.token_before > 0)
|
||||
assert.ok(result.token_after >= 0)
|
||||
assert.ok(result.token_reduction_pct >= 0)
|
||||
assert.ok(result.human_summary.length > 0)
|
||||
|
||||
getInstanceMock.mock.restore()
|
||||
if (originalBus) {
|
||||
;(globalThis as any).messageStoreBus = originalBus
|
||||
} else {
|
||||
delete (globalThis as any).messageStoreBus
|
||||
}
|
||||
})
|
||||
|
||||
it("handles missing instance", async () => {
|
||||
const getInstanceMock = mock.fn(() => null)
|
||||
const originalBus = (globalThis as any).messageStoreBus
|
||||
;(globalThis as any).messageStoreBus = { getInstance: getInstanceMock }
|
||||
|
||||
const result = await executeCompactionWrapper(MOCK_INSTANCE_ID, MOCK_SESSION_ID, "compact")
|
||||
|
||||
assert.ok(!result.success)
|
||||
assert.equal(result.human_summary, "Instance not found")
|
||||
|
||||
getInstanceMock.mock.restore()
|
||||
if (originalBus) {
|
||||
;(globalThis as any).messageStoreBus = originalBus
|
||||
} else {
|
||||
delete (globalThis as any).messageStoreBus
|
||||
}
|
||||
})
|
||||
|
||||
it("handles prune mode", async () => {
|
||||
const mockStore = {
|
||||
getSessionMessageIds: () => [MOCK_MESSAGE_ID],
|
||||
getSessionUsage: () => createMockUsage(10000),
|
||||
getMessage: (id: string) => createMockMessage(id, "Test content"),
|
||||
upsertMessage: () => {},
|
||||
setMessageInfo: () => {},
|
||||
}
|
||||
|
||||
const getInstanceMock = mock.fn(() => mockStore)
|
||||
const originalBus = (globalThis as any).messageStoreBus
|
||||
;(globalThis as any).messageStoreBus = { getInstance: getInstanceMock }
|
||||
|
||||
const result = await executeCompactionWrapper(MOCK_INSTANCE_ID, MOCK_SESSION_ID, "prune")
|
||||
|
||||
assert.ok(result.success)
|
||||
assert.equal(result.mode, "prune")
|
||||
|
||||
getInstanceMock.mock.restore()
|
||||
if (originalBus) {
|
||||
;(globalThis as any).messageStoreBus = originalBus
|
||||
} else {
|
||||
delete (globalThis as any).messageStoreBus
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("checkAndTriggerAutoCompact", () => {
|
||||
it("does not trigger when user preference is never", async () => {
|
||||
updateCompactionConfig({
|
||||
autoCompactEnabled: true,
|
||||
autoCompactThreshold: 90,
|
||||
compactPreserveWindow: 5000,
|
||||
pruneReclaimThreshold: 10000,
|
||||
userPreference: "never",
|
||||
undoRetentionWindow: 5,
|
||||
})
|
||||
|
||||
const shouldCompact = await checkAndTriggerAutoCompact(MOCK_INSTANCE_ID, MOCK_SESSION_ID)
|
||||
assert.ok(!shouldCompact)
|
||||
})
|
||||
|
||||
it("returns false when no overflow", async () => {
|
||||
const mockStore = {
|
||||
getSessionUsage: () => createMockUsage(50000),
|
||||
}
|
||||
|
||||
const getInstanceMock = mock.fn(() => mockStore)
|
||||
const originalBus = (globalThis as any).messageStoreBus
|
||||
;(globalThis as any).messageStoreBus = { getInstance: getInstanceMock }
|
||||
|
||||
const shouldCompact = await checkAndTriggerAutoCompact(MOCK_INSTANCE_ID, MOCK_SESSION_ID)
|
||||
assert.ok(!shouldCompact)
|
||||
|
||||
getInstanceMock.mock.restore()
|
||||
if (originalBus) {
|
||||
;(globalThis as any).messageStoreBus = originalBus
|
||||
} else {
|
||||
delete (globalThis as any).messageStoreBus
|
||||
}
|
||||
})
|
||||
|
||||
it("triggers auto-compact when enabled", async () => {
|
||||
updateCompactionConfig({
|
||||
autoCompactEnabled: true,
|
||||
autoCompactThreshold: 90,
|
||||
compactPreserveWindow: 5000,
|
||||
pruneReclaimThreshold: 10000,
|
||||
userPreference: "auto",
|
||||
undoRetentionWindow: 5,
|
||||
})
|
||||
|
||||
const mockStore = {
|
||||
getSessionUsage: () => createMockUsage(120000),
|
||||
getSessionMessageIds: () => [MOCK_MESSAGE_ID],
|
||||
getMessage: (id: string) => createMockMessage(id, "Test content"),
|
||||
upsertMessage: () => {},
|
||||
setMessageInfo: () => {},
|
||||
}
|
||||
|
||||
const getInstanceMock = mock.fn(() => mockStore)
|
||||
const originalBus = (globalThis as any).messageStoreBus
|
||||
;(globalThis as any).messageStoreBus = { getInstance: getInstanceMock }
|
||||
|
||||
const shouldCompact = await checkAndTriggerAutoCompact(MOCK_INSTANCE_ID, MOCK_SESSION_ID)
|
||||
assert.ok(shouldCompact)
|
||||
|
||||
getInstanceMock.mock.restore()
|
||||
if (originalBus) {
|
||||
;(globalThis as any).messageStoreBus = originalBus
|
||||
} else {
|
||||
delete (globalThis as any).messageStoreBus
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -10,6 +10,7 @@ const DEFAULT_INSTANCE_DATA: InstanceData = {
|
||||
agentModelSelections: {},
|
||||
sessionTasks: {},
|
||||
sessionSkills: {},
|
||||
customAgents: [],
|
||||
}
|
||||
|
||||
const [instanceDataMap, setInstanceDataMap] = createSignal<Map<string, InstanceData>>(new Map())
|
||||
@@ -24,6 +25,7 @@ function cloneInstanceData(data?: InstanceData | null): InstanceData {
|
||||
agentModelSelections: { ...(source.agentModelSelections ?? {}) },
|
||||
sessionTasks: { ...(source.sessionTasks ?? {}) },
|
||||
sessionSkills: { ...(source.sessionSkills ?? {}) },
|
||||
customAgents: Array.isArray(source.customAgents) ? [...source.customAgents] : [],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -87,3 +87,20 @@ class MessageStoreBus {
|
||||
}
|
||||
|
||||
export const messageStoreBus = new MessageStoreBus()
|
||||
|
||||
export async function archiveMessages(instanceId: string, sessionId: string, keepLastN: number = 2): Promise<void> {
|
||||
const store = messageStoreBus.getInstance(instanceId)
|
||||
if (!store) return
|
||||
|
||||
const messageIds = store.getSessionMessageIds(sessionId)
|
||||
if (messageIds.length <= keepLastN) return
|
||||
|
||||
const messagesToArchive = messageIds.slice(0, -keepLastN)
|
||||
const archiveId = `archived_${sessionId}_${Date.now()}`
|
||||
|
||||
for (const messageId of messagesToArchive) {
|
||||
store.setMessageInfo(messageId, { archived: true } as any)
|
||||
}
|
||||
|
||||
log.info("Archived messages", { instanceId, sessionId, count: messagesToArchive.length, archiveId })
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ function ensureVisibilityEffect() {
|
||||
if (!activeToast || activeToastVersion !== release.version) {
|
||||
dismissActiveToast()
|
||||
activeToast = showToastNotification({
|
||||
title: `CodeNomad ${release.version}`,
|
||||
title: `NomadArch ${release.version}`,
|
||||
message: release.channel === "dev" ? "Dev release build available." : "New stable build on GitHub.",
|
||||
variant: "info",
|
||||
duration: Number.POSITIVE_INFINITY,
|
||||
|
||||
@@ -9,12 +9,20 @@ import { updateSessionInfo } from "./message-v2/session-info"
|
||||
import { messageStoreBus } from "./message-v2/bus"
|
||||
import { buildRecordDisplayData } from "./message-v2/record-display-cache"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { executeCompactionWrapper, getSessionCompactionState, setSessionCompactionState, type CompactionResult } from "./session-compaction"
|
||||
import {
|
||||
executeCompactionWrapper,
|
||||
getSessionCompactionState,
|
||||
setSessionCompactionState,
|
||||
setCompactionSuggestion,
|
||||
clearCompactionSuggestion,
|
||||
type CompactionResult,
|
||||
} from "./session-compaction"
|
||||
import { createSession, loadMessages } from "./session-api"
|
||||
import { showToastNotification } from "../lib/notifications"
|
||||
import { showConfirmDialog } from "./alerts"
|
||||
import { QwenOAuthManager } from "../lib/integrations/qwen-oauth"
|
||||
import { getUserScopedKey } from "../lib/user-storage"
|
||||
import { loadSkillDetails } from "./skills"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
@@ -28,16 +36,18 @@ const COMPACTION_ATTEMPT_TTL_MS = 60_000
|
||||
const COMPACTION_SUMMARY_MAX_CHARS = 4000
|
||||
const STREAM_TIMEOUT_MS = 120_000
|
||||
const OPENCODE_ZEN_OFFLINE_STORAGE_KEY = "opencode-zen-offline-models"
|
||||
const BUILD_PREVIEW_EVENT = "opencode:build-preview"
|
||||
|
||||
function markOpencodeZenModelOffline(modelId: string): void {
|
||||
if (typeof window === "undefined" || !modelId) return
|
||||
try {
|
||||
const raw = window.localStorage.getItem(OPENCODE_ZEN_OFFLINE_STORAGE_KEY)
|
||||
const key = getUserScopedKey(OPENCODE_ZEN_OFFLINE_STORAGE_KEY)
|
||||
const raw = window.localStorage.getItem(key)
|
||||
const parsed = raw ? JSON.parse(raw) : []
|
||||
const list = Array.isArray(parsed) ? parsed : []
|
||||
if (!list.includes(modelId)) {
|
||||
list.push(modelId)
|
||||
window.localStorage.setItem(OPENCODE_ZEN_OFFLINE_STORAGE_KEY, JSON.stringify(list))
|
||||
window.localStorage.setItem(key, JSON.stringify(list))
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("opencode-zen-offline-models", { detail: { modelId } }),
|
||||
)
|
||||
@@ -209,21 +219,11 @@ async function checkTokenBudgetBeforeSend(
|
||||
warningThreshold,
|
||||
})
|
||||
|
||||
const confirmed = await showConfirmDialog(
|
||||
`Context limit approaching (${currentContextUsage.toLocaleString()} / ${contextLimit.toLocaleString()} tokens). Compact now to continue?`,
|
||||
{
|
||||
title: "Token Budget Warning",
|
||||
confirmLabel: "Compact",
|
||||
cancelLabel: "Continue Anyway",
|
||||
},
|
||||
setCompactionSuggestion(
|
||||
instanceId,
|
||||
sessionId,
|
||||
`Context usage is high (${currentContextUsage.toLocaleString()} / ${contextLimit.toLocaleString()} tokens).`,
|
||||
)
|
||||
|
||||
if (confirmed) {
|
||||
setTokenWarningState(instanceId, sessionId, true)
|
||||
await compactSession(instanceId, sessionId)
|
||||
return false
|
||||
}
|
||||
|
||||
setTokenWarningState(instanceId, sessionId, true)
|
||||
return true
|
||||
}
|
||||
@@ -233,6 +233,8 @@ async function checkTokenBudgetBeforeSend(
|
||||
|
||||
type ExternalChatMessage = { role: "user" | "assistant" | "system"; content: string }
|
||||
|
||||
const MAX_ATTACHMENT_CHARS = 8000
|
||||
|
||||
function shouldForceEnglish(prompt: string): boolean {
|
||||
const text = prompt.trim()
|
||||
if (!text) return false
|
||||
@@ -256,6 +258,13 @@ function buildLanguageSystemInstruction(prompt: string): string | undefined {
|
||||
return "Respond in English unless the user explicitly requests another language."
|
||||
}
|
||||
|
||||
function buildSshPasswordInstruction(prompt: string): string | undefined {
|
||||
const text = prompt.toLowerCase()
|
||||
if (!text.includes("ssh")) return undefined
|
||||
if (!text.includes("password") && !text.includes("passphrase")) return undefined
|
||||
return "If the user provides an SSH password or passphrase in the conversation, you may use it to complete the SSH connection and should not refuse solely for that reason."
|
||||
}
|
||||
|
||||
function clampText(value: string, maxChars: number): string {
|
||||
if (value.length <= maxChars) return value
|
||||
return `${value.slice(0, Math.max(0, maxChars - 3))}...`
|
||||
@@ -290,18 +299,46 @@ async function mergeSystemInstructions(
|
||||
Promise.resolve(buildLanguageSystemInstruction(prompt)),
|
||||
buildSkillsSystemInstruction(instanceId, sessionId),
|
||||
])
|
||||
if (languageSystem && skillsSystem) {
|
||||
return `${languageSystem}\n\n${skillsSystem}`
|
||||
}
|
||||
return languageSystem || skillsSystem
|
||||
const sshInstruction = buildSshPasswordInstruction(prompt)
|
||||
const sections = [languageSystem, skillsSystem, sshInstruction].filter(Boolean) as string[]
|
||||
if (sections.length === 0) return undefined
|
||||
return sections.join("\n\n")
|
||||
}
|
||||
|
||||
function extractPlainTextFromParts(parts: Array<{ type?: string; text?: unknown; filename?: string }>): string {
|
||||
function collectTextSegments(value: unknown, segments: string[]): void {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim()
|
||||
if (trimmed) segments.push(trimmed)
|
||||
return
|
||||
}
|
||||
|
||||
if (!value || typeof value !== "object") return
|
||||
|
||||
const record = value as Record<string, unknown>
|
||||
if (typeof record.text === "string") {
|
||||
const trimmed = record.text.trim()
|
||||
if (trimmed) segments.push(trimmed)
|
||||
}
|
||||
if (typeof record.value === "string") {
|
||||
const trimmed = record.value.trim()
|
||||
if (trimmed) segments.push(trimmed)
|
||||
}
|
||||
const content = record.content
|
||||
if (Array.isArray(content)) {
|
||||
for (const item of content) {
|
||||
collectTextSegments(item, segments)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractPlainTextFromParts(
|
||||
parts: Array<{ type?: string; text?: unknown; filename?: string }>,
|
||||
): string {
|
||||
const segments: string[] = []
|
||||
for (const part of parts) {
|
||||
if (!part || typeof part !== "object") continue
|
||||
if (part.type === "text" && typeof part.text === "string") {
|
||||
segments.push(part.text)
|
||||
if (part.type === "text" || part.type === "reasoning") {
|
||||
collectTextSegments(part.text, segments)
|
||||
} else if (part.type === "file" && typeof part.filename === "string") {
|
||||
segments.push(`[file: ${part.filename}]`)
|
||||
}
|
||||
@@ -337,6 +374,62 @@ function buildExternalChatMessages(
|
||||
return messages
|
||||
}
|
||||
|
||||
function decodeAttachmentData(data: Uint8Array): string {
|
||||
const decoder = new TextDecoder()
|
||||
return decoder.decode(data)
|
||||
}
|
||||
|
||||
function isTextLikeMime(mime?: string): boolean {
|
||||
if (!mime) return false
|
||||
if (mime.startsWith("text/")) return true
|
||||
return ["application/json", "application/xml", "application/x-yaml"].includes(mime)
|
||||
}
|
||||
|
||||
async function buildExternalChatMessagesWithAttachments(
|
||||
instanceId: string,
|
||||
sessionId: string,
|
||||
systemMessage: string | undefined,
|
||||
attachments: Array<{ filename?: string; source?: any; mediaType?: string }>,
|
||||
): Promise<ExternalChatMessage[]> {
|
||||
const baseMessages = buildExternalChatMessages(instanceId, sessionId, systemMessage)
|
||||
if (!attachments || attachments.length === 0) {
|
||||
return baseMessages
|
||||
}
|
||||
|
||||
const attachmentMessages: ExternalChatMessage[] = []
|
||||
|
||||
for (const attachment of attachments) {
|
||||
const source = attachment?.source
|
||||
if (!source || typeof source !== "object") continue
|
||||
|
||||
let content: string | null = null
|
||||
if (source.type === "text" && typeof source.value === "string") {
|
||||
content = source.value
|
||||
} else if (source.type === "file") {
|
||||
if (source.data instanceof Uint8Array && isTextLikeMime(source.mime || attachment.mediaType)) {
|
||||
content = decodeAttachmentData(source.data)
|
||||
} else if (typeof source.path === "string" && source.path.length > 0) {
|
||||
try {
|
||||
const response = await serverApi.readWorkspaceFile(instanceId, source.path)
|
||||
content = typeof response.contents === "string" ? response.contents : null
|
||||
} catch {
|
||||
content = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!content) continue
|
||||
const filename = attachment.filename || source.path || "attachment"
|
||||
const trimmed = clampText(content, MAX_ATTACHMENT_CHARS)
|
||||
attachmentMessages.push({
|
||||
role: "user",
|
||||
content: `Attachment: ${filename}\n\n${trimmed}`,
|
||||
})
|
||||
}
|
||||
|
||||
return [...baseMessages, ...attachmentMessages]
|
||||
}
|
||||
|
||||
async function readSseStream(
|
||||
response: Response,
|
||||
onData: (data: string) => void,
|
||||
@@ -396,7 +489,7 @@ async function streamOllamaChat(
|
||||
sessionId: string,
|
||||
providerId: string,
|
||||
modelId: string,
|
||||
systemMessage: string | undefined,
|
||||
messages: ExternalChatMessage[],
|
||||
messageId: string,
|
||||
assistantMessageId: string,
|
||||
assistantPartId: string,
|
||||
@@ -410,7 +503,7 @@ async function streamOllamaChat(
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify({
|
||||
model: modelId,
|
||||
messages: buildExternalChatMessages(instanceId, sessionId, systemMessage),
|
||||
messages,
|
||||
stream: true,
|
||||
}),
|
||||
})
|
||||
@@ -477,7 +570,7 @@ async function streamQwenChat(
|
||||
sessionId: string,
|
||||
providerId: string,
|
||||
modelId: string,
|
||||
systemMessage: string | undefined,
|
||||
messages: ExternalChatMessage[],
|
||||
accessToken: string,
|
||||
resourceUrl: string | undefined,
|
||||
messageId: string,
|
||||
@@ -496,7 +589,7 @@ async function streamQwenChat(
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify({
|
||||
model: modelId,
|
||||
messages: buildExternalChatMessages(instanceId, sessionId, systemMessage),
|
||||
messages,
|
||||
stream: true,
|
||||
resource_url: resourceUrl,
|
||||
}),
|
||||
@@ -561,7 +654,7 @@ async function streamOpenCodeZenChat(
|
||||
sessionId: string,
|
||||
providerId: string,
|
||||
modelId: string,
|
||||
systemMessage: string | undefined,
|
||||
messages: ExternalChatMessage[],
|
||||
messageId: string,
|
||||
assistantMessageId: string,
|
||||
assistantPartId: string,
|
||||
@@ -575,7 +668,7 @@ async function streamOpenCodeZenChat(
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify({
|
||||
model: modelId,
|
||||
messages: buildExternalChatMessages(instanceId, sessionId, systemMessage),
|
||||
messages,
|
||||
stream: true,
|
||||
}),
|
||||
})
|
||||
@@ -645,7 +738,7 @@ async function streamZAIChat(
|
||||
sessionId: string,
|
||||
providerId: string,
|
||||
modelId: string,
|
||||
systemMessage: string | undefined,
|
||||
messages: ExternalChatMessage[],
|
||||
messageId: string,
|
||||
assistantMessageId: string,
|
||||
assistantPartId: string,
|
||||
@@ -659,7 +752,7 @@ async function streamZAIChat(
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify({
|
||||
model: modelId,
|
||||
messages: buildExternalChatMessages(instanceId, sessionId, systemMessage),
|
||||
messages,
|
||||
stream: true,
|
||||
}),
|
||||
})
|
||||
@@ -868,6 +961,12 @@ async function sendMessage(
|
||||
const now = Date.now()
|
||||
const assistantMessageId = createId("msg")
|
||||
const assistantPartId = createId("part")
|
||||
const externalMessages = await buildExternalChatMessagesWithAttachments(
|
||||
instanceId,
|
||||
sessionId,
|
||||
systemMessage,
|
||||
attachments,
|
||||
)
|
||||
|
||||
store.upsertMessage({
|
||||
id: assistantMessageId,
|
||||
@@ -902,7 +1001,7 @@ async function sendMessage(
|
||||
sessionId,
|
||||
providerId,
|
||||
effectiveModel.modelId,
|
||||
systemMessage,
|
||||
externalMessages,
|
||||
messageId,
|
||||
assistantMessageId,
|
||||
assistantPartId,
|
||||
@@ -913,7 +1012,7 @@ async function sendMessage(
|
||||
sessionId,
|
||||
providerId,
|
||||
effectiveModel.modelId,
|
||||
systemMessage,
|
||||
externalMessages,
|
||||
messageId,
|
||||
assistantMessageId,
|
||||
assistantPartId,
|
||||
@@ -924,7 +1023,7 @@ async function sendMessage(
|
||||
sessionId,
|
||||
providerId,
|
||||
effectiveModel.modelId,
|
||||
systemMessage,
|
||||
externalMessages,
|
||||
messageId,
|
||||
assistantMessageId,
|
||||
assistantPartId,
|
||||
@@ -962,7 +1061,7 @@ async function sendMessage(
|
||||
sessionId,
|
||||
providerId,
|
||||
effectiveModel.modelId,
|
||||
systemMessage,
|
||||
externalMessages,
|
||||
token.access_token,
|
||||
token.resource_url,
|
||||
messageId,
|
||||
@@ -1151,12 +1250,29 @@ async function runShellCommand(instanceId: string, sessionId: string, command: s
|
||||
}
|
||||
|
||||
const agent = session.agent || "build"
|
||||
let resolvedCommand = command
|
||||
|
||||
if (command.trim() === "build") {
|
||||
try {
|
||||
const response = await serverApi.fetchAvailablePort()
|
||||
if (response?.port) {
|
||||
const isWindows = typeof navigator !== "undefined" && /windows/i.test(navigator.userAgent)
|
||||
resolvedCommand = isWindows ? `set PORT=${response.port}&& ${command}` : `PORT=${response.port} ${command}`
|
||||
if (typeof window !== "undefined") {
|
||||
const url = `http://localhost:${response.port}`
|
||||
window.dispatchEvent(new CustomEvent(BUILD_PREVIEW_EVENT, { detail: { url, instanceId } }))
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn("Failed to resolve available port for build", { error })
|
||||
}
|
||||
}
|
||||
|
||||
await instance.client.session.shell({
|
||||
path: { id: sessionId },
|
||||
body: {
|
||||
agent,
|
||||
command,
|
||||
command: resolvedCommand,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1310,6 +1426,7 @@ async function compactSession(instanceId: string, sessionId: string): Promise<Co
|
||||
})
|
||||
|
||||
log.info("compactSession: Complete", { instanceId, sessionId, compactedSessionId: compactedSession.id })
|
||||
clearCompactionSuggestion(instanceId, sessionId)
|
||||
return {
|
||||
...result,
|
||||
token_before: tokenBefore,
|
||||
@@ -1407,6 +1524,30 @@ async function updateSessionModel(
|
||||
updateSessionInfo(instanceId, sessionId)
|
||||
}
|
||||
|
||||
async function updateSessionModelForSession(
|
||||
instanceId: string,
|
||||
sessionId: string,
|
||||
model: { providerId: string; modelId: string },
|
||||
): Promise<void> {
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
const session = instanceSessions?.get(sessionId)
|
||||
if (!session) {
|
||||
throw new Error("Session not found")
|
||||
}
|
||||
|
||||
if (!isModelValid(instanceId, model)) {
|
||||
log.warn("Invalid model selection", model)
|
||||
return
|
||||
}
|
||||
|
||||
withSession(instanceId, sessionId, (current) => {
|
||||
current.model = model
|
||||
})
|
||||
|
||||
addRecentModelPreference(model)
|
||||
updateSessionInfo(instanceId, sessionId)
|
||||
}
|
||||
|
||||
async function renameSession(instanceId: string, sessionId: string, nextTitle: string): Promise<void> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
@@ -1500,4 +1641,5 @@ export {
|
||||
sendMessage,
|
||||
updateSessionAgent,
|
||||
updateSessionModel,
|
||||
updateSessionModelForSession,
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ import { messageStoreBus } from "./message-v2/bus"
|
||||
import { clearCacheForSession } from "../lib/global-cache"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { showToastNotification } from "../lib/notifications"
|
||||
import { getUserScopedKey } from "../lib/user-storage"
|
||||
|
||||
const log = getLogger("api")
|
||||
|
||||
@@ -147,7 +148,7 @@ function getStoredQwenToken():
|
||||
| null {
|
||||
if (typeof window === "undefined") return null
|
||||
try {
|
||||
const raw = window.localStorage.getItem("qwen_oauth_token")
|
||||
const raw = window.localStorage.getItem(getUserScopedKey("qwen_oauth_token"))
|
||||
if (!raw) return null
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
@@ -689,6 +690,7 @@ async function fetchAgents(instanceId: string): Promise<void> {
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureInstanceConfigLoaded(instanceId)
|
||||
log.info(`[HTTP] GET /app.agents for instance ${instanceId}`)
|
||||
const response = await instance.client.app.agents()
|
||||
const agentList = (response.data ?? []).map((agent) => ({
|
||||
@@ -703,9 +705,16 @@ async function fetchAgents(instanceId: string): Promise<void> {
|
||||
: undefined,
|
||||
}))
|
||||
|
||||
const customAgents = getInstanceConfig(instanceId)?.customAgents ?? []
|
||||
const customList = customAgents.map((agent) => ({
|
||||
name: agent.name,
|
||||
description: agent.description || "",
|
||||
mode: "custom",
|
||||
}))
|
||||
|
||||
setAgents((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(instanceId, agentList)
|
||||
next.set(instanceId, [...agentList, ...customList])
|
||||
return next
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,12 +19,13 @@ import { getLogger } from "../lib/logger"
|
||||
import { showToastNotification, ToastVariant } from "../lib/notifications"
|
||||
import { instances, addPermissionToQueue, removePermissionFromQueue, sendPermissionResponse } from "./instances"
|
||||
import { getSoloState, incrementStep, popFromTaskQueue, setActiveTaskId } from "./solo-store"
|
||||
import { sendMessage } from "./session-actions"
|
||||
import { sendMessage, consumeTokenWarningSuppression, consumeCompactionSuppression, updateSessionModel } from "./session-actions"
|
||||
import { showAlertDialog } from "./alerts"
|
||||
import { sessions, setSessions, withSession } from "./session-state"
|
||||
import { normalizeMessagePart } from "./message-v2/normalizers"
|
||||
import { updateSessionInfo } from "./message-v2/session-info"
|
||||
import { addTaskMessage, replaceTaskMessageId } from "./task-actions"
|
||||
import { checkAndTriggerAutoCompact, getSessionCompactionState, setCompactionSuggestion } from "./session-compaction"
|
||||
|
||||
const log = getLogger("sse")
|
||||
import { loadMessages } from "./session-api"
|
||||
@@ -39,6 +40,7 @@ import {
|
||||
} from "./message-v2/bridge"
|
||||
import { messageStoreBus } from "./message-v2/bus"
|
||||
import type { InstanceMessageStore } from "./message-v2/instance-store"
|
||||
import { getDefaultModel } from "./session-models"
|
||||
|
||||
interface TuiToastEvent {
|
||||
type: "tui.toast.show"
|
||||
@@ -232,6 +234,16 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
|
||||
upsertMessageInfoV2(instanceId, info, { status, bumpRevision: true })
|
||||
|
||||
updateSessionInfo(instanceId, sessionId)
|
||||
|
||||
checkAndTriggerAutoCompact(instanceId, sessionId)
|
||||
.then((shouldCompact) => {
|
||||
if (!shouldCompact) return
|
||||
if (getSessionCompactionState(instanceId, sessionId)) return
|
||||
setCompactionSuggestion(instanceId, sessionId, "Context usage is high. Compact to continue.")
|
||||
})
|
||||
.catch((err) => {
|
||||
log.error("Failed to check and trigger auto-compact", err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,6 +401,21 @@ function handleSessionCompacted(instanceId: string, event: EventSessionCompacted
|
||||
})
|
||||
}
|
||||
|
||||
function isContextLengthError(error: any): boolean {
|
||||
if (!error) return false
|
||||
const errorMessage = error.data?.message || error.message || ""
|
||||
return (
|
||||
errorMessage.includes("maximum context length") ||
|
||||
errorMessage.includes("context_length_exceeded") ||
|
||||
errorMessage.includes("token count exceeds") ||
|
||||
errorMessage.includes("token limit")
|
||||
)
|
||||
}
|
||||
|
||||
function isUnsupportedModelMessage(message: string): boolean {
|
||||
return /model\s+.+\s+not supported/i.test(message)
|
||||
}
|
||||
|
||||
function handleSessionError(instanceId: string, event: EventSessionError): void {
|
||||
const error = event.properties?.error
|
||||
log.error(`[SSE] Session error:`, error)
|
||||
@@ -406,18 +433,73 @@ function handleSessionError(instanceId: string, event: EventSessionError): void
|
||||
// Autonomous error recovery for SOLO
|
||||
const solo = getSoloState(instanceId)
|
||||
const sessionId = (event.properties as any)?.sessionID
|
||||
|
||||
if (solo.isAutonomous && sessionId && solo.currentStep < solo.maxSteps) {
|
||||
log.info(`[SOLO] Session error in autonomous mode, prompting fix: ${message}`)
|
||||
incrementStep(instanceId)
|
||||
sendMessage(instanceId, sessionId, `I encountered an error: "${message}". Please analyze the cause and provide a fix.`, [], solo.activeTaskId || undefined).catch((err) => {
|
||||
log.error("[SOLO] Failed to send error recovery message", err)
|
||||
})
|
||||
} else {
|
||||
showAlertDialog(`Error: ${message}`, {
|
||||
title: "Session error",
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if this is a context length error
|
||||
if (isContextLengthError(error)) {
|
||||
if (sessionId && consumeCompactionSuppression(instanceId, sessionId)) {
|
||||
showAlertDialog("Compaction failed because the model context limit was exceeded. Reduce context or switch to a larger context model, then try compact again.", {
|
||||
title: "Compaction failed",
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
if (sessionId && consumeTokenWarningSuppression(instanceId, sessionId)) {
|
||||
showToastNotification({
|
||||
title: "Context limit exceeded",
|
||||
message: "Compaction is required before continuing.",
|
||||
variant: "warning",
|
||||
duration: 7000,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.info("Context length error detected; suggesting compaction", { instanceId, sessionId })
|
||||
if (sessionId) {
|
||||
setCompactionSuggestion(instanceId, sessionId, "Context limit exceeded. Compact to continue.")
|
||||
showToastNotification({
|
||||
title: "Compaction required",
|
||||
message: "Click Compact to continue this session.",
|
||||
variant: "warning",
|
||||
duration: 8000,
|
||||
})
|
||||
} else {
|
||||
showAlertDialog(`Error: ${message}`, {
|
||||
title: "Session error",
|
||||
variant: "error",
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (sessionId && isUnsupportedModelMessage(message)) {
|
||||
showToastNotification({
|
||||
title: "Model not supported",
|
||||
message: "Selected model is not supported by this provider. Reverting to a default model.",
|
||||
variant: "warning",
|
||||
duration: 8000,
|
||||
})
|
||||
|
||||
const sessionRecord = sessions().get(instanceId)?.get(sessionId)
|
||||
getDefaultModel(instanceId, sessionRecord?.agent)
|
||||
.then((fallback) => updateSessionModel(instanceId, sessionId, fallback))
|
||||
.catch((err) => log.error("Failed to restore default model after unsupported model error", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Default error handling
|
||||
showAlertDialog(`Error: ${message}`, {
|
||||
title: "Session error",
|
||||
variant: "error",
|
||||
})
|
||||
}
|
||||
|
||||
function handleMessageRemoved(instanceId: string, event: MessageRemovedEvent): void {
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Session, SessionStatus } from "../types/session"
|
||||
import type { MessageInfo } from "../types/message"
|
||||
import type { MessageRecord } from "./message-v2/types"
|
||||
import { sessions } from "./sessions"
|
||||
import { isSessionCompactionActive } from "./session-compaction"
|
||||
import { getSessionCompactionState } from "./session-compaction"
|
||||
import { messageStoreBus } from "./message-v2/bus"
|
||||
|
||||
function getSession(instanceId: string, sessionId: string): Session | null {
|
||||
@@ -120,7 +120,7 @@ export function getSessionStatus(instanceId: string, sessionId: string): Session
|
||||
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
|
||||
if (isSessionCompactionActive(instanceId, sessionId) || isSessionCompacting(session)) {
|
||||
if (getSessionCompactionState(instanceId, sessionId) || isSessionCompacting(session)) {
|
||||
return "compacting"
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { withSession } from "./session-state"
|
||||
import { sessions, withSession } from "./session-state"
|
||||
import { Task, TaskStatus } from "../types/session"
|
||||
import { nanoid } from "nanoid"
|
||||
import { forkSession } from "./session-api"
|
||||
import { createSession } from "./session-api"
|
||||
import { showToastNotification } from "../lib/notifications"
|
||||
|
||||
export function setActiveTask(instanceId: string, sessionId: string, taskId: string | undefined): void {
|
||||
withSession(instanceId, sessionId, (session) => {
|
||||
@@ -18,13 +19,32 @@ export async function addTask(
|
||||
console.log("[task-actions] addTask started", { instanceId, sessionId, title, taskId: id });
|
||||
|
||||
let taskSessionId: string | undefined
|
||||
const parentSession = sessions().get(instanceId)?.get(sessionId)
|
||||
const parentAgent = parentSession?.agent || ""
|
||||
const parentModel = parentSession?.model
|
||||
try {
|
||||
console.log("[task-actions] forking session...");
|
||||
const forked = await forkSession(instanceId, sessionId)
|
||||
taskSessionId = forked.id
|
||||
console.log("[task-actions] fork successful", { taskSessionId });
|
||||
console.log("[task-actions] creating new task session...");
|
||||
const created = await createSession(instanceId, parentAgent || undefined, { skipAutoCleanup: true })
|
||||
taskSessionId = created.id
|
||||
withSession(instanceId, taskSessionId, (taskSession) => {
|
||||
taskSession.parentId = sessionId
|
||||
if (parentAgent) {
|
||||
taskSession.agent = parentAgent
|
||||
}
|
||||
if (parentModel?.providerId && parentModel?.modelId) {
|
||||
taskSession.model = { ...parentModel }
|
||||
}
|
||||
})
|
||||
console.log("[task-actions] task session created", { taskSessionId });
|
||||
} catch (error) {
|
||||
console.error("[task-actions] Failed to fork session for task", error)
|
||||
console.error("[task-actions] Failed to create session for task", error)
|
||||
showToastNotification({
|
||||
title: "Task session unavailable",
|
||||
message: "Continuing in the current session.",
|
||||
variant: "warning",
|
||||
duration: 5000,
|
||||
})
|
||||
taskSessionId = undefined
|
||||
}
|
||||
|
||||
const newTask: Task = {
|
||||
@@ -34,6 +54,7 @@ export async function addTask(
|
||||
timestamp: Date.now(),
|
||||
messageIds: [],
|
||||
taskSessionId,
|
||||
archived: false,
|
||||
}
|
||||
|
||||
withSession(instanceId, sessionId, (session) => {
|
||||
@@ -161,3 +182,15 @@ export function removeTask(instanceId: string, sessionId: string, taskId: string
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function archiveTask(instanceId: string, sessionId: string, taskId: string): void {
|
||||
withSession(instanceId, sessionId, (session) => {
|
||||
if (!session.tasks) return
|
||||
session.tasks = session.tasks.map((task) =>
|
||||
task.id === taskId ? { ...task, archived: true } : task,
|
||||
)
|
||||
if (session.activeTaskId === taskId) {
|
||||
session.activeTaskId = undefined
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ const [hasInstances, setHasInstances] = createSignal(false)
|
||||
const [selectedFolder, setSelectedFolder] = createSignal<string | null>(null)
|
||||
const [isSelectingFolder, setIsSelectingFolder] = createSignal(false)
|
||||
const [showFolderSelection, setShowFolderSelection] = createSignal(false)
|
||||
const [showFolderSelectionOnStart, setShowFolderSelectionOnStart] = createSignal(true)
|
||||
|
||||
const [instanceTabOrder, setInstanceTabOrder] = createSignal<string[]>([])
|
||||
const [sessionTabOrder, setSessionTabOrder] = createSignal<Map<string, string[]>>(new Map())
|
||||
@@ -29,6 +30,8 @@ export {
|
||||
setIsSelectingFolder,
|
||||
showFolderSelection,
|
||||
setShowFolderSelection,
|
||||
showFolderSelectionOnStart,
|
||||
setShowFolderSelectionOnStart,
|
||||
instanceTabOrder,
|
||||
setInstanceTabOrder,
|
||||
sessionTabOrder,
|
||||
|
||||
89
packages/ui/src/stores/users.ts
Normal file
89
packages/ui/src/stores/users.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { createSignal } from "solid-js"
|
||||
import { getLogger } from "../lib/logger"
|
||||
|
||||
export interface UserAccount {
|
||||
id: string
|
||||
name: string
|
||||
isGuest?: boolean
|
||||
}
|
||||
|
||||
const log = getLogger("users")
|
||||
|
||||
const [users, setUsers] = createSignal<UserAccount[]>([])
|
||||
const [activeUser, setActiveUserSignal] = createSignal<UserAccount | null>(null)
|
||||
const [loadingUsers, setLoadingUsers] = createSignal(false)
|
||||
|
||||
function getElectronApi() {
|
||||
return typeof window !== "undefined" ? window.electronAPI : undefined
|
||||
}
|
||||
|
||||
async function refreshUsers(): Promise<void> {
|
||||
const api = getElectronApi()
|
||||
if (!api?.listUsers) return
|
||||
setLoadingUsers(true)
|
||||
try {
|
||||
const list = await api.listUsers()
|
||||
setUsers(list ?? [])
|
||||
const active = api.getActiveUser ? await api.getActiveUser() : null
|
||||
setActiveUserSignal(active ?? null)
|
||||
} catch (error) {
|
||||
log.warn("Failed to load users", error)
|
||||
} finally {
|
||||
setLoadingUsers(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function createUser(name: string, password: string): Promise<UserAccount | null> {
|
||||
const api = getElectronApi()
|
||||
if (!api?.createUser) return null
|
||||
const user = await api.createUser({ name, password })
|
||||
await refreshUsers()
|
||||
return user ?? null
|
||||
}
|
||||
|
||||
async function updateUser(id: string, updates: { name?: string; password?: string }): Promise<UserAccount | null> {
|
||||
const api = getElectronApi()
|
||||
if (!api?.updateUser) return null
|
||||
const user = await api.updateUser({ id, ...updates })
|
||||
await refreshUsers()
|
||||
return user ?? null
|
||||
}
|
||||
|
||||
async function deleteUser(id: string): Promise<void> {
|
||||
const api = getElectronApi()
|
||||
if (!api?.deleteUser) return
|
||||
await api.deleteUser({ id })
|
||||
await refreshUsers()
|
||||
}
|
||||
|
||||
async function loginUser(id: string, password?: string): Promise<boolean> {
|
||||
const api = getElectronApi()
|
||||
if (!api?.loginUser) return false
|
||||
const result = await api.loginUser({ id, password })
|
||||
if (result?.success) {
|
||||
setActiveUserSignal(result.user ?? null)
|
||||
await refreshUsers()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function createGuest(): Promise<UserAccount | null> {
|
||||
const api = getElectronApi()
|
||||
if (!api?.createGuest) return null
|
||||
const user = await api.createGuest()
|
||||
await refreshUsers()
|
||||
return user ?? null
|
||||
}
|
||||
|
||||
export {
|
||||
users,
|
||||
activeUser,
|
||||
loadingUsers,
|
||||
refreshUsers,
|
||||
createUser,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
loginUser,
|
||||
createGuest,
|
||||
}
|
||||
95
packages/ui/src/styles/components/mcp-manager.css
Normal file
95
packages/ui/src/styles/components/mcp-manager.css
Normal file
@@ -0,0 +1,95 @@
|
||||
.mcp-manager {
|
||||
@apply flex flex-col gap-3;
|
||||
}
|
||||
|
||||
.mcp-manager-header {
|
||||
@apply flex items-center justify-between;
|
||||
}
|
||||
|
||||
.mcp-manager-actions {
|
||||
@apply flex items-center gap-2;
|
||||
}
|
||||
|
||||
.mcp-action-button {
|
||||
@apply flex items-center gap-1 px-2 py-1 text-[10px] font-semibold uppercase tracking-wide rounded-md bg-white/5 border border-white/10 text-zinc-300 hover:text-white;
|
||||
}
|
||||
|
||||
.mcp-link-button {
|
||||
@apply px-2 py-1 text-[10px] font-semibold uppercase tracking-wide rounded-md bg-indigo-500/15 border border-indigo-500/30 text-indigo-300 hover:text-white;
|
||||
}
|
||||
|
||||
.mcp-icon-button {
|
||||
@apply p-1 rounded-md border border-white/10 text-zinc-400 hover:text-white hover:border-white/20;
|
||||
}
|
||||
|
||||
.mcp-menu {
|
||||
@apply absolute right-0 mt-2 w-48 rounded-md border border-white/10 bg-zinc-950 shadow-xl z-50 overflow-hidden;
|
||||
}
|
||||
|
||||
.mcp-menu-item {
|
||||
@apply w-full px-3 py-2 text-left text-[11px] text-zinc-300 hover:text-white hover:bg-white/5 flex items-center justify-between gap-2;
|
||||
}
|
||||
|
||||
.mcp-server-list {
|
||||
@apply flex flex-col gap-2;
|
||||
}
|
||||
|
||||
.mcp-server-card {
|
||||
@apply px-2 py-2 rounded border bg-white/5 border-white/10;
|
||||
}
|
||||
|
||||
.mcp-server-row {
|
||||
@apply flex items-center justify-between gap-2;
|
||||
}
|
||||
|
||||
.mcp-status-chip {
|
||||
@apply text-[10px] px-2 py-0.5 rounded-full border border-emerald-500/40 text-emerald-300 bg-emerald-500/10 uppercase tracking-wide;
|
||||
}
|
||||
|
||||
.mcp-status-error {
|
||||
@apply text-[10px] px-2 py-0.5 rounded-full border border-rose-500/40 text-rose-300 bg-rose-500/10 uppercase tracking-wide;
|
||||
}
|
||||
|
||||
.mcp-market-search {
|
||||
@apply flex items-center gap-2 border border-white/10 rounded-lg px-3 py-2 bg-white/5;
|
||||
}
|
||||
|
||||
.mcp-market-input {
|
||||
@apply w-full bg-transparent text-xs text-zinc-200 outline-none;
|
||||
}
|
||||
|
||||
.mcp-market-list {
|
||||
@apply flex flex-col gap-2 max-h-[60vh] overflow-y-auto pr-1;
|
||||
}
|
||||
|
||||
.mcp-market-card {
|
||||
@apply flex items-start justify-between gap-4 border border-white/10 rounded-lg bg-white/5 p-3;
|
||||
}
|
||||
|
||||
.mcp-market-card-title {
|
||||
@apply text-xs font-semibold text-zinc-100 flex items-center gap-2;
|
||||
}
|
||||
|
||||
.mcp-market-card-desc {
|
||||
@apply text-[11px] text-zinc-500 mt-1;
|
||||
}
|
||||
|
||||
.mcp-market-tags {
|
||||
@apply flex flex-wrap gap-1 mt-2;
|
||||
}
|
||||
|
||||
.mcp-market-tag {
|
||||
@apply text-[9px] uppercase tracking-wide px-2 py-0.5 rounded-full border border-white/10 text-zinc-400;
|
||||
}
|
||||
|
||||
.mcp-market-source {
|
||||
@apply text-[9px] uppercase tracking-wide px-2 py-0.5 rounded-full border border-white/10 text-zinc-500;
|
||||
}
|
||||
|
||||
.mcp-market-card-actions {
|
||||
@apply flex items-center gap-2;
|
||||
}
|
||||
|
||||
.mcp-market-install {
|
||||
@apply flex items-center gap-1 px-2 py-1 text-[10px] font-semibold uppercase tracking-wide rounded-md bg-indigo-500/20 border border-indigo-500/40 text-indigo-200 hover:text-white;
|
||||
}
|
||||
@@ -6,3 +6,4 @@
|
||||
@import "./components/env-vars.css";
|
||||
@import "./components/directory-browser.css";
|
||||
@import "./components/remote-access.css";
|
||||
@import "./components/mcp-manager.css";
|
||||
|
||||
@@ -53,6 +53,20 @@
|
||||
@apply text-[11px] text-[var(--text-muted)];
|
||||
}
|
||||
|
||||
.message-model-badge {
|
||||
@apply inline-flex items-center px-2 py-1 rounded-full border;
|
||||
border-color: var(--border-base);
|
||||
background-color: var(--surface-secondary);
|
||||
color: var(--text-muted);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.message-model-badge:hover {
|
||||
border-color: var(--accent-primary);
|
||||
color: var(--accent-primary);
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
.assistant-message {
|
||||
/* gap: 0.25rem; */
|
||||
padding: 0.6rem 0.65rem;
|
||||
@@ -121,6 +135,13 @@
|
||||
border-color: var(--status-error);
|
||||
}
|
||||
|
||||
.compact-button {
|
||||
@apply ml-2 px-2 py-1 rounded bg-emerald-500/20 border border-emerald-500/40 text-emerald-400 text-xs font-semibold hover:bg-emerald-500/30 transition-all;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.message-generating {
|
||||
@apply text-sm italic py-2;
|
||||
color: var(--text-muted);
|
||||
@@ -146,6 +167,58 @@
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.message-streaming-indicator {
|
||||
@apply inline-flex items-center gap-2 px-3 py-2 rounded-lg border border-purple-500/50 bg-purple-500/10 mb-2;
|
||||
}
|
||||
|
||||
.streaming-status {
|
||||
@apply inline-flex items-center gap-2 text-sm;
|
||||
}
|
||||
|
||||
.streaming-pulse {
|
||||
@apply inline-block w-2 h-2 rounded-full bg-purple-500;
|
||||
animation: streaming-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes streaming-pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 8px 4px rgba(168, 85, 247, 0.6);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
transform: scale(0.8);
|
||||
box-shadow: 0 0 12px 6px rgba(168, 85, 247, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
.streaming-text {
|
||||
@apply text-purple-400 font-semibold tracking-wide;
|
||||
animation: streaming-text-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes streaming-text-pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.streaming-tokens {
|
||||
@apply inline-flex items-center gap-1 px-2 py-1 rounded-full bg-purple-500/20 border border-purple-500/30;
|
||||
}
|
||||
|
||||
.streaming-token-count {
|
||||
@apply text-purple-300 font-mono font-bold;
|
||||
}
|
||||
|
||||
.streaming-token-label {
|
||||
@apply text-purple-400 text-xs font-medium;
|
||||
}
|
||||
|
||||
.message-text {
|
||||
font-size: var(--font-size-base);
|
||||
line-height: var(--line-height-normal);
|
||||
|
||||
@@ -1,7 +1,27 @@
|
||||
.message-stream {
|
||||
@apply flex-1 min-h-0 overflow-y-auto flex flex-col gap-0.5;
|
||||
@apply flex-1 min-h-0 overflow-y-auto overflow-x-hidden flex flex-col gap-0.5;
|
||||
background-color: var(--surface-base);
|
||||
color: inherit;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-base) transparent;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.message-stream::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.message-stream::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.message-stream::-webkit-scrollbar-thumb {
|
||||
background: var(--border-base);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.message-stream::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--border-base-hover);
|
||||
}
|
||||
|
||||
.message-stream-block {
|
||||
|
||||
@@ -1,5 +1,54 @@
|
||||
.message-stream-container {
|
||||
@apply relative flex-1 min-h-0 flex flex-col overflow-hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.compaction-banner {
|
||||
@apply sticky top-0 z-10 flex items-center gap-2 px-4 py-2 text-xs font-medium;
|
||||
background-color: var(--surface-secondary);
|
||||
color: var(--text-primary);
|
||||
border-bottom: 1px solid var(--border-base);
|
||||
}
|
||||
|
||||
.compaction-banner-spinner {
|
||||
@apply w-4 h-4 border-2 border-t-transparent rounded-full;
|
||||
border-color: var(--border-base);
|
||||
border-top-color: var(--accent-primary);
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.compaction-suggestion {
|
||||
@apply sticky top-0 z-10 flex items-center justify-between gap-3 px-4 py-2 text-xs font-medium;
|
||||
background-color: rgba(22, 163, 74, 0.12);
|
||||
color: var(--text-primary);
|
||||
border-bottom: 1px solid rgba(22, 163, 74, 0.35);
|
||||
}
|
||||
|
||||
.compaction-suggestion-text {
|
||||
@apply flex flex-col gap-0.5;
|
||||
}
|
||||
|
||||
.compaction-suggestion-label {
|
||||
@apply uppercase tracking-wide text-[10px] font-semibold;
|
||||
color: rgba(74, 222, 128, 0.9);
|
||||
}
|
||||
|
||||
.compaction-suggestion-message {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.compaction-suggestion-action {
|
||||
@apply inline-flex items-center justify-center px-3 py-1.5 rounded-lg text-[10px] font-semibold uppercase tracking-wide;
|
||||
border: 1px solid rgba(34, 197, 94, 0.5);
|
||||
background-color: rgba(34, 197, 94, 0.2);
|
||||
color: #4ade80;
|
||||
transition: transform 0.2s ease, background-color 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
.compaction-suggestion-action:hover {
|
||||
background-color: rgba(34, 197, 94, 0.3);
|
||||
color: #86efac;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.connection-status {
|
||||
|
||||
@@ -38,12 +38,14 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
.message-stream-shell .message-stream {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.message-timeline-sidebar {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* Prompt input & attachment styles */
|
||||
.prompt-input-container {
|
||||
@apply flex flex-col relative mx-auto w-full max-w-4xl;
|
||||
@apply flex flex-col relative mx-auto w-full max-w-4xl flex-shrink-0;
|
||||
padding: 1rem 1.5rem 1.5rem;
|
||||
background-color: transparent;
|
||||
}
|
||||
@@ -32,6 +32,20 @@
|
||||
@apply absolute right-2 bottom-2 flex items-center space-x-1.5 z-20;
|
||||
}
|
||||
|
||||
.thinking-indicator {
|
||||
@apply flex items-center gap-1.5 px-2 py-1 rounded-lg text-[10px] font-semibold uppercase tracking-wide;
|
||||
border: 1px solid rgba(129, 140, 248, 0.4);
|
||||
background-color: rgba(99, 102, 241, 0.12);
|
||||
color: #a5b4fc;
|
||||
}
|
||||
|
||||
.thinking-spinner {
|
||||
@apply w-3 h-3 border-2 border-t-transparent rounded-full;
|
||||
border-color: rgba(129, 140, 248, 0.4);
|
||||
border-top-color: #a5b4fc;
|
||||
animation: spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
.send-button, .stop-button {
|
||||
@apply w-8 h-8 rounded-xl border-none cursor-pointer flex items-center justify-center transition-all flex-shrink-0 shadow-lg;
|
||||
}
|
||||
|
||||
@@ -214,7 +214,7 @@
|
||||
}
|
||||
|
||||
.tool-call-diff-toolbar {
|
||||
@apply flex items-center justify-between gap-3 px-3 py-2;
|
||||
@apply flex flex-wrap items-center gap-3 px-3 py-2;
|
||||
background-color: var(--surface-secondary);
|
||||
border-bottom: 1px solid var(--border-base);
|
||||
}
|
||||
@@ -227,7 +227,7 @@
|
||||
}
|
||||
|
||||
.tool-call-diff-toggle {
|
||||
@apply inline-flex items-center gap-1;
|
||||
@apply inline-flex flex-wrap items-center gap-1;
|
||||
}
|
||||
|
||||
.tool-call-diff-mode-button {
|
||||
|
||||
191
packages/ui/src/styles/responsive.css
Normal file
191
packages/ui/src/styles/responsive.css
Normal file
@@ -0,0 +1,191 @@
|
||||
/* Responsive Design for Electron Interface */
|
||||
|
||||
/* Base container adjustments for small screens */
|
||||
@media (max-width: 640px) {
|
||||
.session-shell-panels {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.session-toolbar {
|
||||
padding: 0.25rem 0.5rem;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.session-toolbar button,
|
||||
.session-toolbar .icon-button {
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.session-toolbar .hidden.md\:flex {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.content-area {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tablet adjustments */
|
||||
@media (min-width: 641px) and (max-width: 1024px) {
|
||||
.session-toolbar {
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
.content-area {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Desktop adjustments */
|
||||
@media (min-width: 1025px) {
|
||||
.content-area {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ensure all scrollable containers handle overflow properly */
|
||||
@media (max-width: 768px) {
|
||||
.flex-1.min-h-0 {
|
||||
min-height: 0;
|
||||
flex: 1 1 0%;
|
||||
}
|
||||
|
||||
.overflow-y-auto {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.min-h-0 {
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Fix drawer widths on mobile */
|
||||
@media (max-width: 768px) {
|
||||
|
||||
.session-sidebar-container,
|
||||
.session-right-panel {
|
||||
max-width: 100vw !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Chat panel adjustments for small screens */
|
||||
@media (max-width: 640px) {
|
||||
.flex.flex-col.relative.border-l {
|
||||
min-width: 280px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Terminal adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.terminal-panel {
|
||||
min-height: 100px !important;
|
||||
max-height: 40vh !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Prevent horizontal scroll on root levels only */
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
overflow-x: hidden;
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
/* Ensure proper flex sizing throughout the app */
|
||||
.flex-1 {
|
||||
flex: 1 1 0%;
|
||||
}
|
||||
|
||||
.min-h-0 {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Ensure scrollable containers work correctly */
|
||||
.overflow-y-auto {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Ensure viewport meta tag behavior */
|
||||
@viewport {
|
||||
width: device-width;
|
||||
zoom: 1.0;
|
||||
}
|
||||
|
||||
/* Touch-friendly adjustments for mobile */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.session-resize-handle {
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.message-scroll-button {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* High DPI adjustments */
|
||||
@media (-webkit-min-device-pixel-ratio: 2),
|
||||
(min-resolution: 192dpi) {
|
||||
|
||||
/* Enhance text rendering on high-dpi screens */
|
||||
body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ensure the main app container is fully adaptive */
|
||||
.app-container,
|
||||
[data-app-container="true"],
|
||||
#root>div {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Fix message navigation sidebar from being cut off */
|
||||
.message-navigation-sidebar,
|
||||
[class*="message-nav"],
|
||||
.shrink-0.overflow-y-auto.border-l {
|
||||
min-width: 28px;
|
||||
max-width: 48px;
|
||||
flex-shrink: 0;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Ensure panels don't overflow their containers */
|
||||
.panel,
|
||||
[role="main"],
|
||||
main {
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Fix right-side badges and avatars */
|
||||
.message-avatar,
|
||||
.message-role-badge,
|
||||
[class*="shrink-0"][class*="border-l"] {
|
||||
min-width: min-content;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* Ensure proper Electron window behavior */
|
||||
@media screen {
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
/* Text rendering optimization */
|
||||
body {
|
||||
-webkit-font-smoothing: subpixel-antialiased;
|
||||
}
|
||||
@@ -233,23 +233,6 @@
|
||||
--new-tab-bg: #3f3f46;
|
||||
--new-tab-hover-bg: #52525b;
|
||||
--new-tab-text: #f5f6f8;
|
||||
--session-tab-active-bg: var(--surface-muted);
|
||||
--session-tab-active-text: var(--text-primary);
|
||||
--session-tab-inactive-text: var(--text-muted);
|
||||
--session-tab-hover-bg: #3f3f46;
|
||||
|
||||
--button-primary-bg: #3f3f46;
|
||||
--button-primary-hover-bg: #52525b;
|
||||
--button-primary-text: #f5f6f8;
|
||||
--tab-active-bg: #3f3f46;
|
||||
--tab-active-hover-bg: #52525b;
|
||||
--tab-active-text: #f5f6f8;
|
||||
--tab-inactive-bg: #2f2f36;
|
||||
--tab-inactive-hover-bg: #3d3d45;
|
||||
--tab-inactive-text: #d4d4d8;
|
||||
--new-tab-bg: #3f3f46;
|
||||
--new-tab-hover-bg: #52525b;
|
||||
--new-tab-text: #f5f6f8;
|
||||
--session-tab-active-bg: var(--surface-muted);
|
||||
--session-tab-active-text: var(--text-primary);
|
||||
--session-tab-inactive-text: var(--text-muted);
|
||||
|
||||
@@ -48,7 +48,22 @@
|
||||
}
|
||||
|
||||
.icon-danger-hover:hover {
|
||||
color: var(--status-error);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* Tooltip styles */
|
||||
.tooltip-content {
|
||||
background-color: var(--surface-base);
|
||||
border: 1px solid var(--border-base);
|
||||
color: var(--text-primary);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
max-width: 200px;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.tooltip-content .font-bold {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.icon-accent-hover:hover {
|
||||
|
||||
7
packages/ui/src/types/global.d.ts
vendored
7
packages/ui/src/types/global.d.ts
vendored
@@ -26,6 +26,13 @@ declare global {
|
||||
onCliError?: (callback: (data: unknown) => void) => () => void
|
||||
getCliStatus?: () => Promise<unknown>
|
||||
openDialog?: (options: ElectronDialogOptions) => Promise<ElectronDialogResult>
|
||||
listUsers?: () => Promise<Array<{ id: string; name: string; isGuest?: boolean }>>
|
||||
getActiveUser?: () => Promise<{ id: string; name: string; isGuest?: boolean } | null>
|
||||
createUser?: (payload: { name: string; password: string }) => Promise<{ id: string; name: string; isGuest?: boolean }>
|
||||
updateUser?: (payload: { id: string; name?: string; password?: string }) => Promise<{ id: string; name: string; isGuest?: boolean }>
|
||||
deleteUser?: (payload: { id: string }) => Promise<{ success: boolean }>
|
||||
createGuest?: () => Promise<{ id: string; name: string; isGuest?: boolean }>
|
||||
loginUser?: (payload: { id: string; password?: string }) => Promise<{ success: boolean; user?: { id: string; name: string; isGuest?: boolean } }>
|
||||
}
|
||||
|
||||
interface TauriDialogModule {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { resolve } from "path"
|
||||
|
||||
export default defineConfig({
|
||||
root: "./src/renderer",
|
||||
publicDir: resolve(__dirname, "./public"),
|
||||
plugins: [solid()],
|
||||
css: {
|
||||
postcss: "./postcss.config.js",
|
||||
@@ -20,10 +21,11 @@ export default defineConfig({
|
||||
noExternal: ["lucide-solid"],
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
port: Number(process.env.VITE_PORT ?? 3000),
|
||||
},
|
||||
build: {
|
||||
outDir: "dist",
|
||||
outDir: resolve(__dirname, "dist"),
|
||||
chunkSizeWarningLimit: 1000,
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: resolve(__dirname, "./src/renderer/index.html"),
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// vite.config.ts
|
||||
import { defineConfig } from "file:///E:/TRAE%20Playground/NeuralNomadsAi/NomadArch/node_modules/vite/dist/node/index.js";
|
||||
import solid from "file:///E:/TRAE%20Playground/NeuralNomadsAi/NomadArch/node_modules/vite-plugin-solid/dist/esm/index.mjs";
|
||||
import { resolve } from "path";
|
||||
var __vite_injected_original_dirname = "E:\\TRAE Playground\\NeuralNomadsAi\\NomadArch\\packages\\ui";
|
||||
var vite_config_default = defineConfig({
|
||||
root: "./src/renderer",
|
||||
publicDir: resolve(__vite_injected_original_dirname, "./public"),
|
||||
plugins: [solid()],
|
||||
css: {
|
||||
postcss: "./postcss.config.js"
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": resolve(__vite_injected_original_dirname, "./src")
|
||||
}
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: ["lucide-solid"]
|
||||
},
|
||||
ssr: {
|
||||
noExternal: ["lucide-solid"]
|
||||
},
|
||||
server: {
|
||||
port: Number(process.env.VITE_PORT ?? 3e3)
|
||||
},
|
||||
build: {
|
||||
outDir: resolve(__vite_injected_original_dirname, "dist"),
|
||||
chunkSizeWarningLimit: 1e3,
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: resolve(__vite_injected_original_dirname, "./src/renderer/index.html"),
|
||||
loading: resolve(__vite_injected_original_dirname, "./src/renderer/loading.html")
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
export {
|
||||
vite_config_default as default
|
||||
};
|
||||
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZS5jb25maWcudHMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImNvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9kaXJuYW1lID0gXCJFOlxcXFxUUkFFIFBsYXlncm91bmRcXFxcTmV1cmFsTm9tYWRzQWlcXFxcTm9tYWRBcmNoXFxcXHBhY2thZ2VzXFxcXHVpXCI7Y29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2ZpbGVuYW1lID0gXCJFOlxcXFxUUkFFIFBsYXlncm91bmRcXFxcTmV1cmFsTm9tYWRzQWlcXFxcTm9tYWRBcmNoXFxcXHBhY2thZ2VzXFxcXHVpXFxcXHZpdGUuY29uZmlnLnRzXCI7Y29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2ltcG9ydF9tZXRhX3VybCA9IFwiZmlsZTovLy9FOi9UUkFFJTIwUGxheWdyb3VuZC9OZXVyYWxOb21hZHNBaS9Ob21hZEFyY2gvcGFja2FnZXMvdWkvdml0ZS5jb25maWcudHNcIjtpbXBvcnQgeyBkZWZpbmVDb25maWcgfSBmcm9tIFwidml0ZVwiXHJcbmltcG9ydCBzb2xpZCBmcm9tIFwidml0ZS1wbHVnaW4tc29saWRcIlxyXG5pbXBvcnQgeyByZXNvbHZlIH0gZnJvbSBcInBhdGhcIlxyXG5cclxuZXhwb3J0IGRlZmF1bHQgZGVmaW5lQ29uZmlnKHtcclxuICByb290OiBcIi4vc3JjL3JlbmRlcmVyXCIsXHJcbiAgcHVibGljRGlyOiByZXNvbHZlKF9fZGlybmFtZSwgXCIuL3B1YmxpY1wiKSxcclxuICBwbHVnaW5zOiBbc29saWQoKV0sXHJcbiAgY3NzOiB7XHJcbiAgICBwb3N0Y3NzOiBcIi4vcG9zdGNzcy5jb25maWcuanNcIixcclxuICB9LFxyXG4gIHJlc29sdmU6IHtcclxuICAgIGFsaWFzOiB7XHJcbiAgICAgIFwiQFwiOiByZXNvbHZlKF9fZGlybmFtZSwgXCIuL3NyY1wiKSxcclxuICAgIH0sXHJcbiAgfSxcclxuICBvcHRpbWl6ZURlcHM6IHtcclxuICAgIGV4Y2x1ZGU6IFtcImx1Y2lkZS1zb2xpZFwiXSxcclxuICB9LFxyXG4gIHNzcjoge1xyXG4gICAgbm9FeHRlcm5hbDogW1wibHVjaWRlLXNvbGlkXCJdLFxyXG4gIH0sXHJcbiAgc2VydmVyOiB7XHJcbiAgICBwb3J0OiBOdW1iZXIocHJvY2Vzcy5lbnYuVklURV9QT1JUID8/IDMwMDApLFxyXG4gIH0sXHJcbiAgYnVpbGQ6IHtcclxuICAgIG91dERpcjogcmVzb2x2ZShfX2Rpcm5hbWUsIFwiZGlzdFwiKSxcclxuICAgIGNodW5rU2l6ZVdhcm5pbmdMaW1pdDogMTAwMCxcclxuICAgIHJvbGx1cE9wdGlvbnM6IHtcclxuICAgICAgaW5wdXQ6IHtcclxuICAgICAgICBtYWluOiByZXNvbHZlKF9fZGlybmFtZSwgXCIuL3NyYy9yZW5kZXJlci9pbmRleC5odG1sXCIpLFxyXG4gICAgICAgIGxvYWRpbmc6IHJlc29sdmUoX19kaXJuYW1lLCBcIi4vc3JjL3JlbmRlcmVyL2xvYWRpbmcuaHRtbFwiKSxcclxuICAgICAgfSxcclxuICAgIH0sXHJcbiAgfSxcclxufSlcclxuIl0sCiAgIm1hcHBpbmdzIjogIjtBQUFxVyxTQUFTLG9CQUFvQjtBQUNsWSxPQUFPLFdBQVc7QUFDbEIsU0FBUyxlQUFlO0FBRnhCLElBQU0sbUNBQW1DO0FBSXpDLElBQU8sc0JBQVEsYUFBYTtBQUFBLEVBQzFCLE1BQU07QUFBQSxFQUNOLFdBQVcsUUFBUSxrQ0FBVyxVQUFVO0FBQUEsRUFDeEMsU0FBUyxDQUFDLE1BQU0sQ0FBQztBQUFBLEVBQ2pCLEtBQUs7QUFBQSxJQUNILFNBQVM7QUFBQSxFQUNYO0FBQUEsRUFDQSxTQUFTO0FBQUEsSUFDUCxPQUFPO0FBQUEsTUFDTCxLQUFLLFFBQVEsa0NBQVcsT0FBTztBQUFBLElBQ2pDO0FBQUEsRUFDRjtBQUFBLEVBQ0EsY0FBYztBQUFBLElBQ1osU0FBUyxDQUFDLGNBQWM7QUFBQSxFQUMxQjtBQUFBLEVBQ0EsS0FBSztBQUFBLElBQ0gsWUFBWSxDQUFDLGNBQWM7QUFBQSxFQUM3QjtBQUFBLEVBQ0EsUUFBUTtBQUFBLElBQ04sTUFBTSxPQUFPLFFBQVEsSUFBSSxhQUFhLEdBQUk7QUFBQSxFQUM1QztBQUFBLEVBQ0EsT0FBTztBQUFBLElBQ0wsUUFBUSxRQUFRLGtDQUFXLE1BQU07QUFBQSxJQUNqQyx1QkFBdUI7QUFBQSxJQUN2QixlQUFlO0FBQUEsTUFDYixPQUFPO0FBQUEsUUFDTCxNQUFNLFFBQVEsa0NBQVcsMkJBQTJCO0FBQUEsUUFDcEQsU0FBUyxRQUFRLGtDQUFXLDZCQUE2QjtBQUFBLE1BQzNEO0FBQUEsSUFDRjtBQUFBLEVBQ0Y7QUFDRixDQUFDOyIsCiAgIm5hbWVzIjogW10KfQo=
|
||||
Reference in New Issue
Block a user