Added AI via Prompt API & WebLLM

This commit is contained in:
Dustin Brett 2024-08-12 23:07:42 -07:00
parent 8e35a879a7
commit 983fc5f6fc
25 changed files with 1529 additions and 20 deletions

View File

@ -7,7 +7,7 @@ import { useProcesses } from "contexts/process";
import { loadFiles } from "utils/functions"; import { loadFiles } from "utils/functions";
import { useLinkHandler } from "hooks/useLinkHandler"; import { useLinkHandler } from "hooks/useLinkHandler";
type MarkedOptions = { export type MarkedOptions = {
headerIds: boolean; headerIds: boolean;
mangle: boolean; mangle: boolean;
}; };

View File

@ -0,0 +1,36 @@
import {
AI_STAGE,
AI_TITLE,
WINDOW_ID,
} from "components/system/Taskbar/AI/constants";
import { AIIcon } from "components/system/Taskbar/AI/icons";
import StyledAIButton from "components/system/Taskbar/AI/StyledAIButton";
import { DIV_BUTTON_PROPS } from "utils/constants";
import { label } from "utils/functions";
import useTaskbarContextMenu from "components/system/Taskbar/useTaskbarContextMenu";
import { useSession } from "contexts/session";
type AIButtonProps = {
aiVisible: boolean;
toggleAI: () => void;
};
const AIButton: FC<AIButtonProps> = ({ aiVisible, toggleAI }) => {
const { removeFromStack } = useSession();
return (
<StyledAIButton
onClick={() => {
toggleAI();
if (aiVisible) removeFromStack(WINDOW_ID);
}}
{...DIV_BUTTON_PROPS}
{...label(`${AI_TITLE} (${AI_STAGE})`)}
{...useTaskbarContextMenu()}
>
<AIIcon />
</StyledAIButton>
);
};
export default AIButton;

View File

@ -0,0 +1,417 @@
import { useTheme } from "styled-components";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
formatWebLlmProgress,
speakMessage,
} from "components/system/Taskbar/AI/functions";
import {
AIIcon,
ChatIcon,
CopyIcon,
EditIcon,
PersonIcon,
SendFilledIcon,
SendIcon,
SpeakIcon,
StopIcon,
WarningIcon,
} from "components/system/Taskbar/AI/icons";
import useAITransition from "components/system/Taskbar/AI/useAITransition";
import {
AI_STAGE,
AI_TITLE,
AI_WORKER,
DEFAULT_CONVO_STYLE,
WINDOW_ID,
} from "components/system/Taskbar/AI/constants";
import StyledAIChat from "components/system/Taskbar/AI/StyledAIChat";
import { CloseIcon } from "components/system/Window/Titlebar/WindowActionIcons";
import Button from "styles/common/Button";
import { label, viewWidth } from "utils/functions";
import { PREVENT_SCROLL } from "utils/constants";
import {
type MessageTypes,
type ConvoStyles,
type Message,
type WorkerResponse,
type WebLlmProgress,
type AIResponse,
} from "components/system/Taskbar/AI/types";
import useWorker from "hooks/useWorker";
import useFocusable from "components/system/Window/useFocusable";
import { useSession } from "contexts/session";
import { useWindowAI } from "hooks/useWindowAI";
type AIChatProps = {
toggleAI: () => void;
};
const AIChat: FC<AIChatProps> = ({ toggleAI }) => {
const {
colors: { taskbar: taskbarColor },
sizes: { taskbar: taskbarSize },
} = useTheme();
const getFullWidth = useCallback(
() => Math.min(taskbarSize.ai.chatWidth, viewWidth()),
[taskbarSize.ai.chatWidth]
);
const [fullWidth, setFullWidth] = useState(getFullWidth);
const aiTransition = useAITransition(fullWidth);
const [convoStyle, setConvoStyle] = useState(DEFAULT_CONVO_STYLE);
const [primaryColor, secondaryColor, tertiaryColor] =
taskbarColor.ai[convoStyle];
const [promptText, setPromptText] = useState("");
const textAreaRef = useRef<HTMLTextAreaElement>(null);
const sectionRef = useRef<HTMLDivElement>(null);
const typing = promptText.length > 0;
const [conversation, setConversation] = useState<Message[]>([]);
const addMessage = useCallback(
(
text: string | undefined,
type: MessageTypes,
formattedText?: string
): void => {
if (text) {
setConversation((prevMessages) => [
...prevMessages,
{ formattedText: formattedText || text, text, type },
]);
}
},
[]
);
const addUserPrompt = useCallback(() => {
if (promptText) {
addMessage(promptText, "user");
(textAreaRef.current as HTMLTextAreaElement).value = "";
setPromptText("");
}
}, [addMessage, promptText]);
const lastAiMessageIndex = useMemo(
() =>
conversation.length -
[...conversation].reverse().findIndex(({ type }) => type === "ai") -
1,
[conversation]
);
const [responding, setResponding] = useState(false);
const [canceling, setCanceling] = useState(false);
const [failedSession, setFailedSession] = useState(false);
const sessionIdRef = useRef<number>(0);
const hasWindowAI = useWindowAI();
const aiWorker = useWorker<void>(AI_WORKER);
const stopResponse = useCallback(() => {
if (aiWorker.current && responding) {
aiWorker.current.postMessage("cancel");
setCanceling(true);
}
}, [aiWorker, responding]);
const newTopic = useCallback(() => {
stopResponse();
sessionIdRef.current = 0;
setConversation([]);
setFailedSession(false);
}, [stopResponse]);
const changeConvoStyle = useCallback(
(newConvoStyle: ConvoStyles) => {
if (convoStyle !== newConvoStyle) {
newTopic();
setConvoStyle(newConvoStyle);
textAreaRef.current?.focus(PREVENT_SCROLL);
}
},
[convoStyle, newTopic]
);
const [containerElement, setContainerElement] =
useState<HTMLElement | null>();
const { removeFromStack } = useSession();
const { zIndex, ...focusableProps } = useFocusable(
WINDOW_ID,
undefined,
containerElement
);
const scrollbarVisible = useMemo(
() =>
conversation.length > 0 &&
sectionRef.current instanceof HTMLElement &&
sectionRef.current.scrollHeight > sectionRef.current.clientHeight,
[conversation.length]
);
const [copiedIndex, setCopiedIndex] = useState(-1);
const [progressMessage, setProgressMessage] = useState<string>("");
const autoSizeText = useCallback(() => {
const textArea = textAreaRef.current as HTMLTextAreaElement;
textArea.style.height = "auto";
textArea.style.height = `${textArea.scrollHeight}px`;
}, []);
useEffect(() => {
textAreaRef.current?.focus(PREVENT_SCROLL);
}, []);
useEffect(() => {
const updateFullWidth = (): void => setFullWidth(getFullWidth);
window.addEventListener("resize", updateFullWidth);
return () => window.removeEventListener("resize", updateFullWidth);
}, [getFullWidth]);
useEffect(() => {
if (conversation.length > 0 || failedSession) {
requestAnimationFrame(() =>
sectionRef.current?.scrollTo({
behavior: "smooth",
top: sectionRef.current.scrollHeight,
})
);
}
}, [conversation, failedSession]);
useEffect(() => {
if (
aiWorker.current &&
conversation.length > 0 &&
conversation[conversation.length - 1].type === "user"
) {
const { text } = conversation[conversation.length - 1];
setResponding(true);
sessionIdRef.current ||= Date.now();
aiWorker.current.postMessage({
hasWindowAI,
id: sessionIdRef.current,
style: convoStyle,
text,
});
}
}, [aiWorker, conversation, convoStyle, hasWindowAI]);
useEffect(() => {
const workerRef = aiWorker.current;
const workerResponse = ({ data }: WorkerResponse): void => {
const doneResponding = typeof data === "string" || "response" in data;
setResponding(!doneResponding);
if (data === "canceled") {
setCanceling(false);
} else if ((data as WebLlmProgress).progress) {
const {
progress: { text },
} = data as WebLlmProgress;
setProgressMessage(formatWebLlmProgress(text));
} else if ((data as AIResponse).response) {
const { formattedResponse, response } = data as AIResponse;
addMessage(response, "ai", formattedResponse);
} else if ((data as AIResponse).response === "") {
setFailedSession(true);
}
};
workerRef?.addEventListener("message", workerResponse);
return () => workerRef?.removeEventListener("message", workerResponse);
}, [addMessage, aiWorker]);
return (
<StyledAIChat
ref={setContainerElement}
$primaryColor={primaryColor}
$scrollbarVisible={scrollbarVisible}
$secondaryColor={secondaryColor}
$tertiaryColor={tertiaryColor}
$typing={typing}
$width={fullWidth}
$zIndex={zIndex}
{...aiTransition}
{...focusableProps}
>
<div className="header">
<header>
{`${AI_TITLE} (${AI_STAGE})`}
<nav>
<Button
className="close"
onClick={() => {
toggleAI();
removeFromStack(WINDOW_ID);
}}
{...label("Close")}
>
<CloseIcon />
</Button>
</nav>
</header>
</div>
<section ref={sectionRef}>
<div className="convo-header">
<div className="title">
<AIIcon /> {AI_TITLE}
</div>
<div className="convo-style">
Choose a conversation style
<div className="buttons">
<button
className={convoStyle === "creative" ? "selected" : ""}
onClick={() => changeConvoStyle("creative")}
type="button"
{...label("Start an original and imaginative chat")}
>
<h4>More</h4>
<h2>Creative</h2>
</button>
<button
className={convoStyle === "balanced" ? "selected" : ""}
onClick={() => changeConvoStyle("balanced")}
type="button"
{...label("For everyday, informed chats")}
>
<h4>More</h4>
<h2>Balanced</h2>
</button>
<button
className={convoStyle === "precise" ? "selected" : ""}
onClick={() => changeConvoStyle("precise")}
type="button"
{...label("Start a concise chat, useful for fact-finding")}
>
<h4>More</h4>
<h2>Precise</h2>
</button>
</div>
</div>
</div>
<div className="conversation">
{conversation.map(({ formattedText, type, text }, index) => (
// eslint-disable-next-line react/no-array-index-key
<div key={index} className={type}>
{(index === 0 || conversation[index - 1].type !== type) && (
<div className="avatar">
{type === "user" ? <PersonIcon /> : <AIIcon />}
{type === "user" ? "You" : "AI"}
</div>
)}
<div
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: formattedText }}
className="message"
/>
<div
className={`controls${index === lastAiMessageIndex ? " last" : ""}${responding && index === conversation.length - 1 ? " hidden" : ""}`}
>
<button
className="copy"
onClick={() => {
navigator.clipboard?.writeText(text);
setCopiedIndex(index);
setTimeout(() => setCopiedIndex(-1), 5000);
}}
type="button"
{...label(copiedIndex === index ? "Copied" : "Copy")}
>
<CopyIcon />
</button>
{type === "user" && (
<button
className="edit"
onClick={() => {
if (textAreaRef.current) {
textAreaRef.current.value = text;
textAreaRef.current.focus(PREVENT_SCROLL);
setPromptText(text);
}
}}
type="button"
{...label("Edit")}
>
<EditIcon />
</button>
)}
{"speechSynthesis" in window && type === "ai" && (
<button
className="speak"
onClick={() => speakMessage(text)}
type="button"
{...label("Read aloud")}
>
<SpeakIcon />
</button>
)}
</div>
</div>
))}
{responding && (
<div className="responding">
<button
className={`stop${canceling ? " canceling" : ""}`}
disabled={Boolean(progressMessage) || canceling}
onClick={stopResponse}
type="button"
>
{!progressMessage && !canceling && <StopIcon />}
{canceling ? "Canceling" : progressMessage || "Stop Responding"}
</button>
</div>
)}
{failedSession && (
<div className="failed-session">
<WarningIcon />
It might be time to move onto a new topic.
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid, jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<a onClick={newTopic}>Let&apos;s start over.</a>
</div>
)}
</div>
</section>
<footer>
<textarea
ref={textAreaRef}
disabled={failedSession}
onBlur={autoSizeText}
onChange={(event) => {
setPromptText(event.target.value);
autoSizeText();
}}
onFocus={autoSizeText}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
if (!canceling && !responding) addUserPrompt();
}
autoSizeText();
}}
placeholder="Ask me anything..."
/>
<button
className="new-topic"
onClick={newTopic}
type="button"
{...label("New topic")}
>
<ChatIcon />
</button>
<button
className="submit"
disabled={canceling || responding}
{...(typing && {
onClick: addUserPrompt,
})}
type="button"
{...(!canceling && typing ? label("Submit") : undefined)}
>
{!canceling && typing ? <SendFilledIcon /> : <SendIcon />}
</button>
</footer>
</StyledAIChat>
);
};
export default AIChat;

View File

@ -0,0 +1,26 @@
import styled from "styled-components";
const StyledAIButton = styled.div`
display: flex;
height: 100%;
place-content: center;
place-items: center;
position: absolute;
right: 0;
width: ${({ theme }) => theme.sizes.taskbar.ai.buttonWidth};
svg {
height: 22px;
width: 22px;
}
&:hover {
background-color: ${({ theme }) => theme.colors.taskbar.hover};
}
&:active {
background-color: ${({ theme }) => theme.colors.taskbar.foreground};
}
`;
export default StyledAIButton;

View File

@ -0,0 +1,490 @@
import { m as motion } from "framer-motion";
import styled from "styled-components";
import { TASKBAR_HEIGHT } from "utils/constants";
type StyledAIChatProps = {
$primaryColor: string;
$scrollbarVisible: boolean;
$secondaryColor: string;
$tertiaryColor: string;
$typing: boolean;
$width: number;
$zIndex: number;
};
const StyledAIChat = styled(motion.section)<StyledAIChatProps>`
background-color: rgb(32, 32, 32);
border-left: 1px solid rgb(104, 104, 104);
bottom: ${TASKBAR_HEIGHT}px;
color: rgb(200, 200, 200);
font-size: 14px;
height: calc(100% - ${TASKBAR_HEIGHT}px);
position: absolute;
right: 0;
z-index: ${({ $zIndex }) => $zIndex};
section {
&::-webkit-scrollbar {
width: 16px;
}
&::-webkit-scrollbar-track {
background-color: transparent;
}
&::-webkit-scrollbar-thumb {
background-clip: content-box;
background-color: rgb(77, 77, 77);
border: 6px solid transparent;
border-radius: 16px;
}
&::-webkit-scrollbar-thumb:hover {
background-color: rgb(121, 121, 121);
}
display: flex;
flex-direction: column;
height: calc(100% - 49px - 122px);
min-width: ${({ $width }) => $width - 1}px;
overflow: hidden auto;
place-content: space-between;
.convo-header {
color: #fff;
display: flex;
flex-direction: column;
gap: 20px;
place-content: center;
place-items: center;
width: ${({ $scrollbarVisible }) =>
$scrollbarVisible ? "100%" : "calc(100% + 13px)"};
.title {
display: flex;
font-size: 28px;
font-weight: 700;
> svg {
height: 34px;
margin-right: 8px;
margin-top: 3px;
width: 34px;
}
}
.convo-style {
display: flex;
flex-direction: column;
gap: 17px;
place-content: center;
place-items: center;
.buttons {
border: 1px solid rgb(102, 102, 102);
border-radius: 4px;
display: flex;
margin-bottom: 48px;
button {
background-color: transparent;
border-radius: 4px;
color: #fff;
cursor: pointer;
display: flex;
flex-direction: column;
max-width: 100px;
min-width: 100px;
padding: 7px 28px;
place-items: center;
&.selected {
background: ${({ $secondaryColor, $tertiaryColor }) =>
`linear-gradient(135deg, ${$secondaryColor} 0%, ${$tertiaryColor} 100%)`};
}
}
h2,
h4 {
font-weight: 400;
pointer-events: none;
}
h2 {
font-size: 12.5px;
}
h4 {
font-size: 9.5px;
padding-bottom: 3px;
}
}
}
}
.conversation {
color: #fff;
display: flex;
flex-direction: column;
font-size: 13.5px;
gap: 13px;
letter-spacing: 0.2px;
padding: 16px;
padding-bottom: 5px;
.user {
.avatar {
.person {
background: ${({ $tertiaryColor }) => $tertiaryColor};
border-radius: 50%;
fill: rgb(255, 255, 255, 45%);
padding: 5px;
}
}
}
.avatar {
display: flex;
font-size: 15px;
padding-bottom: 12px;
place-items: center;
svg {
height: 24px;
margin-right: 12px;
width: 24px;
}
}
.message {
cursor: text;
padding-left: 36px;
user-select: text;
white-space: pre-line;
* {
cursor: text;
user-select: text;
}
pre {
background-color: rgb(26, 26, 26);
border: 1px solid rgb(48, 48, 48);
border-radius: 5px;
padding: 12px;
}
code {
white-space: pre-wrap;
&.language-js,
&.language-python {
&::before {
background-color: rgb(29, 29, 29);
border-top-left-radius: 5px;
border-top-right-radius: 5px;
display: flex;
font-family: ${({ theme }) => theme.formats.systemFont};
font-size: 15px;
font-weight: 600;
height: 40px;
left: -12px;
padding: 0 12px;
place-items: center;
position: relative;
top: -12px;
width: calc(100% + 24px);
}
}
&.language-js::before {
content: "JavaScript";
}
&.language-python::before {
content: "Python";
}
}
&:hover {
+ .controls button {
visibility: visible;
}
}
p:last-child {
display: inline;
}
pre:last-child {
display: inline-block;
}
}
.responding {
display: flex;
margin-top: -6px;
place-content: center;
place-items: center;
width: calc(100% + 13px);
.stop {
background-color: rgb(45, 45, 45);
border: ${({ $primaryColor }) => `1px solid ${$primaryColor}`};
border-radius: 8px;
color: #fff;
cursor: pointer;
display: flex;
font-size: 13px;
letter-spacing: 0.3px;
min-height: 36px;
padding: 8px;
place-content: center;
place-items: center;
&:hover {
background-color: rgb(50, 50, 50);
}
&.canceling {
background-color: rgb(42, 42, 42);
}
.stop-icon {
fill: ${({ $primaryColor }) => $primaryColor};
height: 18px;
margin-right: 6px;
width: 18px;
}
}
}
.failed-session {
display: flex;
font-size: 12px;
margin-bottom: 10px;
place-content: center;
place-items: center;
width: ${({ $scrollbarVisible }) =>
$scrollbarVisible ? "100%" : "calc(100% + 13px)"};
&::before,
&::after {
border-bottom: 1px solid rgb(48, 48, 48);
content: "";
flex: 1 1 0%;
margin-top: 3px;
}
&::before {
margin-inline-end: 5px;
}
&::after {
margin-inline-start: 5px;
}
a {
color: ${({ $primaryColor }) => $primaryColor};
cursor: pointer;
margin-left: 4px;
}
.warning-icon {
fill: ${({ $primaryColor }) => $primaryColor};
height: 18px;
margin-right: 4px;
margin-top: 2px;
width: 18px;
}
}
.controls {
padding-left: 36px;
padding-top: 11px;
pointer-events: all;
position: relative;
&.hidden {
display: none;
}
.copy,
.edit,
.speak {
background-color: transparent;
border-radius: 5px;
height: 32px;
visibility: hidden;
width: 32px;
&:hover {
background-color: rgb(45, 45, 45);
border: 1px solid rgb(65, 65, 65);
}
&:active {
background-color: rgb(42, 42, 42);
}
.copy-icon,
.edit-icon,
.speak-icon {
fill: #fff;
height: 20px;
width: 20px;
}
}
&:hover,
&.last {
.copy,
.edit,
.speak {
visibility: visible;
}
}
}
}
}
.header {
height: 49px;
min-width: ${({ $width }) => $width - 1}px;
padding: 14px 15px 16px;
header {
display: flex;
justify-content: space-between;
padding: 3px 1px;
nav {
position: relative;
right: -8px;
top: -9px;
.close {
border-radius: 5px;
height: 36px;
transition: background-color 0.2s ease-in-out;
width: 36px;
> svg {
fill: rgb(241, 241, 241);
width: 12px;
}
&:hover {
background-color: rgb(49, 49, 49);
}
}
}
}
}
footer {
display: flex;
flex-direction: row;
height: 122px;
min-width: ${({ $width }) => $width - 1}px;
padding: 16px 14px;
place-content: space-between;
position: absolute;
.new-topic {
background: ${({ $secondaryColor, $tertiaryColor }) =>
`linear-gradient(135deg, ${$secondaryColor} 0%, ${$tertiaryColor} 100%)`};
border-radius: 50%;
cursor: pointer;
height: 40px;
place-content: center;
place-items: center;
transition: opacity 0.1s 0.1s ease-in-out;
width: 40px;
> .chat {
fill: #fff;
height: 20px;
pointer-events: none;
position: relative;
top: 1px;
width: 20px;
}
&:active {
border: 1px solid rgb(32, 32, 32);
}
}
.submit {
background-color: transparent;
border-radius: 5px;
height: 36px;
position: relative;
right: 5px;
top: 49px;
width: 36px;
.send {
fill: ${({ $primaryColor, $typing }) =>
$typing ? $primaryColor : "rgb(99, 99, 99)"};
height: 20px;
pointer-events: none;
position: relative;
top: 1px;
width: 20px;
}
&:hover {
background-color: ${({ $typing }) =>
$typing ? "rgb(44, 44, 44)" : undefined};
cursor: ${({ $typing }) => ($typing ? "pointer" : undefined)};
}
&:active {
background-color: rgb(38, 38, 38);
}
}
textarea {
background-color: rgb(31, 31, 31);
border: 1px solid rgb(102, 102, 102);
border-radius: 7px;
bottom: 16px;
color: #fff;
font-family: ${({ theme }) => theme.formats.systemFont};
font-size: 12.5px;
letter-spacing: 0.6px;
min-height: 90px;
overflow: hidden;
padding: 13px 44px 13px 15px;
position: absolute;
resize: none;
right: 14px;
transition:
border-bottom 0.2s 0.2s ease-in-out,
width 0.2s 0.2s ease-in-out;
width: calc(100% - 80px);
&::placeholder {
color: rgb(206, 206, 206);
}
&:focus {
border-bottom: ${({ $primaryColor }) => `2px solid ${$primaryColor}`};
width: ${({ $typing }) => ($typing ? "calc(100% - 28px)" : undefined)};
& + .new-topic {
opacity: ${({ $typing }) => ($typing ? "0%" : "100%")};
transition-delay: ${({ $typing }) => ($typing ? 0.2 : 0.4)}s;
}
}
&:not(:focus) + .new-topic {
transition-delay: 0.4s;
}
}
}
`;
export default StyledAIChat;

View File

@ -0,0 +1,152 @@
import {
type ChatCompletionMessageParam,
type MLCEngine,
} from "@mlc-ai/web-llm";
import {
type WorkerMessage,
type Session,
} from "components/system/Taskbar/AI/types";
const MARKED_LIBS = [
"/Program Files/Marked/marked.min.js",
"/Program Files/Marked/purify.min.js",
];
const CONVO_STYLE_TEMPS = {
balanced: {
temperature: 0.5,
topK: 3,
},
creative: {
temperature: 0.8,
topK: 5,
},
precise: {
temperature: 0.2,
topK: 2,
},
};
const WEB_LLM_MODEL = "Llama-3.1-8B-Instruct-q4f32_1-MLC";
const WEB_LLM_MODEL_CONFIG = {
"Llama-3.1-8B-Instruct-q4f32_1-MLC": {
frequency_penalty: 0,
max_tokens: 4000,
presence_penalty: 0,
top_p: 0.9,
},
};
const WEB_LLM_SYSTEM_PROMPT: ChatCompletionMessageParam = {
content: "You are a helpful AI assistant.",
role: "system",
};
let cancel = false;
let responding = false;
let sessionId = 0;
let session: Session | ChatCompletionMessageParam[] | undefined;
let engine: MLCEngine;
let markedLoaded = false;
globalThis.addEventListener(
"message",
async ({ data }: { data: WorkerMessage | "cancel" | "init" }) => {
if (!data || data === "init") return;
if (data === "cancel") {
if (responding) cancel = true;
} else if (data.id && data.text && data.style) {
responding = true;
if (sessionId !== data.id) {
sessionId = data.id;
if (data.hasWindowAI) {
(session as Session)?.destroy();
session = await globalThis.ai.createTextSession(
CONVO_STYLE_TEMPS[data.style]
);
} else {
session = [WEB_LLM_SYSTEM_PROMPT];
if (!engine) {
const { CreateMLCEngine } = await import("@mlc-ai/web-llm");
if (!cancel) {
engine = await CreateMLCEngine(WEB_LLM_MODEL, {
initProgressCallback: (progress) =>
globalThis.postMessage({ progress }),
});
}
}
}
}
let response = "";
let retry = 0;
try {
while (retry++ < 3 && !response) {
if (cancel) break;
try {
if (data.hasWindowAI) {
// eslint-disable-next-line no-await-in-loop
response = (await (session as Session)?.prompt(data.text)) || "";
} else {
(session as ChatCompletionMessageParam[]).push({
content: data.text,
role: "user",
});
const {
choices: [{ message }],
// eslint-disable-next-line no-await-in-loop
} = await engine.chat.completions.create({
logprobs: true,
messages: session as ChatCompletionMessageParam[],
temperature: CONVO_STYLE_TEMPS[data.style].temperature,
top_logprobs: CONVO_STYLE_TEMPS[data.style].topK,
...WEB_LLM_MODEL_CONFIG[WEB_LLM_MODEL],
});
(session as ChatCompletionMessageParam[]).push(message);
response = message.content || "";
}
} catch (error) {
console.error("Failed to get prompt response.", error);
}
}
if (!response) console.error("Failed retires to create response.");
} catch (error) {
console.error("Failed to create text session.", error);
}
if (cancel) {
cancel = false;
globalThis.postMessage("canceled");
} else {
if (response && !markedLoaded) {
globalThis.importScripts(...MARKED_LIBS);
markedLoaded = true;
}
globalThis.postMessage({
formattedResponse: globalThis.marked.parse(response, {
headerIds: false,
mangle: false,
}),
response,
});
}
responding = false;
}
},
{ passive: true }
);

View File

@ -0,0 +1,15 @@
import { type ConvoStyles } from "components/system/Taskbar/AI/types";
export const AI_TITLE = "Talos";
export const AI_STAGE = "alpha";
export const DEFAULT_CONVO_STYLE: ConvoStyles = "balanced";
export const AI_WORKER = (): Worker =>
new Worker(
new URL("components/system/Taskbar/AI/ai.worker", import.meta.url),
{ name: "AI" }
);
export const WINDOW_ID = "ai-chat-window";

View File

@ -0,0 +1,57 @@
export const formatWebLlmProgress = (text: string): string => {
if (text === "Start to fetch params") return "Fetching parameters";
if (text.startsWith("Finish loading on WebGPU")) return "";
const [, progressCurrent, progressTotal] =
// eslint-disable-next-line unicorn/better-regex
/\[(\d+)\/(\d+)\]/.exec(text) || [];
let progress = "";
if (typeof Number(progressTotal) === "number") {
progress = `${progressCurrent || 0}/${progressTotal}`;
}
if (text.startsWith("Loading model from cache")) {
return `Loading${progress ? ` (${progress})` : ""}`;
}
const [, percentComplete] = /(\d+)% completed/.exec(text) || [];
const [, secsElapsed] = /(\d+) secs elapsed/.exec(text) || [];
if (typeof Number(percentComplete) === "number") {
progress += `${progress ? ", " : ""}${percentComplete}%`;
}
if (typeof Number(secsElapsed) === "number") {
progress += `${progress ? ", " : ""}${secsElapsed}s`;
}
if (text.startsWith("Loading GPU shader modules")) {
return `Loading into GPU${progress ? ` (${progress})` : ""}`;
}
const [, dataLoaded] = /(\d+)MB (fetched|loaded)/.exec(text) || [];
if (typeof Number(dataLoaded) === "number") {
progress += `${progress ? ", " : ""}${dataLoaded}MB`;
}
if (text.startsWith("Fetching param cache")) {
return `Fetching${progress ? ` (${progress})` : ""}`;
}
return text;
};
export const speakMessage = (text: string): void => {
const [voice] = window.speechSynthesis.getVoices();
const utterance = new SpeechSynthesisUtterance(text);
utterance.voice = voice;
utterance.pitch = 0.9;
utterance.rate = 1.5;
utterance.volume = 0.5;
window.speechSynthesis.speak(utterance);
};

View File

@ -0,0 +1,123 @@
import { memo } from "react";
export const AIIcon = memo(() => (
<svg viewBox="0 0 330 220" xmlns="http://www.w3.org/2000/svg">
<path
d="M177.49 123.22c7.325 13.037 15.82 22.852 25.489 29.444 9.814 6.445 20.874 9.668 33.179 9.668 14.794 0 26.88-4.908 36.255-14.722 9.374-9.961 14.062-22.632 14.062-38.013 0-14.795-4.322-27.1-12.964-36.914-8.643-9.814-19.483-14.722-32.52-14.722-11.865 0-22.632 4.908-32.3 14.722-9.521 9.668-19.921 26.514-31.2 50.537m-26.807-23.51c-7.178-12.891-15.674-22.56-25.489-29.005-9.668-6.445-20.727-9.667-33.178-9.668-14.795 0-26.88 4.908-36.255 14.722-9.375 9.668-14.063 22.193-14.063 37.573 0 14.796 4.321 27.1 12.964 36.915 8.643 9.814 19.482 14.721 32.52 14.721 11.865 0 22.558-4.834 32.08-14.502 9.668-9.668 20.141-26.587 31.42-50.757m15.601 40.21c-10.4 19.922-21.313 34.498-32.739 43.726-11.28 9.229-23.877 13.843-37.793 13.843-19.775 0-36.548-8.203-50.317-24.61C31.81 156.472 25 136.184 25 112.014c0-25.635 6.079-46.362 18.237-62.183 12.305-15.82 28.272-23.73 47.9-23.73 13.917 0 26.368 4.541 37.354 13.623 10.987 8.936 21.973 23.73 32.96 44.385 9.96-20.215 20.727-35.083 32.3-44.605 11.571-9.668 24.462-14.502 38.671-14.502 19.482 0 36.108 8.277 49.878 24.83 13.916 16.552 20.874 36.987 20.874 61.303 0 25.489-6.152 46.143-18.457 61.963-12.158 15.674-28.052 23.511-47.68 23.511-13.917 0-26.295-4.248-37.134-12.744-10.694-8.643-21.9-23.291-33.619-43.946"
fill="url(#a)"
/>
<defs>
<linearGradient
gradientUnits="objectBoundingBox"
id="a"
x1="0"
x2="1"
y1="0"
y2="1"
>
<stop offset="0" stopColor="#C02B3C">
<animate
attributeName="stop-color"
dur="20s"
repeatCount="indefinite"
values="#C02B3C;#A86EDD;#0736C4;#52B471;#FFC800;#FF5F3D;#C02B3C;"
/>
</stop>
<stop offset=".5" stopColor="#A86EDD">
<animate
attributeName="stop-color"
dur="20s"
repeatCount="indefinite"
values="#A86EDD;#0736C4;#52B471;#FFC800;#FF5F3D;#C02B3C;#A86EDD;"
/>
</stop>
<stop offset="1" stopColor="#0736C4">
<animate
attributeName="stop-color"
dur="20s"
repeatCount="indefinite"
values="#0736C4;#52B471;#FFC800;#FF5F3D;#C02B3C;#A86EDD;#0736C4;"
/>
</stop>
</linearGradient>
</defs>
</svg>
));
export const ChatIcon = memo(() => (
<svg className="chat" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2c5.523 0 10 4.477 10 10 0 .263-.01.523-.03.78a6.518 6.518 0 0 0-1.474-1.05 8.5 8.5 0 1 0-15.923 4.407l.15.27-1.112 3.984 3.987-1.112.27.15a8.449 8.449 0 0 0 3.862 1.067c.281.54.636 1.036 1.05 1.474a9.96 9.96 0 0 1-5.368-1.082l-3.825 1.067a1.25 1.25 0 0 1-1.54-1.54l1.068-3.823A9.96 9.96 0 0 1 2 12C2 6.477 6.477 2 12 2Zm11 15.5a5.5 5.5 0 1 0-11 0 5.5 5.5 0 0 0 11 0Zm-5 .5.001 2.503a.5.5 0 1 1-1 0V18h-2.505a.5.5 0 0 1 0-1H17v-2.5a.5.5 0 1 1 1 0V17h2.497a.5.5 0 0 1 0 1H18Z" />
</svg>
));
export const SendIcon = memo(() => (
<svg className="send" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M5.694 12 2.299 3.27c-.236-.607.356-1.188.942-.981l.093.039 18 9a.75.75 0 0 1 .097 1.284l-.097.058-18 9c-.583.291-1.217-.245-1.065-.848l.03-.095L5.694 12 2.299 3.27 5.694 12ZM4.402 4.54l2.61 6.71h6.627a.75.75 0 0 1 .743.648l.007.102a.75.75 0 0 1-.649.743l-.101.007H7.01l-2.609 6.71L19.322 12 4.401 4.54Z" />
</svg>
));
export const SendFilledIcon = memo(() => (
<svg className="send" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="m12.815 12.197-7.532 1.255a.5.5 0 0 0-.386.318L2.3 20.728c-.248.64.421 1.25 1.035.942l18-9a.75.75 0 0 0 0-1.341l-18-9c-.614-.307-1.283.303-1.035.942l2.598 6.958a.5.5 0 0 0 .386.318l7.532 1.255a.2.2 0 0 1 0 .395Z" />
</svg>
));
export const PersonIcon = memo(() => (
<svg
className="person"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M17.755 14a2.249 2.249 0 0 1 2.248 2.25v.918a2.75 2.75 0 0 1-.512 1.598c-1.546 2.164-4.07 3.235-7.49 3.235-3.422 0-5.945-1.072-7.487-3.236a2.75 2.75 0 0 1-.51-1.596v-.92A2.249 2.249 0 0 1 6.253 14h11.502ZM12 2.005a5 5 0 1 1 0 10 5 5 0 0 1 0-10Z" />
</svg>
));
export const CopyIcon = memo(() => (
<svg
className="copy-icon"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M5.503 4.627 5.5 6.75v10.504a3.25 3.25 0 0 0 3.25 3.25h8.616a2.251 2.251 0 0 1-2.122 1.5H8.75A4.75 4.75 0 0 1 4 17.254V6.75c0-.98.627-1.815 1.503-2.123ZM17.75 2A2.25 2.25 0 0 1 20 4.25v13a2.25 2.25 0 0 1-2.25 2.25h-9a2.25 2.25 0 0 1-2.25-2.25v-13A2.25 2.25 0 0 1 8.75 2h9Zm0 1.5h-9a.75.75 0 0 0-.75.75v13c0 .414.336.75.75.75h9a.75.75 0 0 0 .75-.75v-13a.75.75 0 0 0-.75-.75Z" />
</svg>
));
export const EditIcon = memo(() => (
<svg
className="edit-icon"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M20.952 3.048a3.578 3.578 0 0 0-5.06 0L3.94 15a3.106 3.106 0 0 0-.825 1.476L2.02 21.078a.75.75 0 0 0 .904.903l4.601-1.096a3.106 3.106 0 0 0 1.477-.825L20.952 8.11a3.578 3.578 0 0 0 0-5.06Zm-4 1.06a2.078 2.078 0 1 1 2.94 2.94L19 7.939 16.06 5l.892-.891ZM15 6.062 17.94 9 7.94 19c-.21.21-.474.357-.763.426l-3.416.814.813-3.416c.069-.29.217-.554.427-.764L15 6.06Z" />
</svg>
));
export const StopIcon = memo(() => (
<svg
className="stop-icon"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M4.75 3A1.75 1.75 0 0 0 3 4.75v14.5c0 .966.784 1.75 1.75 1.75h14.5A1.75 1.75 0 0 0 21 19.25V4.75A1.75 1.75 0 0 0 19.25 3H4.75Z" />
</svg>
));
export const WarningIcon = memo(() => (
<svg
className="warning-icon"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M13 17a.999.999 0 1 0-1.998 0 .999.999 0 0 0 1.997 0Zm-.26-7.853a.75.75 0 0 0-1.493.103l.004 4.501.007.102a.75.75 0 0 0 1.493-.103l-.004-4.502-.007-.101Zm1.23-5.488c-.857-1.548-3.082-1.548-3.938 0L2.286 17.66c-.83 1.5.255 3.34 1.97 3.34h15.49c1.714 0 2.799-1.84 1.969-3.34L13.969 3.66Zm-2.626.726a.75.75 0 0 1 1.313 0l7.745 14.002a.75.75 0 0 1-.656 1.113H4.256a.75.75 0 0 1-.657-1.113l7.745-14.002Z" />
</svg>
));
export const SpeakIcon = memo(() => (
<svg
className="speak-icon"
viewBox="0 0 20 19"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M13 2.25c0-1.079-1.274-1.65-2.08-.934L6.427 5.309a.75.75 0 0 1-.498.19H2.25A2.25 2.25 0 0 0 0 7.749v4.497a2.25 2.25 0 0 0 2.25 2.25h3.68a.75.75 0 0 1 .498.19l4.491 3.994c.806.716 2.081.144 2.081-.934V2.25ZM7.425 6.43 11.5 2.807v14.382l-4.075-3.624a2.25 2.25 0 0 0-1.495-.569H2.25a.75.75 0 0 1-.75-.75V7.75A.75.75 0 0 1 2.25 7h3.68a2.25 2.25 0 0 0 1.495-.569Zm9.567-2.533a.75.75 0 0 1 1.049.157A9.959 9.959 0 0 1 20 10a9.96 9.96 0 0 1-1.96 5.946.75.75 0 0 1-1.205-.892A8.459 8.459 0 0 0 18.5 10a8.459 8.459 0 0 0-1.665-5.054.75.75 0 0 1 .157-1.049ZM15.143 6.37a.75.75 0 0 1 1.017.303c.536.99.84 2.125.84 3.328a6.973 6.973 0 0 1-.84 3.328.75.75 0 0 1-1.32-.714c.42-.777.66-1.666.66-2.614s-.24-1.837-.66-2.614a.75.75 0 0 1 .303-1.017Z" />
</svg>
));

