feat: migrate conversation orchestration to Rust-side Tauri commands

- Added `chat_send` and `chat_cancel` Tauri commands for backend-driven conversation loops.
- Moved agentic loop logic from frontend to Rust, optimizing performance and reducing frontend responsibilities.
- Implemented event protocol (`chat:tool_call`, `chat:tool_result`, `chat:done`, `chat:error`) to communicate loop progress to the frontend.
- Introduced AI config loading, OpenClaw context building, and cancellation support for chat requests.
This commit is contained in:
cyrus
2026-03-23 23:48:43 +05:30
parent 7c909e512a
commit a373e5de3c
8 changed files with 1544 additions and 54 deletions
+2
View File
@@ -54,6 +54,7 @@
"@tauri-apps/plugin-os": "^2.3.2",
"@tauri-apps/plugin-shell": "~2",
"@types/react-router-dom": "^5.3.3",
"@types/three": "^0.183.1",
"buffer": "^6.0.3",
"debug": "^4.4.3",
"immer": "^11.1.3",
@@ -70,6 +71,7 @@
"redux-persist": "^6.0.0",
"socket.io-client": "^4.8.3",
"telegram": "^2.26.22",
"three": "^0.183.2",
"util": "^0.12.5",
"zustand": "^5.0.10"
},
File diff suppressed because it is too large Load Diff
+2
View File
@@ -1,4 +1,5 @@
pub mod auth;
pub mod chat;
pub mod memory;
pub mod model;
pub mod runtime;
@@ -11,6 +12,7 @@ pub mod window;
// Re-export all commands for registration
pub use auth::*;
pub use chat::{chat_cancel, chat_send};
pub use memory::*;
pub use model::*;
pub use runtime::*;
+12
View File
@@ -20,6 +20,7 @@ mod unified_skills;
mod utils;
use ai::*;
use commands::chat::ChatState;
use commands::unified_skills::{
unified_execute_skill, unified_generate_skill, unified_list_skills,
unified_self_evolve_skill,
@@ -648,6 +649,11 @@ pub fn run() {
app.manage(commands::memory::MemoryState(std::sync::Mutex::new(None)));
log::info!("[memory] Memory state registered — awaiting JWT from frontend");
// Initialize ChatState for managing in-flight conversation requests
let chat_state = std::sync::Arc::new(ChatState::new());
app.manage(chat_state);
log::info!("[chat] ChatState registered");
// Store SocketManager as Tauri state
app.manage(socket_mgr.clone());
@@ -798,6 +804,9 @@ pub fn run() {
init_memory_client,
memory_query,
recall_memory,
// Chat commands (agentic conversation loop)
chat_send,
chat_cancel,
]
}
#[cfg(not(desktop))]
@@ -918,6 +927,9 @@ pub fn run() {
init_memory_client,
memory_query,
recall_memory,
// Chat commands (agentic conversation loop)
chat_send,
chat_cancel,
]
}
})
+2 -1
View File
@@ -38,7 +38,8 @@
"icons/icon.ico"
],
"resources": [
"../skills/skills"
"../skills/skills",
"../ai"
],
"macOS": {
"minimumSystemVersion": "10.15",
+219 -48
View File
@@ -18,8 +18,18 @@ import {
type ModelInfo,
type Tool,
} from '../services/api/inferenceApi';
import {
chatCancel,
chatSend,
subscribeChatEvents,
useRustChat,
type ChatToolCallEvent,
type ChatToolResultEvent,
} from '../services/chatService';
import { useAppDispatch, useAppSelector } from '../store/hooks';
import type { NotionPageSummary, NotionSummary, NotionUserProfile } from '../store/notionSlice';
import { store } from '../store';
import { BACKEND_URL } from '../utils/config';
import {
addInferenceResponse,
addMessageLocal,
@@ -141,6 +151,17 @@ const Conversations = () => {
const [isLoadingModels, setIsLoadingModels] = useState(false);
const [isSending, setIsSending] = useState(false);
const [sendError, setSendError] = useState<string | null>(null);
const [activeToolCall, setActiveToolCall] = useState<{ name: string; args: unknown } | null>(
null
);
const rustChat = useRustChat();
// Ref to track selectedThreadId inside event callbacks without re-subscribing
const selectedThreadIdRef = useRef(selectedThreadId);
useEffect(() => {
selectedThreadIdRef.current = selectedThreadId;
}, [selectedThreadId]);
// Budget state
const [teamUsage, setTeamUsage] = useState<TeamUsage | null>(null);
const [isLoadingBudget, setIsLoadingBudget] = useState(false);
@@ -288,6 +309,78 @@ const Conversations = () => {
}
}, [inputValue, sendError]);
// Subscribe to Rust chat events when running in Tauri.
// Registered ONCE (deps: [rustChat]) — uses refs for values that change.
useEffect(() => {
if (!rustChat) return;
let cleanup: (() => void) | null = null;
let mounted = true;
subscribeChatEvents({
onToolCall: (event: ChatToolCallEvent) => {
if (event.thread_id !== selectedThreadIdRef.current) return;
setActiveToolCall({ name: event.tool_name, args: event.args });
},
onToolResult: (event: ChatToolResultEvent) => {
if (event.thread_id !== selectedThreadIdRef.current) return;
setActiveToolCall(null);
},
onDone: event => {
// Guard against duplicate dispatch (React StrictMode double-fires effects in dev)
const currentState = store.getState() as {
thread: { messagesByThreadId: Record<string, ThreadMessage[]> };
};
const threadMessages = currentState.thread.messagesByThreadId[event.thread_id] || [];
const lastMsg = threadMessages[threadMessages.length - 1];
if (lastMsg?.sender === 'agent' && lastMsg?.content === event.full_response) {
return; // Already added — skip duplicate
}
dispatch(addInferenceResponse({ content: event.full_response, threadId: event.thread_id }));
setIsSending(false);
setActiveToolCall(null);
dispatch(setActiveThread(null));
},
onError: event => {
if (event.thread_id !== selectedThreadIdRef.current) return;
if (event.error_type !== 'cancelled') {
setSendError(event.message);
}
setIsSending(false);
setActiveToolCall(null);
dispatch(setActiveThread(null));
// Remove the optimistic user message on error
dispatch((innerDispatch, getState) => {
const state = getState() as {
thread: { messagesByThreadId: Record<string, ThreadMessage[]> };
};
const persistedMessages = state.thread.messagesByThreadId[event.thread_id] || [];
const lastUserIdx = [...persistedMessages]
.reverse()
.findIndex(m => m.sender === 'user');
if (lastUserIdx !== -1) {
const actualIdx = persistedMessages.length - 1 - lastUserIdx;
const updated = persistedMessages.filter((_, i) => i !== actualIdx);
innerDispatch(updateMessagesForThread({ threadId: event.thread_id, messages: updated }));
if (event.thread_id === selectedThreadIdRef.current) {
innerDispatch(setSelectedThread(event.thread_id));
}
}
});
},
}).then(fn => {
if (mounted) cleanup = fn;
});
return () => {
mounted = false;
cleanup?.();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rustChat]);
const handleSelectThread = (threadId: string) => {
if (threadId === selectedThreadId) return;
navigate(`/conversations/${threadId}`, { replace: true });
@@ -321,49 +414,14 @@ const Conversations = () => {
}
};
const handleSendMessage = async (text?: string) => {
const trimmed = text ?? inputValue.trim();
if (!trimmed || !selectedThreadId || isSending) return;
// Check if another thread is already sending
if (activeThreadId && activeThreadId !== selectedThreadId) {
return; // Block sending from non-active threads
}
// Store the original thread ID to ensure response goes to correct thread
const sendingThreadId = selectedThreadId;
// Create stable user message and persist immediately
const userMessage: ThreadMessage = {
id: `msg_${Date.now()}_${Math.random()}`,
content: trimmed,
type: 'text',
extraMetadata: {},
sender: 'user',
createdAt: new Date().toISOString(),
};
// Immediately persist user message to both current view and persistent storage
dispatch(addMessageLocal({ threadId: sendingThreadId, message: userMessage }));
// Update current view if this is the selected thread
if (sendingThreadId === selectedThreadId) {
// Message is already added to persistent storage, reload current view
dispatch(setSelectedThread(sendingThreadId));
}
// Snapshot history for AI request (excluding the just-added user message since we'll add it manually)
const historySnapshot = messages.filter(
m => !m.id.startsWith('optimistic-') && m.id !== userMessage.id
);
setInputValue('');
setSendError(null);
setIsSending(true);
// Set this thread as active
dispatch(setActiveThread(sendingThreadId));
// Web fallback: the original orchestration logic, preserved exactly as-is.
// Called when not running in Tauri.
const handleSendMessageWeb = async (
sendingThreadId: string,
trimmed: string,
userMessage: ThreadMessage,
historySnapshot: ThreadMessage[]
) => {
// Safety-net timeout: force-clear loading states if everything hangs
const OVERALL_TIMEOUT_MS = 240_000;
const safetyTimeout = setTimeout(() => {
@@ -387,7 +445,7 @@ const Conversations = () => {
const { invoke } = await import('@tauri-apps/api/core');
const recalledContext = await invoke<string | null>('recall_memory', {
skillId: 'conversations',
integrationId: selectedThreadId,
integrationId: sendingThreadId,
maxChunks: 10,
});
if (recalledContext) {
@@ -519,7 +577,10 @@ const Conversations = () => {
skillManager.callTool(skillId, toolName, toolArgs),
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error(`Tool "${toolName}" timed out after ${TOOL_TIMEOUT_MS / 1000}s`)),
() =>
reject(
new Error(`Tool "${toolName}" timed out after ${TOOL_TIMEOUT_MS / 1000}s`)
),
TOOL_TIMEOUT_MS
)
),
@@ -575,17 +636,19 @@ const Conversations = () => {
} catch (err) {
// Remove the user message from persistent storage on error
// We'll use a thunk-like approach to access current state
dispatch((dispatch, getState) => {
dispatch((innerDispatch, getState) => {
const state = getState() as {
thread: { messagesByThreadId: Record<string, ThreadMessage[]> };
};
const persistedMessages = state.thread.messagesByThreadId[sendingThreadId] || [];
const currentMessages = persistedMessages.filter(m => m.id !== userMessage.id);
dispatch(updateMessagesForThread({ threadId: sendingThreadId, messages: currentMessages }));
innerDispatch(
updateMessagesForThread({ threadId: sendingThreadId, messages: currentMessages })
);
// Also remove from current view if this is the selected thread
if (sendingThreadId === selectedThreadId) {
dispatch(setSelectedThread(sendingThreadId));
innerDispatch(setSelectedThread(sendingThreadId));
}
});
@@ -602,6 +665,96 @@ const Conversations = () => {
}
};
const handleSendMessage = async (text?: string) => {
const trimmed = text ?? inputValue.trim();
if (!trimmed || !selectedThreadId || isSending) return;
// Check if another thread is already sending
if (activeThreadId && activeThreadId !== selectedThreadId) {
return; // Block sending from non-active threads
}
// Store the original thread ID to ensure response goes to correct thread
const sendingThreadId = selectedThreadId;
// Create stable user message and persist immediately
const userMessage: ThreadMessage = {
id: `msg_${Date.now()}_${Math.random()}`,
content: trimmed,
type: 'text',
extraMetadata: {},
sender: 'user',
createdAt: new Date().toISOString(),
};
// Immediately persist user message to both current view and persistent storage
dispatch(addMessageLocal({ threadId: sendingThreadId, message: userMessage }));
// Update current view if this is the selected thread
if (sendingThreadId === selectedThreadId) {
// Message is already added to persistent storage, reload current view
dispatch(setSelectedThread(sendingThreadId));
}
// Snapshot history for AI request (excluding the just-added user message since we'll add it manually)
const historySnapshot = messages.filter(
m => !m.id.startsWith('optimistic-') && m.id !== userMessage.id
);
setInputValue('');
setSendError(null);
setIsSending(true);
// Set this thread as active
dispatch(setActiveThread(sendingThreadId));
if (rustChat) {
// ── Rust path ────────────────────────────────────────────────────────
try {
const chatMessages = historySnapshot.map(m => ({
role: m.sender === 'user' ? 'user' : 'assistant',
content: m.content,
}));
const notionCtx = buildNotionContext(
notionProfile,
notionPages,
notionSummaries,
notionWorkspaceName
);
const authToken = (store.getState() as { auth: { token: string | null } }).auth.token;
if (!authToken) {
setSendError('Not authenticated');
setIsSending(false);
dispatch(setActiveThread(null));
return;
}
await chatSend({
threadId: sendingThreadId,
message: trimmed,
model: selectedModel,
authToken,
backendUrl: BACKEND_URL,
messages: chatMessages,
notionContext: notionCtx,
});
// setIsSending(false) and setActiveThread(null) happen in the onDone/onError event handlers
} catch (err) {
// invoke() itself failed (the chat loop reports errors via events)
const msg = err instanceof Error ? err.message : String(err);
setSendError(msg);
setIsSending(false);
dispatch(setActiveThread(null));
}
} else {
// ── Web fallback (existing orchestration logic) ───────────────────────
await handleSendMessageWeb(sendingThreadId, trimmed, userMessage, historySnapshot);
}
};
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
@@ -1010,6 +1163,24 @@ const Conversations = () => {
</div>
</div>
)}
{/* Tool call indicator — shown when Rust backend is executing a tool */}
{activeToolCall && isSending && (
<div className="flex items-center gap-2 px-1 py-1 text-xs text-stone-400">
<span className="animate-pulse">Running tool: {activeToolCall.name}</span>
</div>
)}
{/* Cancel button — shown when Rust backend is processing */}
{isSending && rustChat && (
<div className="flex justify-start px-1">
<button
onClick={() => {
if (selectedThreadId) void chatCancel(selectedThreadId);
}}
className="text-xs text-stone-400 hover:text-stone-200 transition-colors">
Cancel
</button>
</div>
)}
<div ref={messagesEndRef} />
</div>
) : (
+130
View File
@@ -0,0 +1,130 @@
/**
* Chat Service — sends messages via Rust backend (Tauri) or falls back to
* frontend-driven orchestration (web mode).
*/
import { isTauri as coreIsTauri, invoke } from '@tauri-apps/api/core';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
// ─── Event payload types (must match Rust structs exactly — snake_case) ───────
export interface ChatToolCallEvent {
thread_id: string;
tool_name: string;
skill_id: string;
args: Record<string, unknown>;
round: number;
}
export interface ChatToolResultEvent {
thread_id: string;
tool_name: string;
skill_id: string;
output: string;
success: boolean;
round: number;
}
export interface ChatDoneEvent {
thread_id: string;
full_response: string;
rounds_used: number;
total_input_tokens: number;
total_output_tokens: number;
}
export interface ChatErrorEvent {
thread_id: string;
message: string;
error_type: 'network' | 'timeout' | 'tool_error' | 'inference' | 'cancelled';
round: number | null;
}
// ─── Listener setup ───────────────────────────────────────────────────────────
export interface ChatEventListeners {
onToolCall?: (event: ChatToolCallEvent) => void;
onToolResult?: (event: ChatToolResultEvent) => void;
onDone?: (event: ChatDoneEvent) => void;
onError?: (event: ChatErrorEvent) => void;
}
/**
* Subscribe to chat events from the Rust backend.
* Returns a cleanup function that removes all listeners.
* Only works in Tauri mode.
*/
export async function subscribeChatEvents(listeners: ChatEventListeners): Promise<() => void> {
const unlisteners: UnlistenFn[] = [];
if (listeners.onToolCall) {
const cb = listeners.onToolCall;
unlisteners.push(await listen<ChatToolCallEvent>('chat:tool_call', e => cb(e.payload)));
}
if (listeners.onToolResult) {
const cb = listeners.onToolResult;
unlisteners.push(await listen<ChatToolResultEvent>('chat:tool_result', e => cb(e.payload)));
}
if (listeners.onDone) {
const cb = listeners.onDone;
unlisteners.push(await listen<ChatDoneEvent>('chat:done', e => cb(e.payload)));
}
if (listeners.onError) {
const cb = listeners.onError;
unlisteners.push(await listen<ChatErrorEvent>('chat:error', e => cb(e.payload)));
}
return () => {
for (const unlisten of unlisteners) {
unlisten();
}
};
}
// ─── Send message ─────────────────────────────────────────────────────────────
export interface ChatSendParams {
threadId: string;
message: string;
model: string;
authToken: string;
backendUrl: string;
messages: Array<{
role: string;
content: string;
tool_calls?: unknown[];
tool_call_id?: string;
}>;
notionContext?: string | null;
}
/**
* Send a message via the Rust chat_send command.
* Returns immediately — results arrive via events.
* Tauri v2 converts camelCase param names to snake_case for the Rust command.
*/
export async function chatSend(params: ChatSendParams): Promise<void> {
await invoke('chat_send', {
threadId: params.threadId,
message: params.message,
model: params.model,
authToken: params.authToken,
backendUrl: params.backendUrl,
messages: params.messages,
notionContext: params.notionContext ?? null,
});
}
/**
* Cancel an in-flight chat request.
*/
export async function chatCancel(threadId: string): Promise<boolean> {
return await invoke<boolean>('chat_cancel', { threadId });
}
/**
* Check if we should use the Rust backend for chat.
* Returns true when running in Tauri on desktop.
*/
export function useRustChat(): boolean {
return coreIsTauri();
}
+81 -5
View File
@@ -319,6 +319,11 @@
resolved "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz"
integrity sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==
"@dimforge/rapier3d-compat@~0.12.0":
version "0.12.0"
resolved "https://registry.yarnpkg.com/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz#7b3365e1dfdc5cd957b45afe920b4ac06c7cd389"
integrity sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==
"@esbuild/aix-ppc64@0.27.2":
version "0.27.2"
resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz#521cbd968dcf362094034947f76fa1b18d2d403c"
@@ -1461,6 +1466,11 @@
minimatch "^9.0.0"
parse-imports-exports "^0.2.4"
"@tweenjs/tween.js@~23.1.3":
version "23.1.3"
resolved "https://registry.yarnpkg.com/@tweenjs/tween.js/-/tween.js-23.1.3.tgz#eff0245735c04a928bb19c026b58c2a56460539d"
integrity sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==
"@types/aria-query@^5.0.1":
version "5.0.4"
resolved "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz"
@@ -1661,11 +1671,29 @@
resolved "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz"
integrity sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==
"@types/stats.js@*":
version "0.17.4"
resolved "https://registry.yarnpkg.com/@types/stats.js/-/stats.js-0.17.4.tgz#1933e5ff153a23c7664487833198d685c22e791e"
integrity sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==
"@types/statuses@^2.0.6":
version "2.0.6"
resolved "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz"
integrity sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==
"@types/three@^0.183.1":
version "0.183.1"
resolved "https://registry.yarnpkg.com/@types/three/-/three-0.183.1.tgz#d812d028b38ad68843725e3e7bd3268607cef150"
integrity sha512-f2Pu5Hrepfgavttdye3PsH5RWyY/AvdZQwIVhrc4uNtvF7nOWJacQKcoVJn0S4f0yYbmAE6AR+ve7xDcuYtMGw==
dependencies:
"@dimforge/rapier3d-compat" "~0.12.0"
"@tweenjs/tween.js" "~23.1.3"
"@types/stats.js" "*"
"@types/webxr" ">=0.5.17"
"@webgpu/types" "*"
fflate "~0.8.2"
meshoptimizer "~1.0.1"
"@types/unist@*", "@types/unist@^3.0.0":
version "3.0.3"
resolved "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz"
@@ -1681,6 +1709,11 @@
resolved "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz"
integrity sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==
"@types/webxr@>=0.5.17":
version "0.5.24"
resolved "https://registry.yarnpkg.com/@types/webxr/-/webxr-0.5.24.tgz#734d5d90dadc5809a53e422726c60337fa2f4a44"
integrity sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==
"@types/which@^2.0.1":
version "2.0.2"
resolved "https://registry.npmjs.org/@types/which/-/which-2.0.2.tgz"
@@ -2109,6 +2142,11 @@
dependencies:
"@wdio/logger" "9.18.0"
"@webgpu/types@*":
version "0.1.69"
resolved "https://registry.yarnpkg.com/@webgpu/types/-/types-0.1.69.tgz#6b849bf370a1f29c78bd3aeba8e84c1150b237f2"
integrity sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==
"@xmldom/xmldom@^0.9.5":
version "0.9.8"
resolved "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.8.tgz"
@@ -4127,6 +4165,11 @@ fdir@^6.5.0:
resolved "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz"
integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==
fflate@~0.8.2:
version "0.8.2"
resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.8.2.tgz#fc8631f5347812ad6028bbe4a2308b2792aa1dea"
integrity sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==
figures@^6.1.0:
version "6.1.0"
resolved "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz"
@@ -5594,6 +5637,11 @@ merge2@^1.3.0:
resolved "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz"
integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==
meshoptimizer@~1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/meshoptimizer/-/meshoptimizer-1.0.1.tgz#c3ef0d509a8b84ac562493dba5a108fd67fa76dc"
integrity sha512-Vix+QlA1YYT3FwmBBZ+49cE5y/b+pRrcXKqGpS5ouh33d3lSp2PoTpCw19E0cKDFWalembrHnIaZetf27a+W2g==
micromark-core-commonmark@^2.0.0:
version "2.0.3"
resolved "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz"
@@ -7400,7 +7448,16 @@ strict-event-emitter@^0.5.1:
resolved "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz"
integrity sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
"string-width-cjs@npm:string-width@^4.2.0":
version "4.2.3"
resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"
string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -7499,8 +7556,14 @@ stringify-entities@^4.0.0:
character-entities-html4 "^2.0.0"
character-entities-legacy "^3.0.0"
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
name strip-ansi-cjs
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
version "6.0.1"
resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies:
ansi-regex "^5.0.1"
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@@ -7689,6 +7752,11 @@ thenify-all@^1.0.0:
dependencies:
any-promise "^1.0.0"
three@^0.183.2:
version "0.183.2"
resolved "https://registry.yarnpkg.com/three/-/three-0.183.2.tgz#606e3195bf210ef8d1eaaca2ab8c59d92d2bbc18"
integrity sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==
timers-browserify@^2.0.4:
version "2.0.12"
resolved "https://registry.npmjs.org/timers-browserify/-/timers-browserify-2.0.12.tgz"
@@ -8345,8 +8413,7 @@ workerpool@^6.5.1:
resolved "https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz"
integrity sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
name wrap-ansi-cjs
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
version "7.0.0"
resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
@@ -8364,6 +8431,15 @@ wrap-ansi@^6.2.0:
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
dependencies:
ansi-styles "^4.0.0"
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^8.1.0:
version "8.1.0"
resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz"