Compare commits
26 Commits
svelte-5-u
...
main
Author | SHA1 | Date |
---|---|---|
|
7d5934faad | |
|
1dd6a060ef | |
|
f00e80a5cb | |
|
d3e112692e | |
|
30ccb9657e | |
|
bee420feb3 | |
|
5d55c6de4a | |
|
06e37b3712 | |
|
cc2e29c62b | |
|
d6270d42b7 | |
|
637ec3657b | |
|
3e2e1b0bcb | |
|
5f8e355bdc | |
|
a214da2dfe | |
|
4e84ffda4d | |
|
5b0c2ba65c | |
|
b76ab4e170 | |
|
8e2c52d3f8 | |
|
0d62d0bb6a | |
|
f0d1c62e5f | |
|
5e7fc8b87d | |
|
39d7f3c943 | |
|
bdac4e24f0 | |
|
41aa05b70e | |
|
74b4e810f8 | |
|
96f6d87a9f |
|
@ -12,3 +12,5 @@ vite.config.ts.timestamp-*
|
|||
.vercel
|
||||
vectorstore
|
||||
ecosystem.config.js
|
||||
.ai-robots-cache.json
|
||||
robots.txt
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
# Playground
|
||||
|
||||
Portfolio site of silentsilas.
|
||||
Portfolio site of silentsilas
|
||||
|
||||
## Developing
|
||||
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -3,13 +3,12 @@
|
|||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=22.13.1"
|
||||
"node": "^22"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"deploy": "bash scripts/deploy.sh",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"test": "vitest",
|
||||
|
@ -17,9 +16,11 @@
|
|||
"format": "prettier --write .",
|
||||
"model-pipeline:run": "node scripts/model-pipeline.js",
|
||||
"generate-embeddings": "node scripts/generate-embeddings.js",
|
||||
"finetune": "node scripts/finetune.js"
|
||||
"finetune": "node scripts/finetune.js",
|
||||
"start": "PORT=8080 node build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@silentsilas/vite-plugin-ai-robots": "^1.0.1",
|
||||
"@sveltejs/adapter-auto": "^3.2.4",
|
||||
"@sveltejs/adapter-node": "^5.2.2",
|
||||
"@sveltejs/kit": "^2.16.1",
|
||||
|
@ -49,6 +50,7 @@
|
|||
"type": "module",
|
||||
"dependencies": {
|
||||
"@dimforge/rapier3d-compat": "^0.14.0",
|
||||
"@friendofsvelte/tipex": "^0.0.7-fix-0",
|
||||
"@langchain/anthropic": "^0.3.1",
|
||||
"@langchain/community": "^0.3.1",
|
||||
"@langchain/core": "^0.3.32",
|
||||
|
|
|
@ -1,22 +0,0 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Pull the latest changes
|
||||
git pull
|
||||
|
||||
# Build the project
|
||||
npm run build
|
||||
|
||||
# Copy necessary files
|
||||
cp package.json ./build/
|
||||
cp package-lock.json ./build/
|
||||
|
||||
# Create or update the .env file
|
||||
# echo "ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY" >> ./build/.env
|
||||
#echo "OPENAI_API_KEY=$OPENAI_API_KEY" >> ./build/.env
|
||||
|
||||
# Navigate to build directory and install dependencies
|
||||
cd build
|
||||
npm install
|
||||
|
||||
# Restart the application
|
||||
pm2 restart ecosystem.config.cjs
|
12
src/app.html
12
src/app.html
|
@ -1,6 +1,7 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
|
@ -9,9 +10,12 @@
|
|||
<meta property="og:url" content="https://silentsilas.com">
|
||||
<meta property="og:image" content="https://silentsilas.com/social.png">
|
||||
<meta property="og:description" content="Personal site of silentsilas.">
|
||||
<script src="https://darkvisitors.com/tracker.js?project_key=e275359a-67b4-4862-b800-c6a2878446a6"></script>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover" data-theme="forest" style="overflow-x: hidden;">
|
||||
</head>
|
||||
|
||||
<body data-sveltekit-preload-data="hover" data-theme="forest" style="overflow-x: hidden;">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</body>
|
||||
|
||||
</html>
|
|
@ -1,7 +1,29 @@
|
|||
import { getModel } from '$lib/utils/search';
|
||||
import { building } from '$app/environment';
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
import type { Handle, RequestEvent } from '@sveltejs/kit';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { DARK_VISITORS_TOKEN } from '$env/static/private';
|
||||
|
||||
async function sendAnalytics(event: RequestEvent) {
|
||||
try {
|
||||
const headers = Object.fromEntries(event.request.headers.entries());
|
||||
|
||||
void fetch('https://api.darkvisitors.com/visits', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${DARK_VISITORS_TOKEN}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
request_path: event.url.pathname,
|
||||
request_method: event.request.method,
|
||||
request_headers: headers
|
||||
})
|
||||
});
|
||||
} catch (e) {
|
||||
// Silent fail
|
||||
}
|
||||
}
|
||||
|
||||
if (!building) {
|
||||
getModel().catch((error) => {
|
||||
|
@ -18,10 +40,19 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||
// If no session exists, create a new one
|
||||
if (!sessionId) {
|
||||
sessionId = uuidv4();
|
||||
event.cookies.set('sessionId', sessionId, { path: '/', httpOnly: true, sameSite: 'strict', maxAge: 60 * 60 * 24 * 7 }); // 1 week
|
||||
event.cookies.set('sessionId', sessionId, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
maxAge: 60 * 60 * 24 * 7
|
||||
}); // 1 week
|
||||
}
|
||||
// Add sessionId to locals for easy access in routes
|
||||
event.locals = { ...event.locals, sessionId };
|
||||
|
||||
if (import.meta.env.PROD) {
|
||||
sendAnalytics(event);
|
||||
}
|
||||
|
||||
return resolve(event);
|
||||
};
|
||||
|
|
|
@ -71,8 +71,27 @@
|
|||
</script>
|
||||
|
||||
<div class="container mx-auto flex flex-col items-center">
|
||||
<div class="prose mb-4">
|
||||
<h1 class="py-6">{title}</h1>
|
||||
<div class="prose flex flex-row py-6 items-center">
|
||||
<h1 class="mb-0">{title}</h1>
|
||||
<a
|
||||
aria-label="Link to poetry RSS feed."
|
||||
class="link-primary mt-2 ml-2"
|
||||
href={`${baseUrl}/rss`}
|
||||
rel="external"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
class="size-5"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M3.75 4.5a.75.75 0 0 1 .75-.75h.75c8.284 0 15 6.716 15 15v.75a.75.75 0 0 1-.75.75h-.75a.75.75 0 0 1-.75-.75v-.75C18 11.708 12.292 6 5.25 6H4.5a.75.75 0 0 1-.75-.75V4.5Zm0 6.75a.75.75 0 0 1 .75-.75h.75a8.25 8.25 0 0 1 8.25 8.25v.75a.75.75 0 0 1-.75.75H12a.75.75 0 0 1-.75-.75v-.75a6 6 0 0 0-6-6H4.5a.75.75 0 0 1-.75-.75v-.75Zm0 7.5a1.5 1.5 0 1 1 3 0 1.5 1.5 0 0 1-3 0Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<ul>
|
||||
|
|
|
@ -0,0 +1,239 @@
|
|||
<script lang="ts">
|
||||
import { Tipex, type TipexEditor } from '@friendofsvelte/tipex';
|
||||
import '@friendofsvelte/tipex/styles/Tipex.css';
|
||||
import '@friendofsvelte/tipex/styles/ProseMirror.css';
|
||||
import '@friendofsvelte/tipex/styles/Controls.css';
|
||||
import '@friendofsvelte/tipex/styles/EditLink.css';
|
||||
import '@friendofsvelte/tipex/styles/CodeBlock.css';
|
||||
import Utilities from './Utilities.svelte';
|
||||
import SuggestionList from './SuggestionList.svelte';
|
||||
import { datamuseApi, type Suggestion } from '$lib/services/datamuse';
|
||||
import { calculateCursorPosition } from '$lib/utils/cursor';
|
||||
import { createDebounce } from '$lib/utils/debounce';
|
||||
import { extractCurrentWord, extractSelectedWord } from '$lib/utils/text';
|
||||
|
||||
let { initialContent } = $props();
|
||||
|
||||
let editor = $state<TipexEditor>();
|
||||
let body = $state(localStorage.getItem('tipex') || initialContent || '');
|
||||
let suggestions = $state<Suggestion[]>([]);
|
||||
let rhymes = $state<Suggestion[]>([]);
|
||||
let currentWord = $state('');
|
||||
let selectedIndex = $state(-1);
|
||||
let cursorPosition = $state({ x: 0, y: 0 });
|
||||
let isMobile = $state(false);
|
||||
const debounce = createDebounce();
|
||||
|
||||
// Check for mobile on component mount and resize
|
||||
function checkMobile() {
|
||||
isMobile = window.innerWidth < 768;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
// Add resize listener for responsive behavior
|
||||
window.addEventListener('resize', checkMobile);
|
||||
checkMobile();
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
window.removeEventListener('resize', checkMobile);
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (editor?.getHTML()) {
|
||||
localStorage.setItem('tipex', editor.getHTML());
|
||||
}
|
||||
});
|
||||
|
||||
function updateCursorPosition() {
|
||||
cursorPosition = calculateCursorPosition(editor);
|
||||
}
|
||||
|
||||
const handleUpdate = debounce(async () => {
|
||||
const { state } = editor?.view || {};
|
||||
if (!state) return;
|
||||
|
||||
const { doc, selection } = state;
|
||||
const word = extractCurrentWord(doc, selection.from);
|
||||
|
||||
if (word && word !== currentWord) {
|
||||
currentWord = word;
|
||||
suggestions = await datamuseApi.getSuggestions(word);
|
||||
selectedIndex = -1;
|
||||
} else if (!word) {
|
||||
suggestions = [];
|
||||
currentWord = '';
|
||||
}
|
||||
updateCursorPosition();
|
||||
}, 1000);
|
||||
|
||||
const handleSelectionChange = debounce(async () => {
|
||||
const selection = editor?.view.state.selection;
|
||||
if (editor && selection && !selection.empty) {
|
||||
const word = extractSelectedWord(editor.view.state.doc, selection.from, selection.to);
|
||||
if (word) {
|
||||
rhymes = await datamuseApi.getRhymes(word);
|
||||
selectedIndex = -1;
|
||||
updateCursorPosition();
|
||||
}
|
||||
}
|
||||
}, 300);
|
||||
|
||||
// Special handler for touch events
|
||||
function handleTouchEnd(event: TouchEvent) {
|
||||
// Prevent default only if we have suggestions
|
||||
if (suggestions.length > 0 || rhymes.length > 0) {
|
||||
event.preventDefault();
|
||||
}
|
||||
handleSelectionChange();
|
||||
}
|
||||
|
||||
function clearSuggestions() {
|
||||
suggestions = [];
|
||||
rhymes = [];
|
||||
currentWord = '';
|
||||
selectedIndex = -1;
|
||||
}
|
||||
|
||||
function applySuggestion(word: string) {
|
||||
if (!editor?.view) return;
|
||||
|
||||
const view = editor.view;
|
||||
const { state } = view;
|
||||
const { schema, selection, tr } = state;
|
||||
|
||||
view.focus();
|
||||
|
||||
if (rhymes.length > 0 && !selection.empty) {
|
||||
view.dispatch(tr.replaceSelectionWith(schema.text(word)));
|
||||
rhymes = [];
|
||||
} else {
|
||||
const pos = selection.from;
|
||||
const wordBefore = state.doc.textBetween(Math.max(0, pos - 100), pos);
|
||||
const match = wordBefore.match(/\S+$/);
|
||||
|
||||
if (match) {
|
||||
const wordStart = pos - match[0].length;
|
||||
view.dispatch(tr.replaceWith(wordStart, pos, schema.text(word)));
|
||||
}
|
||||
}
|
||||
|
||||
clearSuggestions();
|
||||
view.focus();
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
const activeSuggestions = suggestions.length ? suggestions : rhymes;
|
||||
if (!activeSuggestions.length) return;
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
selectedIndex = Math.min(selectedIndex + 1, activeSuggestions.length - 1);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
selectedIndex = Math.max(selectedIndex - 1, -1);
|
||||
break;
|
||||
case 'Enter':
|
||||
event.preventDefault();
|
||||
if (selectedIndex >= 0) {
|
||||
applySuggestion(activeSuggestions[selectedIndex].word);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
event.preventDefault();
|
||||
clearSuggestions();
|
||||
break;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onselectionchange={handleSelectionChange} onkeydown={handleKeydown} />
|
||||
|
||||
<div
|
||||
class="container mx-auto my-8 dark relative px-4"
|
||||
onmouseup={handleSelectionChange}
|
||||
ontouchend={handleTouchEnd}
|
||||
onmousedown={clearSuggestions}
|
||||
ontouchstart={clearSuggestions}
|
||||
role="textbox"
|
||||
aria-label="Text editor container"
|
||||
tabindex="0"
|
||||
>
|
||||
<Tipex {body} controls !focal class="h-[80vh]" bind:tipex={editor}>
|
||||
{#snippet utilities(editor)}
|
||||
<Utilities {editor} />
|
||||
{/snippet}
|
||||
</Tipex>
|
||||
|
||||
{#if suggestions.length > 0 || rhymes.length > 0}
|
||||
<div
|
||||
class="suggestions-container {isMobile ? 'mobile-suggestions' : ''}"
|
||||
role="listbox"
|
||||
aria-label="Suggestions and rhymes"
|
||||
style:left="{cursorPosition.x}px"
|
||||
style:top="{cursorPosition.y}px"
|
||||
>
|
||||
{#if suggestions.length > 0}
|
||||
<SuggestionList
|
||||
title="Suggestions"
|
||||
{suggestions}
|
||||
{selectedIndex}
|
||||
onSelect={applySuggestion}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if rhymes.length > 0}
|
||||
<SuggestionList
|
||||
title="Rhymes"
|
||||
suggestions={rhymes}
|
||||
{selectedIndex}
|
||||
onSelect={applySuggestion}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.suggestions-container {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
max-width: min(300px, 90vw);
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
transform: translateX(-50%);
|
||||
overscroll-behavior: contain;
|
||||
scroll-behavior: smooth;
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgb(0 0 0 / 0.1),
|
||||
0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
touch-action: manipulation;
|
||||
background-color: white;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
/* Mobile-specific styles */
|
||||
.mobile-suggestions {
|
||||
max-width: 90vw;
|
||||
width: 90vw;
|
||||
max-height: 200px;
|
||||
left: 50% !important; /* Override inline styles */
|
||||
bottom: 20px !important;
|
||||
top: auto !important;
|
||||
position: fixed;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.suggestions-container {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 50% !important;
|
||||
transform: translateX(-50%);
|
||||
top: auto !important;
|
||||
width: 90vw;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,51 @@
|
|||
<script lang="ts">
|
||||
type Suggestion = {
|
||||
word: string;
|
||||
score: number;
|
||||
};
|
||||
|
||||
let { title, suggestions, selectedIndex, onSelect } = $props<{
|
||||
title: string;
|
||||
suggestions: Suggestion[];
|
||||
selectedIndex: number;
|
||||
onSelect: (word: string) => void;
|
||||
}>();
|
||||
|
||||
const isMobile = window.innerWidth < 768;
|
||||
|
||||
function handleTouchSelect(event: TouchEvent, word: string) {
|
||||
event.preventDefault();
|
||||
onSelect(word);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 shadow-lg rounded-md p-2 mb-2">
|
||||
<h3 class="text-sm font-bold mb-2 text-gray-700 dark:text-gray-300">{title}</h3>
|
||||
{#each suggestions.slice(0, 10) as { word }, i}
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full text-left px-2 {isMobile
|
||||
? 'py-3'
|
||||
: 'py-1'} hover:bg-gray-100 dark:hover:bg-gray-700 rounded
|
||||
{selectedIndex === i ? 'bg-gray-100 dark:bg-gray-700' : ''}"
|
||||
onmousedown={() => onSelect(word)}
|
||||
ontouchstart={(e) => handleTouchSelect(e, word)}
|
||||
role="option"
|
||||
aria-selected={selectedIndex === i}
|
||||
style:touch-action="manipulation"
|
||||
>
|
||||
{word}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Mobile-specific styles */
|
||||
@media (max-width: 768px) {
|
||||
button {
|
||||
padding: 12px 8px;
|
||||
margin-bottom: 4px;
|
||||
font-size: 16px; /* Minimum readable size on mobile */
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,52 @@
|
|||
<script lang="ts">
|
||||
import type { TipexEditor } from '@friendofsvelte/tipex';
|
||||
|
||||
interface UtilitiesProps {
|
||||
editor: TipexEditor;
|
||||
}
|
||||
|
||||
let { editor }: UtilitiesProps = $props();
|
||||
|
||||
const copyHTML = () => {
|
||||
const html = editor?.getHTML();
|
||||
if (!html) return;
|
||||
navigator.clipboard.writeText(html);
|
||||
alert('HTML copied to clipboard!');
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="tipex-utilities">
|
||||
<button
|
||||
class="tipex-edit-button tipex-button-extra tipex-button-rigid"
|
||||
type="button"
|
||||
aria-label="Copy HTML"
|
||||
onclick={copyHTML}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 448 512"
|
||||
width="0.7em"
|
||||
height="0.7em"
|
||||
class="h-4 w-4"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M208 0h124.1C344.8 0 357 5.1 366 14.1L433.9 82c9 9 14.1 21.2 14.1 33.9V336c0 26.5-21.5 48-48 48H208c-26.5 0-48-21.5-48-48V48c0-26.5 21.5-48 48-48M48 128h80v64H64v256h192v-32h64v48c0 26.5-21.5 48-48 48H48c-26.5 0-48-21.5-48-48V176c0-26.5 21.5-48 48-48"
|
||||
></path></svg
|
||||
></button
|
||||
>
|
||||
|
||||
<button class="tipex-edit-button tipex-button-extra tipex-button-rigid" aria-label="Edit link"
|
||||
><svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 640 512"
|
||||
width="0.7em"
|
||||
height="0.7em"
|
||||
class="h-4 w-4"
|
||||
><path
|
||||
fill="currentColor"
|
||||
d="M579.8 267.7c56.5-56.5 56.5-148 0-204.5c-50-50-128.8-56.5-186.3-15.4l-1.6 1.1c-14.4 10.3-17.7 30.3-7.4 44.6s30.3 17.7 44.6 7.4l1.6-1.1c32.1-22.9 76-19.3 103.8 8.6c31.5 31.5 31.5 82.5 0 114L422.3 334.8c-31.5 31.5-82.5 31.5-114 0c-27.9-27.9-31.5-71.8-8.6-103.8l1.1-1.6c10.3-14.4 6.9-34.4-7.4-44.6s-34.4-6.9-44.6 7.4l-1.1 1.6C206.5 251.2 213 330 263 380c56.5 56.5 148 56.5 204.5 0zM60.2 244.3c-56.5 56.5-56.5 148 0 204.5c50 50 128.8 56.5 186.3 15.4l1.6-1.1c14.4-10.3 17.7-30.3 7.4-44.6s-30.3-17.7-44.6-7.4l-1.6 1.1c-32.1 22.9-76 19.3-103.8-8.6C74 372 74 321 105.5 289.5l112.2-112.3c31.5-31.5 82.5-31.5 114 0c27.9 27.9 31.5 71.8 8.6 103.9l-1.1 1.6c-10.3 14.4-6.9 34.4 7.4 44.6s34.4 6.9 44.6-7.4l1.1-1.6C433.5 260.8 427 182 377 132c-56.5-56.5-148-56.5-204.5 0z"
|
||||
></path></svg
|
||||
>
|
||||
</button>
|
||||
</div>
|
|
@ -13,15 +13,7 @@
|
|||
</script>
|
||||
|
||||
<div class="canvas flex flex-1" oncontextmenu={preventRightClick} role="application">
|
||||
<Canvas
|
||||
createRenderer={(canvas) => {
|
||||
return new WebGPURenderer({
|
||||
canvas,
|
||||
antialias: true,
|
||||
forceWebGL: false
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Canvas>
|
||||
{@render children?.()}
|
||||
</Canvas>
|
||||
</div>
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
export type Suggestion = {
|
||||
word: string;
|
||||
score: number;
|
||||
};
|
||||
|
||||
export const datamuseApi = {
|
||||
async getSuggestions(word: string): Promise<Suggestion[]> {
|
||||
if (!word || word.length < 2) return [];
|
||||
return fetch(`https://api.datamuse.com/sug?s=${encodeURIComponent(word)}`)
|
||||
.then((res) => (res.ok ? res.json() : []))
|
||||
.catch((error) => {
|
||||
console.error('Error fetching suggestions:', error);
|
||||
return [];
|
||||
});
|
||||
},
|
||||
|
||||
async getRhymes(word: string): Promise<Suggestion[]> {
|
||||
if (!word) return [];
|
||||
return fetch(`https://api.datamuse.com/words?rel_rhy=${encodeURIComponent(word)}`)
|
||||
.then((res) => (res.ok ? res.json() : []))
|
||||
.catch((error) => {
|
||||
console.error('Error fetching rhymes:', error);
|
||||
return [];
|
||||
});
|
||||
}
|
||||
};
|
|
@ -0,0 +1,31 @@
|
|||
import type { TipexEditor } from '@friendofsvelte/tipex';
|
||||
|
||||
export function calculateCursorPosition(editor: TipexEditor | undefined) {
|
||||
const selection = window.getSelection();
|
||||
if (!selection?.rangeCount) return { x: 0, y: 0 };
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
const rect = range.getBoundingClientRect();
|
||||
const editorRect = editor?.view.dom.getBoundingClientRect() || { left: 0, top: 0 };
|
||||
|
||||
// Check if on mobile
|
||||
const isMobile = window.innerWidth < 768;
|
||||
|
||||
if (isMobile) {
|
||||
// For mobile, position suggestions at the bottom center of the screen
|
||||
// to avoid issues with virtual keyboard and to make them more accessible
|
||||
return {
|
||||
x: window.innerWidth / 2,
|
||||
y: Math.min(rect.bottom - editorRect.top + window.scrollY, window.innerHeight - 250)
|
||||
};
|
||||
}
|
||||
|
||||
// Desktop positioning
|
||||
const x = Math.min(
|
||||
Math.max(rect.left - editorRect.left + window.scrollX, 100),
|
||||
window.innerWidth - 300
|
||||
);
|
||||
const y = rect.bottom - editorRect.top + window.scrollY + 10;
|
||||
|
||||
return { x, y };
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
export function createDebounce() {
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
|
||||
return function debounce<T extends (...args: any[]) => any>(
|
||||
fn: T,
|
||||
delay: number
|
||||
): (...args: Parameters<T>) => void {
|
||||
return (...args: Parameters<T>) => {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => fn(...args), delay);
|
||||
};
|
||||
};
|
||||
}
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
@ -0,0 +1,52 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/**
|
||||
* Extracts the last word (only alphabetical characters) from a given text
|
||||
*/
|
||||
function extractLastWord(text: string): string {
|
||||
const matches = text.match(/[a-zA-Z]+\s*$/);
|
||||
return matches ? matches[0].trim() : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the current word at cursor position from the document
|
||||
* Improved to handle different selection scenarios on mobile
|
||||
*/
|
||||
export function extractCurrentWord(doc: any, pos: number): string {
|
||||
// For mobile, we might need to look further back to find words
|
||||
// since touch selection can be less precise
|
||||
const lookbackDistance = window.innerWidth < 768 ? 200 : 100;
|
||||
const textBefore = doc.textBetween(Math.max(0, pos - lookbackDistance), pos);
|
||||
|
||||
// First try with standard pattern
|
||||
const word = extractLastWord(textBefore);
|
||||
|
||||
// If standard pattern fails, try a more lenient pattern for mobile
|
||||
if (!word && window.innerWidth < 768) {
|
||||
const lenientMatches = textBefore.match(/[a-zA-Z]+(?:\s+[a-zA-Z]+)*\s*$/);
|
||||
return lenientMatches ? lenientMatches[0].trim().split(/\s+/).pop() || '' : '';
|
||||
}
|
||||
|
||||
return word;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the last word from a selected text range
|
||||
* Improved to handle imprecise selections on mobile
|
||||
*/
|
||||
export function extractSelectedWord(doc: any, from: number, to: number): string {
|
||||
// On mobile, selections might include extra spaces
|
||||
const isMobile = window.innerWidth < 768;
|
||||
const selectedText = doc.textBetween(from, to, ' ');
|
||||
|
||||
if (isMobile && selectedText.trim()) {
|
||||
// For mobile, take the largest word in the selection
|
||||
const words = selectedText.trim().split(/\s+/);
|
||||
if (words.length > 0) {
|
||||
// Find the longest word in selection (mobile users often select multiple words)
|
||||
return words.reduce((longest: string, current: string) =>
|
||||
current.length > longest.length ? current : longest, '');
|
||||
}
|
||||
}
|
||||
|
||||
return extractLastWord(selectedText);
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
categories:
|
||||
- Poetry
|
||||
date: 2025-04-05 12:00:00 +0000
|
||||
tags:
|
||||
- Introspective
|
||||
title: Aimless
|
||||
layout: poetry
|
||||
---
|
||||
In an act of self defense
|
||||
I grew accustomed to solo living.
|
||||
|
||||
I held on to my two cents
|
||||
Unsure of when it should be given.
|
||||
|
||||
I thought this to be the fatal flaw
|
||||
That kept me from living right.
|
||||
|
||||
But now I see that aimlessness
|
||||
Is what kills all men at night.
|
||||
|
||||
Living without a purpose,
|
||||
Yet a desire to provide.
|
||||
|
||||
You know what you’d like to do,
|
||||
But what do you do with your time?
|
|
@ -0,0 +1,39 @@
|
|||
---
|
||||
categories:
|
||||
- Poetry
|
||||
date: 2025-04-13 12:00:00 +0000
|
||||
tags:
|
||||
- Introspective
|
||||
title: To My Cat
|
||||
layout: poetry
|
||||
---
|
||||
|
||||
<img src="/imgs/sekai.jpg" width="400" />
|
||||
|
||||
All things must pass,
|
||||
But your exit was unfair.
|
||||
I still keep my water glass
|
||||
Out of reach of your stare.
|
||||
|
||||
All things must pass,
|
||||
But I’m stuck in this nightmare.
|
||||
I knew our time wouldn’t last,
|
||||
Yet your presence is everywhere.
|
||||
|
||||
Daylight will find its way,
|
||||
But your passing clouds my vision.
|
||||
What good is my well-being
|
||||
If you’re not there for it envisioned?
|
||||
|
||||
I’d never complain about my black clothes
|
||||
Covered in your hair
|
||||
If it means when I lint roll
|
||||
It signifies that you’re still there.
|
||||
|
||||
But no,
|
||||
This breaks all the rules.
|
||||
I still keep my door ajar.
|
||||
I know you hate it when it’s closed,
|
||||
But I don’t know where you are.
|
||||
|
||||
— RIP Sekai, 4.10.25 3:37PM EDT
|
|
@ -0,0 +1,59 @@
|
|||
---
|
||||
categories:
|
||||
- blog
|
||||
date: 2025-03-09 13:00:00 +0000
|
||||
tags:
|
||||
- AI
|
||||
- Tutorial
|
||||
title: AI Character Tutorial Part 1
|
||||
year: 2025
|
||||
layout: thoughts
|
||||
draft: true
|
||||
---
|
||||
|
||||
It's March of 2025, and a lot of new advancements have been made in the generative art space. I was also lucky to have snagged a 3090 TI back when they first came out, and its 24GB VRAM allows you to do pretty much anything with diffusion models on your own machine. As for the LLM space, you'll still need to shell out 100GB+ VRAM to get something close to Claude and OpenAI's frontier models to run locally.
|
||||
|
||||
And so I'd like to catalogue my experience trying out various AI art techniques. The first tutorial will be creating your own "AI character" that you can have appear in any prompt you enter. The easiest method to accomplish this is with <a href="https://github.com/VectorSpaceLab/OmniGen?tab=readme-ov-file" target="_blank" class="link-primary">OmniGen</a>. You may be able to run it with as little as 8GB VRAM, but you'll need to mess with a <a href="https://github.com/VectorSpaceLab/OmniGen/blob/main/docs/inference.md#requiremented-resources" target="_blank" class="link-primary">few settings</a>.
|
||||
|
||||
You could follow their instructions to get their demo running locally, but to be able to make more complicated workflows that automate various common tasks, we'll be setting up a UI to interface with various diffusion models, LoRA's, high-res fixes, and what-have-you. There are a handful of open-source UI frontends for this like <a href="https://github.com/AUTOMATIC1111/stable-diffusion-webui" target="_blank" class="link-primary">Stable Diffusion WebUI</a>, <a href="https://github.com/invoke-ai/InvokeAI" target="_blank" class="link-primary">InvokeAI</a>, and even a <a href="https://github.com/LykosAI/StabilityMatrix" target="_blank" class="link-primary">UI that helps you manage and install all the other UI's</a>. We'll be using ComfyUI, as I've been following a Youtube channel called <a href="https://www.youtube.com/@NerdyRodent" target="_blank" class="link-primary">Nerdy Rodent</a> who provides excellent ComfyUI tutorials for cutting edge AI workflows, and provides these workflows for you to import if you subscribe to their Patreon (which I think is well worth the $5/mo!). I could direct you to their channel and call it a day, but there are a few things he doesn't address that I think is crucial if you plan to seriously experiment with AI art.
|
||||
|
||||
### Hardware
|
||||
|
||||
- Framework 16 AMD Ryzen™ 9 7940HS Laptop
|
||||
- 64GB RAM
|
||||
- Its hybrid GPU has 2GB VRAM but we won't be using it
|
||||
- Nvidia 3090 Ti in an eGPU enclosure
|
||||
- 24GB VRAM
|
||||
|
||||
These instructions will be for Linux and assume you're comfortable with the terminal, specifically Bash on Fedora 41. MacOS may work as well with a few adjustments. As for Windows, I recommend deleting your Windows partition. It will also be Nvidia specific, but hopefully when the Framework Desktop drops with their 128GB VRAM AMD chips I can figure this out with ROCm or tooling that runs CUDA on AMD. Don't worry if none of that makes sense, as it likely doesn't apply to you.
|
||||
|
||||
### Setup or Python Sucks
|
||||
|
||||
If you already have Python projects, or plan to install other Python projects, I highly recommend setting up `asdf`. It's a tool that allows you to manage multiple versions of software on your machine. It's particularly useful for managing Python environments, as it allows you to easily switch between different versions of Python and packages. Combine this with `venv` and you can be sure that the next time you try to get an old project running that it will Just Work™.
|
||||
|
||||
You'll first go through `asdf`'s <a href="https://asdf-vm.com/guide/getting-started.html" target="_blank" class="link-primary">installation guide</a>. When I first started using `asdf`, their git installation was the recommended method. But now they recommend using a package manager, pointing to homebrew and AUR. Since Fedora has neither of these, you could set up <a href="https://docs.brew.sh/Homebrew-on-Linux" target="_blank" class="link-primary">linuxbrew</a>, but I'm still using the git version without issue.
|
||||
|
||||
After you've configured your terminal to support `asdf`, you can add a `.tool-versions` file to the root of any python project you come across and put in `python 3.11.4` for `asdf install` to always install and use that specific version with your python commands.
|
||||
|
||||
First `git clone https://github.com/comfyanonymous/ComfyUI` in wherever you keep your projects. then `cd ComfyUI` and `touch .tool-versions`. Open that file and enter `python 3.12.9`, which is their recommended version today. Check the repo if that has changed since I've written this. Then run `asdf install`, and you should have the proper python version installed on your system.
|
||||
|
||||
But before we start installing dependencies, we'll want to encapsulate them in their own environment so other projects using `python 3.12.9` don't install conflicting packages<a href="#ref1" class="link-primary">[1]</a>. At the root of the project, run `python -m venv ./venv` to create a virtual environment in a folder called `venv`. Now run `source ./venv/bin/activate` to enter this virtual environment to install python packages in it. Continue following <a href="https://github.com/comfyanonymous/ComfyUI?tab=readme-ov-file#manual-install-windows-linux" target="_blank" class="link-primary">the instructions</a> in ComfyUI to install all of its dependencies for both your GPU and ComfyUI in general.
|
||||
|
||||
After all of that, you can now run `python main.py` to start ComfyUI and access it at `localhost:8188` in your browser (assuming you didn't change the default port).
|
||||
|
||||
At this point it might be worth creating an alias to start the project properly. If you don't have one already, create a `~/.aliases` file (or wherever you wish to manage them) and add `source ~/.aliases` to your `.bashrc`. Then in `~/.aliases`, add the line `alias comfy='cd ~/Projects/ComfyUI && asdf install && source ./venv/bin/activate && python main.py'`, swapping `~/Projects/ComfyUI` with where you cloned the project. Source `.bashrc` or restart your terminal, and you should be able to start up ComfyUI from anywhere by running `comfy` in the terminal.
|
||||
|
||||
### ComfyUI
|
||||
|
||||
Phew, that was a lot of setup! From here on out, things should be a lot more straightforward. The first thing you'll want to do is to follow <a href="https://github.com/ltdrdata/ComfyUI-Manager" target="_blank" class="link-primary">the installation instructions for the ComfyUI Mod Manager</a>. We'll follow the Method 1 instructions, which at this time is just cloning the repo into the `custom_nodes` folder with `comfyui-manager` as the directory name. From there, you can restart ComfyUI and see a new button in the top right for the mod manager.
|
||||
|
||||
TODO: Screenshot here
|
||||
|
||||
Once that's all set up, I'll essentially be repeating Nerdy Rodent in <a href="https://www.youtube.com/watch?v=mwiz1PNDWGg" target="_blank" class="link-primary">their wonderful tutorial for OmniGen</a>. All you need to do is search for `ComfyUI-OmniGen` in Mod Manager, and install it. This will make sure their custom ComfyUI node project is downloaded and installs all its dependencies. Once this completes, restart ComfyUI.
|
||||
|
||||
TODO: screenshot of searching for omnigen
|
||||
TODO: file of omnigen workflow
|
||||
|
||||
From there, you can take any
|
||||
|
||||
<p><span id="ref1" class="link-primary">[1]</span> Trust me, Python is _notorious_ for dependency hell, you'll thank me later
|
|
@ -0,0 +1,12 @@
|
|||
<script lang="ts">
|
||||
import Editor from '$lib/components/pad/Editor.svelte';
|
||||
|
||||
const INITIAL_HTML_CONTENT = `<h1>Simple editor</h1><p>What's written here will be saved to your local storage. Highlight a word to see suggested rhymes.</p>`;
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>silentsilas - Pad</title>
|
||||
<meta name="description" content="A text editor with rhyming and word suggestions" />
|
||||
</svelte:head>
|
||||
|
||||
<Editor initialContent={INITIAL_HTML_CONTENT} />
|
|
@ -12,8 +12,8 @@ export const GET = async () => {
|
|||
const body = render(posts);
|
||||
const options = {
|
||||
headers: {
|
||||
'Cache-Control': 'max-age=0, s-maxage=3600',
|
||||
'Content-Type': 'application/xml'
|
||||
'Cache-Control': 'max-age=0, s-maxage=604800',
|
||||
'Content-Type': 'application/atom+xml'
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -25,8 +25,8 @@ const render = (posts: Post[]) => `<?xml version="1.0" encoding="UTF-8" ?>
|
|||
<channel>
|
||||
<title>${siteTitle}</title>
|
||||
<description>${siteDescription}</description>
|
||||
<link>${siteURL}</link>
|
||||
<atom:link href="${siteURL}/rss.xml" rel="self" type="application/rss+xml"/>
|
||||
<link>${siteURL}/poetry</link>
|
||||
<atom:link href="${siteURL}/poetry/rss" rel="self" type="application/rss+xml"/>
|
||||
${posts
|
||||
.map(
|
||||
(post) => `<item>
|
||||
|
|
|
@ -12,8 +12,8 @@ export const GET = async () => {
|
|||
const body = render(posts);
|
||||
const options = {
|
||||
headers: {
|
||||
'Cache-Control': 'max-age=0, s-maxage=3600',
|
||||
'Content-Type': 'application/xml'
|
||||
'Cache-Control': 'max-age=0, s-maxage=604800',
|
||||
'Content-Type': 'application/atom+xml'
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -26,7 +26,7 @@ const render = (posts: Post[]) => `<?xml version="1.0" encoding="UTF-8" ?>
|
|||
<title>${siteTitle}</title>
|
||||
<description>${siteDescription}</description>
|
||||
<link>${siteURL}</link>
|
||||
<atom:link href="${siteURL}/rss.xml" rel="self" type="application/rss+xml"/>
|
||||
<atom:link href="${siteURL}/rss" rel="self" type="application/rss+xml"/>
|
||||
${posts
|
||||
.map(
|
||||
(post) => `<item>
|
||||
|
|
|
@ -12,12 +12,6 @@
|
|||
description:
|
||||
"A pastebin service, useful for sending quick text snippets or sharing sensitive data with Burn After Reading. It doesn't require an account, but I do log the IP addresses of visitors for security purposes. None of the data entered is stored on the server itself; it is E2E-encrypted and contained in the URLs it generates. Anyone with the link can access the data, however, so be sure to send it to them securely, enable Burn After Reading, or share only nonsensitive data."
|
||||
},
|
||||
{
|
||||
title: 'FreshRSS',
|
||||
url: 'https://rss.silentsilas.com',
|
||||
description:
|
||||
"A self-hosted RSS reader that I use to keep up with blogs, podcasts, and news sites. I can provision an account for you if you want to try it out. You would use an RSS client on your devices and have them subscribe to your FreshRSS URL. You would then add all of the RSS feeds you'd like to follow via the FreshRSS web interface and all of your RSS clients will then pull the articles for you to read."
|
||||
},
|
||||
{
|
||||
title: 'The Lounge',
|
||||
url: 'https://irc.silentsilas.com',
|
||||
|
|
|
@ -12,8 +12,8 @@ export const GET = async () => {
|
|||
const body = render(posts);
|
||||
const options = {
|
||||
headers: {
|
||||
'Cache-Control': 'max-age=0, s-maxage=3600',
|
||||
'Content-Type': 'application/xml'
|
||||
'Cache-Control': 'max-age=0, s-maxage=604800',
|
||||
'Content-Type': 'application/atom+xml'
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -25,8 +25,8 @@ const render = (posts: Post[]) => `<?xml version="1.0" encoding="UTF-8" ?>
|
|||
<channel>
|
||||
<title>${siteTitle}</title>
|
||||
<description>${siteDescription}</description>
|
||||
<link>${siteURL}</link>
|
||||
<atom:link href="${siteURL}/rss.xml" rel="self" type="application/rss+xml"/>
|
||||
<link>${siteURL}/thoughts</link>
|
||||
<atom:link href="${siteURL}/thoughts/rss" rel="self" type="application/rss+xml"/>
|
||||
${posts
|
||||
.map(
|
||||
(post) => `<item>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
// eslint-disable-next-line
|
||||
import * as tf from '@tensorflow/tfjs-node';
|
||||
import postEmbeddings from '$lib/utils/poetry/embeddings.json';
|
||||
import postEmbeddings from '$lib/utils/embeddings.json';
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import { getModel, type Embedding, type SearchResult } from '$lib/utils/search';
|
||||
import { fetchMarkdownPosts } from '$lib/utils';
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 595 KiB |
|
@ -1,9 +1,16 @@
|
|||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { threlteStudio } from '@threlte/studio/vite';
|
||||
import { aiRobots } from '@silentsilas/vite-plugin-ai-robots';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [threlteStudio(), sveltekit()],
|
||||
plugins: [
|
||||
threlteStudio(),
|
||||
sveltekit(),
|
||||
aiRobots({
|
||||
accessToken: process.env.DARK_VISITORS_TOKEN || ''
|
||||
})
|
||||
],
|
||||
ssr: {
|
||||
noExternal: ['three', 'three-inspect']
|
||||
},
|
||||
|
|
Loading…
Reference in New Issue