View File

@ -0,0 +1,52 @@
import { type MarkedOptions } from "components/apps/Marked/useMarked";
export type Session = {
destroy: () => void;
prompt: (message: string) => Promise<string>;
};
declare global {
/* eslint-disable vars-on-top, no-var */
var ai: {
canCreateTextSession: () => Promise<string>;
createTextSession: (config?: {
temperature?: number;
topK?: number;
}) => Promise<Session>;
};
var marked: {
parse: (markdownString: string, options: MarkedOptions) => string;
};
/* eslint-enable vars-on-top, no-var */
}
export type MessageTypes = "user" | "ai";
export type Message = {
formattedText: string;
text: string;
type: MessageTypes;
};
export type ConvoStyles = "balanced" | "creative" | "precise";
export type WorkerMessage = {
hasWindowAI: boolean;
id: number;
style: ConvoStyles;
text: string;
};
export type AIResponse = { formattedResponse: string; response: string };
export type WebLlmProgress = {
progress: {
progress: number;
text: string;
timeElapsed: number;
};
};
export type WorkerResponse = {
data: AIResponse | WebLlmProgress | "canceled";
};

View File

@ -0,0 +1,28 @@
import { type MotionProps } from "framer-motion";
import { TRANSITIONS_IN_SECONDS } from "utils/constants";
const useAITransition = (width: number, widthOffset = 0.75): MotionProps => ({
animate: "active",
exit: {
transition: {
duration: TRANSITIONS_IN_SECONDS.TASKBAR_ITEM / 10,
ease: "circIn",
},
width: `${width * widthOffset}px`,
},
initial: "initial",
transition: {
duration: TRANSITIONS_IN_SECONDS.TASKBAR_ITEM,
ease: "circOut",
},
variants: {
active: {
width: `${width}px`,
},
initial: {
width: `${width * widthOffset}px`,
},
},
});
export default useAITransition;

View File

@ -1,6 +1,7 @@
import styled from "styled-components"; import styled from "styled-components";
type StyledClockProps = { type StyledClockProps = {
$hasAI: boolean;
$width: number; $width: number;
}; };
@ -16,7 +17,8 @@ const StyledClock = styled.div<StyledClockProps>`
place-content: center; place-content: center;
place-items: center; place-items: center;
position: absolute; position: absolute;
right: 0; right: ${({ theme, $hasAI }) =>
$hasAI ? theme.sizes.taskbar.ai.buttonWidth : 0};
white-space: nowrap; white-space: nowrap;
&:hover { &:hover {

View File

@ -59,12 +59,18 @@ const easterEggOnClick: React.MouseEventHandler<HTMLElement> = async ({
}; };
type ClockProps = { type ClockProps = {
hasAI: boolean;
setClockWidth: React.Dispatch<React.SetStateAction<number>>; setClockWidth: React.Dispatch<React.SetStateAction<number>>;
toggleCalendar: () => void; toggleCalendar: () => void;
width: number; width: number;
}; };
const Clock: FC<ClockProps> = ({ setClockWidth, toggleCalendar, width }) => { const Clock: FC<ClockProps> = ({
hasAI,
setClockWidth,
toggleCalendar,
width,
}) => {
const [now, setNow] = useState<LocaleTimeDate>( const [now, setNow] = useState<LocaleTimeDate>(
Object.create(null) as LocaleTimeDate Object.create(null) as LocaleTimeDate
); );
@ -197,6 +203,7 @@ const Clock: FC<ClockProps> = ({ setClockWidth, toggleCalendar, width }) => {
return ( return (
<StyledClock <StyledClock
ref={supportsOffscreenCanvas ? clockCallbackRef : undefined} ref={supportsOffscreenCanvas ? clockCallbackRef : undefined}
$hasAI={hasAI}
$width={width} $width={width}
aria-label="Clock" aria-label="Clock"
onClick={onClockClick} onClick={onClockClick}

View File

@ -8,7 +8,11 @@ import StyledTaskbar from "components/system/Taskbar/StyledTaskbar";
import TaskbarEntries from "components/system/Taskbar/TaskbarEntries"; import TaskbarEntries from "components/system/Taskbar/TaskbarEntries";
import useTaskbarContextMenu from "components/system/Taskbar/useTaskbarContextMenu"; import useTaskbarContextMenu from "components/system/Taskbar/useTaskbarContextMenu";
import { CLOCK_CANVAS_BASE_WIDTH, FOCUSABLE_ELEMENT } from "utils/constants"; import { CLOCK_CANVAS_BASE_WIDTH, FOCUSABLE_ELEMENT } from "utils/constants";
import { useWindowAI } from "hooks/useWindowAI";
import { useSession } from "contexts/session";
const AIButton = dynamic(() => import("components/system/Taskbar/AI/AIButton"));
const AIChat = dynamic(() => import("components/system/Taskbar/AI/AIChat"));
const Calendar = dynamic(() => import("components/system/Taskbar/Calendar")); const Calendar = dynamic(() => import("components/system/Taskbar/Calendar"));
const Search = dynamic(() => import("components/system/Taskbar/Search")); const Search = dynamic(() => import("components/system/Taskbar/Search"));
const StartMenu = dynamic(() => import("components/system/StartMenu")); const StartMenu = dynamic(() => import("components/system/StartMenu"));
@ -17,7 +21,10 @@ const Taskbar: FC = () => {
const [startMenuVisible, setStartMenuVisible] = useState(false); const [startMenuVisible, setStartMenuVisible] = useState(false);
const [searchVisible, setSearchVisible] = useState(false); const [searchVisible, setSearchVisible] = useState(false);
const [calendarVisible, setCalendarVisible] = useState(false); const [calendarVisible, setCalendarVisible] = useState(false);
const [aiVisible, setAIVisible] = useState(false);
const [clockWidth, setClockWidth] = useState(CLOCK_CANVAS_BASE_WIDTH); const [clockWidth, setClockWidth] = useState(CLOCK_CANVAS_BASE_WIDTH);
const { aiEnabled } = useSession();
const hasWindowAI = useWindowAI();
const toggleStartMenu = useCallback( const toggleStartMenu = useCallback(
(showMenu?: boolean): void => (showMenu?: boolean): void =>
setStartMenuVisible((currentMenuState) => showMenu ?? !currentMenuState), setStartMenuVisible((currentMenuState) => showMenu ?? !currentMenuState),
@ -37,6 +44,11 @@ const Taskbar: FC = () => {
), ),
[] []
); );
const toggleAI = useCallback(
(showAI?: boolean): void =>
setAIVisible((currentAIState) => showAI ?? !currentAIState),
[]
);
return ( return (
<> <>
@ -57,13 +69,18 @@ const Taskbar: FC = () => {
/> />
<TaskbarEntries clockWidth={clockWidth} /> <TaskbarEntries clockWidth={clockWidth} />
<Clock <Clock
hasAI={hasWindowAI || aiEnabled}
setClockWidth={setClockWidth} setClockWidth={setClockWidth}
toggleCalendar={toggleCalendar} toggleCalendar={toggleCalendar}
width={clockWidth} width={clockWidth}
/> />
{(hasWindowAI || aiEnabled) && (
<AIButton aiVisible={aiVisible} toggleAI={toggleAI} />
)}
</StyledTaskbar> </StyledTaskbar>
<AnimatePresence initial={false} presenceAffectsLayout={false}> <AnimatePresence initial={false} presenceAffectsLayout={false}>
{calendarVisible && <Calendar toggleCalendar={toggleCalendar} />} {calendarVisible && <Calendar toggleCalendar={toggleCalendar} />}
{aiVisible && <AIChat toggleAI={toggleAI} />}
</AnimatePresence> </AnimatePresence>
</> </>
); );

View File

@ -1,4 +1,5 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { AI_TITLE } from "components/system/Taskbar/AI/constants";
import { useMenu } from "contexts/menu"; import { useMenu } from "contexts/menu";
import { import {
type ContextMenuCapture, type ContextMenuCapture,
@ -10,13 +11,17 @@ import { useViewport } from "contexts/viewport";
import { useProcessesRef } from "hooks/useProcessesRef"; import { useProcessesRef } from "hooks/useProcessesRef";
import { MENU_SEPERATOR } from "utils/constants"; import { MENU_SEPERATOR } from "utils/constants";
import { toggleShowDesktop } from "utils/functions"; import { toggleShowDesktop } from "utils/functions";
import { useWebGPUCheck } from "hooks/useWebGPUCheck";
import { useWindowAI } from "hooks/useWindowAI";
const useTaskbarContextMenu = (onStartButton = false): ContextMenuCapture => { const useTaskbarContextMenu = (onStartButton = false): ContextMenuCapture => {
const { contextMenu } = useMenu(); const { contextMenu } = useMenu();
const { minimize, open } = useProcesses(); const { minimize, open } = useProcesses();
const { stackOrder } = useSession(); const { aiEnabled, setAiEnabled, stackOrder } = useSession();
const processesRef = useProcessesRef(); const processesRef = useProcessesRef();
const { fullscreenElement, toggleFullscreen } = useViewport(); const { fullscreenElement, toggleFullscreen } = useViewport();
const hasWebGPU = useWebGPUCheck();
const hasWindowAI = useWindowAI();
return useMemo( return useMemo(
() => () =>
@ -62,19 +67,33 @@ const useTaskbarContextMenu = (onStartButton = false): ContextMenuCapture => {
? "Exit full screen" ? "Exit full screen"
: "Enter full screen", : "Enter full screen",
}, },
MENU_SEPERATOR MENU_SEPERATOR,
...(hasWebGPU && !hasWindowAI
? [
{
action: () => setAiEnabled(!aiEnabled),
checked: aiEnabled,
label: `Show ${AI_TITLE} button`,
},
MENU_SEPERATOR,
]
: [])
); );
} }
return menuItems; return menuItems;
}), }),
[ [
aiEnabled,
contextMenu, contextMenu,
fullscreenElement, fullscreenElement,
hasWebGPU,
hasWindowAI,
minimize, minimize,
onStartButton, onStartButton,
open, open,
processesRef, processesRef,
setAiEnabled,
stackOrder, stackOrder,
toggleFullscreen, toggleFullscreen,
] ]

View File

@ -37,6 +37,7 @@ export type IconPosition = {
export type IconPositions = Record<string, IconPosition>; export type IconPositions = Record<string, IconPosition>;
export type SessionData = { export type SessionData = {
aiEnabled: boolean;
clockSource: ClockSource; clockSource: ClockSource;
cursor: string; cursor: string;
iconPositions: IconPositions; iconPositions: IconPositions;
@ -54,6 +55,7 @@ export type SessionContextState = SessionData & {
prependToStack: (id: string) => void; prependToStack: (id: string) => void;
removeFromStack: (id: string) => void; removeFromStack: (id: string) => void;
sessionLoaded: boolean; sessionLoaded: boolean;
setAiEnabled: React.Dispatch<React.SetStateAction<boolean>>;
setClockSource: React.Dispatch<React.SetStateAction<ClockSource>>; setClockSource: React.Dispatch<React.SetStateAction<ClockSource>>;
setCursor: React.Dispatch<React.SetStateAction<string>>; setCursor: React.Dispatch<React.SetStateAction<string>>;
setForegroundId: React.Dispatch<React.SetStateAction<string>>; setForegroundId: React.Dispatch<React.SetStateAction<string>>;

View File

@ -45,6 +45,7 @@ const useSessionContextState = (): SessionContextState => {
const [themeName, setThemeName] = useState(DEFAULT_THEME); const [themeName, setThemeName] = useState(DEFAULT_THEME);
const [clockSource, setClockSource] = useState(DEFAULT_CLOCK_SOURCE); const [clockSource, setClockSource] = useState(DEFAULT_CLOCK_SOURCE);
const [cursor, setCursor] = useState(""); const [cursor, setCursor] = useState("");
const [aiEnabled, setAiEnabled] = useState(false);
const [windowStates, setWindowStates] = useState( const [windowStates, setWindowStates] = useState(
Object.create(null) as WindowStates Object.create(null) as WindowStates
); );
@ -186,6 +187,7 @@ const useSessionContextState = (): SessionContextState => {
writeFile( writeFile(
SESSION_FILE, SESSION_FILE,
JSON.stringify({ JSON.stringify({
aiEnabled,
clockSource, clockSource,
cursor, cursor,
iconPositions, iconPositions,
@ -211,6 +213,7 @@ const useSessionContextState = (): SessionContextState => {
} }
} }
}, [ }, [
aiEnabled,
clockSource, clockSource,
cursor, cursor,
haltSession, haltSession,
@ -247,6 +250,7 @@ const useSessionContextState = (): SessionContextState => {
if (session.clockSource) setClockSource(session.clockSource); if (session.clockSource) setClockSource(session.clockSource);
if (session.cursor) setCursor(session.cursor); if (session.cursor) setCursor(session.cursor);
if (session.aiEnabled) setAiEnabled(session.aiEnabled);
if (session.themeName) setThemeName(session.themeName); if (session.themeName) setThemeName(session.themeName);
if (session.wallpaperImage) { if (session.wallpaperImage) {
setWallpaper(session.wallpaperImage, session.wallpaperFit); setWallpaper(session.wallpaperImage, session.wallpaperFit);
@ -338,6 +342,7 @@ const useSessionContextState = (): SessionContextState => {
}, [deletePath, lstat, readFile, rootFs, setWallpaper]); }, [deletePath, lstat, readFile, rootFs, setWallpaper]);
return { return {
aiEnabled,
clockSource, clockSource,
cursor, cursor,
foregroundId, foregroundId,
@ -347,6 +352,7 @@ const useSessionContextState = (): SessionContextState => {
removeFromStack, removeFromStack,
runHistory, runHistory,
sessionLoaded, sessionLoaded,
setAiEnabled,
setClockSource, setClockSource,
setCursor, setCursor,
setForegroundId, setForegroundId,

View File

@ -14,6 +14,8 @@ type NavigatorWithGPU = Navigator & {
}; };
}; };
let HAS_WEB_GPU = false;
const supportsWebGPU = async (): Promise<boolean> => { const supportsWebGPU = async (): Promise<boolean> => {
if (typeof navigator === "undefined") return false; if (typeof navigator === "undefined") return false;
if (!("gpu" in navigator)) return false; if (!("gpu" in navigator)) return false;
@ -41,19 +43,22 @@ const supportsWebGPU = async (): Promise<boolean> => {
requiredMaxComputeWorkgroupStorageSize > requiredMaxComputeWorkgroupStorageSize >
(adapter.limits.maxComputeWorkgroupStorageSize ?? 0); (adapter.limits.maxComputeWorkgroupStorageSize ?? 0);
if (!insufficientLimits) HAS_WEB_GPU = true;
return !insufficientLimits; return !insufficientLimits;
}; };
export const useWebGPUCheck = (): boolean => { export const useWebGPUCheck = (): boolean => {
const [hasWebGPU, setHasWebGPU] = useState<boolean>(false); const [hasWebGPU, setHasWebGPU] = useState<boolean>(HAS_WEB_GPU);
const checkWebGPU = useCallback( const checkWebGPU = useCallback(async () => {
async () => setHasWebGPU(await supportsWebGPU()), const sufficientLimits = await supportsWebGPU();
[]
); if (sufficientLimits) setHasWebGPU(true);
}, []);
useEffect(() => { useEffect(() => {
requestAnimationFrame(checkWebGPU); if (!hasWebGPU) requestAnimationFrame(checkWebGPU);
}, [checkWebGPU]); }, [checkWebGPU, hasWebGPU]);
return hasWebGPU; return hasWebGPU;
}; };

35
hooks/useWindowAI.ts Normal file
View File

@ -0,0 +1,35 @@
import { useCallback, useEffect, useState } from "react";
let HAS_WINDOW_AI = false;
const supportsAI = async (): Promise<boolean> => {
if (typeof window === "undefined") return false;
if (!("ai" in window)) return false;
try {
if (!("canCreateTextSession" in window.ai)) return false;
const hasWindowAi = (await window.ai.canCreateTextSession()) !== "no";
if (hasWindowAi) HAS_WINDOW_AI = true;
return hasWindowAi;
} catch {
return false;
}
};
export const useWindowAI = (): boolean => {
const [hasAI, setHasAI] = useState<boolean>(HAS_WINDOW_AI);
const checkAI = useCallback(async () => {
const hasWindowAi = await supportsAI();
if (hasWindowAi) setHasAI(true);
}, []);
useEffect(() => {
if (!hasAI) requestAnimationFrame(checkAI);
}, [checkAI, hasAI]);
return hasAI;
};

View File

@ -37,6 +37,7 @@
"*.{js,ts,tsx}": "eslint --fix" "*.{js,ts,tsx}": "eslint --fix"
}, },
"dependencies": { "dependencies": {
"@mlc-ai/web-llm": "^0.2.58",
"@monaco-editor/loader": "^1.4.0", "@monaco-editor/loader": "^1.4.0",
"@panzoom/panzoom": "^4.5.1", "@panzoom/panzoom": "^4.5.1",
"@prettier/plugin-xml": "^3.4.1", "@prettier/plugin-xml": "^3.4.1",

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -27,6 +27,15 @@ const colors = {
taskbar: { taskbar: {
active: "hsla(0, 0%, 20%, 70%)", active: "hsla(0, 0%, 20%, 70%)",
activeForeground: "hsla(0, 0%, 40%, 70%)", activeForeground: "hsla(0, 0%, 40%, 70%)",
ai: {
balanced: ["rgb(112, 203, 255)", "rgb(40, 112, 234)", "rgb(0, 95, 184)"],
creative: [
"rgb(215, 167, 187)",
"rgb(145, 72, 135)",
"rgb(139, 37, 126)",
],
precise: ["rgb(167, 224, 235)", "rgb(0, 104, 128)", "rgb(0, 83, 102)"],
},
background: "hsla(0, 0%, 10%, 70%)", background: "hsla(0, 0%, 10%, 70%)",
button: { button: {
color: "#FFF", color: "#FFF",

View File

@ -46,6 +46,10 @@ const sizes = {
size: 320, size: 320,
}, },
taskbar: { taskbar: {
ai: {
buttonWidth: "40px",
chatWidth: 415,
},
blur: "5px", blur: "5px",
button: { button: {
iconSize: "15px", iconSize: "15px",

View File

@ -746,6 +746,13 @@
semver "^7.3.5" semver "^7.3.5"
tar "^6.1.11" tar "^6.1.11"
"@mlc-ai/web-llm@^0.2.58":
version "0.2.58"
resolved "https://registry.yarnpkg.com/@mlc-ai/web-llm/-/web-llm-0.2.58.tgz#9cb4ed31ebde0573bd0a84d2baf9b5cfbc0799ea"
integrity sha512-QWMwMC6E4JyfBSQ0rRu/Z1V+nicMycE0wXSnrCKebsTAg/Z7h8l5JXAES2v3Yls99qHZtmrKXwn0ZSHUccd7yw==
dependencies:
loglevel "^1.9.1"
"@monaco-editor/loader@^1.4.0": "@monaco-editor/loader@^1.4.0":
version "1.4.0" version "1.4.0"
resolved "https://registry.yarnpkg.com/@monaco-editor/loader/-/loader-1.4.0.tgz#f08227057331ec890fa1e903912a5b711a2ad558" resolved "https://registry.yarnpkg.com/@monaco-editor/loader/-/loader-1.4.0.tgz#f08227057331ec890fa1e903912a5b711a2ad558"
@ -5234,6 +5241,11 @@ log-update@^6.1.0:
strip-ansi "^7.1.0" strip-ansi "^7.1.0"
wrap-ansi "^9.0.0" wrap-ansi "^9.0.0"
loglevel@^1.9.1:
version "1.9.1"
resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.9.1.tgz#d63976ac9bcd03c7c873116d41c2a85bafff1be7"
integrity sha512-hP3I3kCrDIMuRwAwHltphhDM1r8i55H33GgqjXbrisuJhF4kRhW1dNuxsRklp4bXl8DSdLaNLuiL4A/LWRfxvg==
loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0: loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0:
version "1.4.0" version "1.4.0"
resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"