website-xp-phone 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.astro/content-assets.mjs +1 -0
- package/.astro/content-modules.mjs +1 -0
- package/.astro/content.d.ts +199 -0
- package/.astro/data-store.json +1 -0
- package/.astro/settings.json +8 -0
- package/.astro/types.d.ts +1 -0
- package/.devcontainer/devcontainer.json +23 -0
- package/.env.firebase.example +8 -0
- package/.firebaserc +5 -0
- package/.gitattributes +2 -0
- package/.github/copilot-instructions.md +131 -0
- package/.github/dependabot.yml +11 -0
- package/.github/workflows/ci.yml +45 -0
- package/.github/workflows/deploy-admin.yml +48 -0
- package/.github/workflows/static.yml +43 -0
- package/.gitmodules +5 -0
- package/FIREBASE_SETUP.md +69 -0
- package/README.md +63 -0
- package/SECURITY.md +11 -0
- package/admin/Admin.csproj +7 -0
- package/admin/Dockerfile +14 -0
- package/admin/Program.cs +8 -0
- package/deploy-admin-cloud-run.md +229 -0
- package/eslint.config.js +28 -0
- package/firebase.json +5 -0
- package/firestore.rules +29 -0
- package/index.html +52 -0
- package/package.json +48 -0
- package/pagerts_output.json +1 -0
- package/public/5.html +967 -0
- package/public/BAHNSCHRIFT.TTF +0 -0
- package/public/Beep.ogg +0 -0
- package/public/Clippy.png +0 -0
- package/public/Layered Network Security Model for Home Networks (slides).pdf +0 -0
- package/public/Layered Network Security Model for Home Networks.pdf +0 -0
- package/public/TODO.pdf +0 -0
- package/public/WoW_Config.zip +3 -0
- package/public/addons/energy-swing.txt +1 -0
- package/public/addons/lego-yoda-death-readme.txt +11 -0
- package/public/addons/lego-yoda-death.mp3 +0 -0
- package/public/addons/mana-blast.txt +1 -0
- package/public/addons/rage-volley.txt +1 -0
- package/public/addons/rueg-cell.txt +1 -0
- package/public/addons/rueg-elvui-profile.txt +1 -0
- package/public/addons/rueg-grid2.txt +214 -0
- package/public/addons/rueg-plater-smol.txt +1 -0
- package/public/addons/rueg-plater.txt +1 -0
- package/public/addons/rueg-wa-druid.txt +1 -0
- package/public/addons/rueg-wa-priest.txt +1 -0
- package/public/addons/rueg-wa-rogue.txt +1 -0
- package/public/addons/rueg-wa-shaman.txt +1 -0
- package/public/addons/rueg-wa-warrior.txt +1 -0
- package/public/addons/spirit-smash.txt +1 -0
- package/public/avatar.jpg +0 -0
- package/public/avatar.png +0 -0
- package/public/crunchy_kick.ogg +0 -0
- package/public/documents/resume.html +312 -0
- package/public/favicon.ico +0 -0
- package/public/images/Ateric1.png +0 -0
- package/public/images/Ateric2.png +0 -0
- package/public/images/equal1.png +0 -0
- package/public/images/hyperawareofwhatacatis.png +0 -0
- package/public/images/kogg1.png +0 -0
- package/public/images/kogg2.png +0 -0
- package/public/images/rueg1.png +0 -0
- package/public/images/rueg2.png +0 -0
- package/public/incorrect_responses.txt +126 -0
- package/public/loading.css +51 -0
- package/public/resume.pdf +0 -0
- package/public/robots.txt +9 -0
- package/public/soundcloud.json +57 -0
- package/public/spinner.svg +12 -0
- package/public/tada.wav +0 -0
- package/public/yooh.mp3 +0 -0
- package/render.yaml +5 -0
- package/scripts/ensure-blog-worktree.mjs +24 -0
- package/scripts/generate-soundcloud-json.mjs +198 -0
- package/scripts/git-worktree-helper.mjs +122 -0
- package/scripts/hoist-dev-blog-local.mjs +149 -0
- package/scripts/music-schema.mjs +56 -0
- package/scripts/publish-soundcloud-json.mjs +32 -0
- package/scripts/sync-music-links-from-worktree.mjs +32 -0
- package/src/App.tsx +1500 -0
- package/src/addons.json +76 -0
- package/src/components/Addon.tsx +223 -0
- package/src/components/BlogContent.tsx +103 -0
- package/src/components/CopyToClipboardButton.tsx +21 -0
- package/src/components/MenuBar.tsx +151 -0
- package/src/components/MenuBarWithContext.tsx +6 -0
- package/src/components/Modal.tsx +17 -0
- package/src/components/MusicContent.tsx +309 -0
- package/src/components/NavBarController.tsx +55 -0
- package/src/components/NavBarControllerWrapper.tsx +13 -0
- package/src/components/Page.tsx +56 -0
- package/src/components/SitemapContent.tsx +125 -0
- package/src/contacts.json +32 -0
- package/src/env.d.ts +13 -0
- package/src/lib/assistantStateMachine.ts +80 -0
- package/src/lib/audioOverlap.ts +99 -0
- package/src/lib/keyboardInputUtils.ts +182 -0
- package/src/lib/musicSchema.ts +85 -0
- package/src/lib/naggingAssistantClient.ts +241 -0
- package/src/lib/resumeAnalytics.ts +163 -0
- package/src/main.tsx +35 -0
- package/src/pages.json +50 -0
- package/src/sections.json +243 -0
- package/src/src+addons.zip +3 -0
- package/src/styles/main.css +465 -0
- package/src/utils/blogSecurity.ts +87 -0
- package/src/utils/menuItems.ts +33 -0
- package/src/windowing/MinimizedSections.tsx +86 -0
- package/src/windowing/Section.tsx +586 -0
- package/src/windowing/context.tsx +13 -0
- package/src/windowing/hooks.ts +10 -0
- package/src/windowing/index.ts +7 -0
- package/src/windowing/provider.tsx +74 -0
- package/src/windowing/server.ts +3 -0
- package/src/windowing/types.ts +33 -0
- package/src/windowing/utils.ts +135 -0
- package/tests/generate-soundcloud-json.test.mjs +63 -0
- package/tests/music-schema.test.mjs +53 -0
- package/tsconfig.json +26 -0
- package/vite.config.ts +304 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
type ArenaBucket = {
|
|
2
|
+
idle: HTMLAudioElement[];
|
|
3
|
+
active: Set<HTMLAudioElement>;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
const MAX_ALLOCATIONS_PER_SECOND = 90;
|
|
7
|
+
const ALLOCATION_WINDOW_MS = 1000;
|
|
8
|
+
|
|
9
|
+
const audioArena = {
|
|
10
|
+
buckets: new Map<string, ArenaBucket>(),
|
|
11
|
+
allocationTimestamps: [] as number[],
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const getOrCreateBucket = (src: string): ArenaBucket => {
|
|
15
|
+
let bucket = audioArena.buckets.get(src);
|
|
16
|
+
if (!bucket) {
|
|
17
|
+
bucket = { idle: [], active: new Set<HTMLAudioElement>() };
|
|
18
|
+
audioArena.buckets.set(src, bucket);
|
|
19
|
+
}
|
|
20
|
+
return bucket;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const trimAllocationWindow = (now: number) => {
|
|
24
|
+
audioArena.allocationTimestamps = audioArena.allocationTimestamps.filter(
|
|
25
|
+
(timestamp) => now - timestamp < ALLOCATION_WINDOW_MS,
|
|
26
|
+
);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const canAllocateAudio = () => {
|
|
30
|
+
const now = Date.now();
|
|
31
|
+
trimAllocationWindow(now);
|
|
32
|
+
return audioArena.allocationTimestamps.length < MAX_ALLOCATIONS_PER_SECOND;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const markAllocation = () => {
|
|
36
|
+
audioArena.allocationTimestamps.push(Date.now());
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const releaseToPool = (src: string, audio: HTMLAudioElement) => {
|
|
40
|
+
const bucket = getOrCreateBucket(src);
|
|
41
|
+
if (!bucket.active.has(audio)) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
bucket.active.delete(audio);
|
|
46
|
+
audio.pause();
|
|
47
|
+
audio.currentTime = 0;
|
|
48
|
+
bucket.idle.push(audio);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const allocateAudio = (src: string): HTMLAudioElement | null => {
|
|
52
|
+
if (!canAllocateAudio()) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const audio = new Audio(src);
|
|
57
|
+
audio.preload = "auto";
|
|
58
|
+
|
|
59
|
+
audio.addEventListener("ended", () => {
|
|
60
|
+
releaseToPool(src, audio);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
audio.addEventListener("error", () => {
|
|
64
|
+
releaseToPool(src, audio);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
markAllocation();
|
|
68
|
+
return audio;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const acquireFromArena = (src: string): HTMLAudioElement | null => {
|
|
72
|
+
const bucket = getOrCreateBucket(src);
|
|
73
|
+
|
|
74
|
+
const reusable = bucket.idle.pop();
|
|
75
|
+
if (reusable) {
|
|
76
|
+
bucket.active.add(reusable);
|
|
77
|
+
return reusable;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const allocated = allocateAudio(src);
|
|
81
|
+
if (!allocated) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
bucket.active.add(allocated);
|
|
86
|
+
return allocated;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export const playLayeredAudio = (src: string) => {
|
|
90
|
+
const audio = acquireFromArena(src);
|
|
91
|
+
if (!audio) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
audio.currentTime = 0;
|
|
96
|
+
void audio.play().catch(() => {
|
|
97
|
+
releaseToPool(src, audio);
|
|
98
|
+
});
|
|
99
|
+
};
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { playLayeredAudio } from './audioOverlap';
|
|
2
|
+
|
|
3
|
+
const CLIPPY_SEQUENCE = 'fuckingclippy';
|
|
4
|
+
const CLIPPY_SEQUENCE_LENGTH = CLIPPY_SEQUENCE.length;
|
|
5
|
+
|
|
6
|
+
const CLIPPY_SESSION_KEY = 'kroflmao_ui_var';
|
|
7
|
+
|
|
8
|
+
let sequenceProgress = 0;
|
|
9
|
+
let allowFullVolumeTail = false;
|
|
10
|
+
let listenerAttached = false;
|
|
11
|
+
|
|
12
|
+
const audio = new Audio('/yooh.mp3');
|
|
13
|
+
|
|
14
|
+
const visibilitySubscribers = new Set<(visible: boolean) => void>();
|
|
15
|
+
const bubbleSubscribers = new Set<(visible: boolean) => void>();
|
|
16
|
+
|
|
17
|
+
let bubbleVisible = false;
|
|
18
|
+
let clippyClickTimestamps: number[] = [];
|
|
19
|
+
let bubbleTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
20
|
+
|
|
21
|
+
const CLIPPY_CLICK_THRESHOLD = 7;
|
|
22
|
+
const CLIPPY_CLICK_WINDOW_MS = 10_000;
|
|
23
|
+
const BUBBLE_DISMISS_MS = 8_000;
|
|
24
|
+
|
|
25
|
+
const setBubbleVisible = (visible: boolean) => {
|
|
26
|
+
bubbleVisible = visible;
|
|
27
|
+
bubbleSubscribers.forEach((cb) => cb(visible));
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const subscribeClippyBubble = (
|
|
31
|
+
callback: (visible: boolean) => void,
|
|
32
|
+
): (() => void) => {
|
|
33
|
+
bubbleSubscribers.add(callback);
|
|
34
|
+
callback(bubbleVisible);
|
|
35
|
+
return () => bubbleSubscribers.delete(callback);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const showClippyHint = () => {
|
|
39
|
+
if (bubbleTimeout !== null) {
|
|
40
|
+
clearTimeout(bubbleTimeout);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
setBubbleVisible(true);
|
|
44
|
+
bubbleTimeout = setTimeout(() => {
|
|
45
|
+
setBubbleVisible(false);
|
|
46
|
+
bubbleTimeout = null;
|
|
47
|
+
}, BUBBLE_DISMISS_MS);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export const onClippyClick = () => {
|
|
51
|
+
allowFullVolumeTail = false;
|
|
52
|
+
audio.pause();
|
|
53
|
+
audio.currentTime = 0;
|
|
54
|
+
audio.volume = 0;
|
|
55
|
+
playLayeredAudio('/tada.wav');
|
|
56
|
+
|
|
57
|
+
const now = Date.now();
|
|
58
|
+
clippyClickTimestamps = clippyClickTimestamps.filter(
|
|
59
|
+
(t) => now - t < CLIPPY_CLICK_WINDOW_MS,
|
|
60
|
+
);
|
|
61
|
+
clippyClickTimestamps.push(now);
|
|
62
|
+
|
|
63
|
+
if (clippyClickTimestamps.length >= CLIPPY_CLICK_THRESHOLD) {
|
|
64
|
+
clippyClickTimestamps = [];
|
|
65
|
+
if (bubbleTimeout !== null) {
|
|
66
|
+
clearTimeout(bubbleTimeout);
|
|
67
|
+
}
|
|
68
|
+
setBubbleVisible(true);
|
|
69
|
+
bubbleTimeout = setTimeout(() => {
|
|
70
|
+
setBubbleVisible(false);
|
|
71
|
+
bubbleTimeout = null;
|
|
72
|
+
}, BUBBLE_DISMISS_MS);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
let currentVisibility = sessionStorage.getItem(CLIPPY_SESSION_KEY) === '1';
|
|
77
|
+
|
|
78
|
+
export const subscribeClippyVisibility = (
|
|
79
|
+
callback: (visible: boolean) => void,
|
|
80
|
+
): (() => void) => {
|
|
81
|
+
visibilitySubscribers.add(callback);
|
|
82
|
+
callback(currentVisibility);
|
|
83
|
+
return () => visibilitySubscribers.delete(callback);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const setVisible = (visible: boolean) => {
|
|
87
|
+
currentVisibility = visible;
|
|
88
|
+
sessionStorage.setItem(CLIPPY_SESSION_KEY, visible ? '1' : '0');
|
|
89
|
+
visibilitySubscribers.forEach((cb) => cb(visible));
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const resetAudio = () => {
|
|
93
|
+
if (allowFullVolumeTail) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
audio.pause();
|
|
97
|
+
audio.currentTime = 0;
|
|
98
|
+
audio.volume = 0;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const startOrUpdateAudio = (matched: number) => {
|
|
102
|
+
if (audio.paused) {
|
|
103
|
+
audio.currentTime = 0;
|
|
104
|
+
}
|
|
105
|
+
audio.volume = Math.min(1, Math.max(0, matched / CLIPPY_SEQUENCE_LENGTH));
|
|
106
|
+
void audio.play().catch(() => {
|
|
107
|
+
// Autoplay may be blocked before the first user gesture.
|
|
108
|
+
});
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const onAudioEnded = () => {
|
|
112
|
+
allowFullVolumeTail = false;
|
|
113
|
+
audio.currentTime = 0;
|
|
114
|
+
audio.volume = 0;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const isTypingTarget = (target: EventTarget | null) => {
|
|
118
|
+
if (!(target instanceof HTMLElement)) {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (target.isContentEditable) {
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const tag = target.tagName.toLowerCase();
|
|
127
|
+
return tag === 'input' || tag === 'textarea' || tag === 'select';
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
131
|
+
if (event.ctrlKey || event.metaKey || event.altKey) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (isTypingTarget(event.target)) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const key = event.key.toLowerCase();
|
|
140
|
+
if (key.length !== 1 || key < 'a' || key > 'z') {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (key === CLIPPY_SEQUENCE[sequenceProgress]) {
|
|
145
|
+
sequenceProgress += 1;
|
|
146
|
+
startOrUpdateAudio(sequenceProgress);
|
|
147
|
+
|
|
148
|
+
if (sequenceProgress === CLIPPY_SEQUENCE_LENGTH) {
|
|
149
|
+
setVisible(true);
|
|
150
|
+
audio.volume = 1;
|
|
151
|
+
allowFullVolumeTail = true;
|
|
152
|
+
sequenceProgress = 0;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
sequenceProgress = key === CLIPPY_SEQUENCE[0] ? 1 : 0;
|
|
159
|
+
if (sequenceProgress > 0) {
|
|
160
|
+
startOrUpdateAudio(sequenceProgress);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
resetAudio();
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
export const attachClippyListener = () => {
|
|
168
|
+
if (listenerAttached) {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
window.addEventListener('keydown', handleKeyDown);
|
|
172
|
+
audio.addEventListener('ended', onAudioEnded);
|
|
173
|
+
listenerAttached = true;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
export const detachClippyListener = () => {
|
|
177
|
+
window.removeEventListener('keydown', handleKeyDown);
|
|
178
|
+
audio.removeEventListener('ended', onAudioEnded);
|
|
179
|
+
listenerAttached = false;
|
|
180
|
+
allowFullVolumeTail = false;
|
|
181
|
+
resetAudio();
|
|
182
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
const MUSIC_GROUP_NAME = "akinevz";
|
|
2
|
+
const MUSIC_GROUP_URL = "https://akinevz.com";
|
|
3
|
+
const MUSIC_GROUP_DESCRIPTION =
|
|
4
|
+
"Independent sound designer, music creator, and electronic music artist.";
|
|
5
|
+
const MUSIC_GROUP_ALTERNATE_NAMES = [
|
|
6
|
+
"KINE",
|
|
7
|
+
"KALE",
|
|
8
|
+
"I lied my name isn't actually KINE",
|
|
9
|
+
"I lied my name isn't actually KALE",
|
|
10
|
+
];
|
|
11
|
+
const MUSIC_GROUP_GENRES = [
|
|
12
|
+
"Electronic",
|
|
13
|
+
"Experimental",
|
|
14
|
+
"Industrial",
|
|
15
|
+
"Drone",
|
|
16
|
+
"Glitch",
|
|
17
|
+
];
|
|
18
|
+
const MUSIC_GROUP_SAME_AS = [
|
|
19
|
+
"https://soundcloud.com/akinevz",
|
|
20
|
+
"https://youtube.com/@akinevz",
|
|
21
|
+
"https://x.com/akinevz",
|
|
22
|
+
"https://github.com/akinevz2",
|
|
23
|
+
];
|
|
24
|
+
const MUSIC_GROUP_PRIMARY_ALIAS = MUSIC_GROUP_ALTERNATE_NAMES[0] ?? MUSIC_GROUP_NAME;
|
|
25
|
+
|
|
26
|
+
export type MusicSchemaTrack = {
|
|
27
|
+
title: string;
|
|
28
|
+
url: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type MusicRecordingSchema = {
|
|
32
|
+
"@type": "MusicRecording";
|
|
33
|
+
name: string;
|
|
34
|
+
url: string;
|
|
35
|
+
byArtist: {
|
|
36
|
+
"@type": "MusicGroup";
|
|
37
|
+
name: string;
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type MusicGroupSchema = {
|
|
42
|
+
"@context": "https://schema.org";
|
|
43
|
+
"@type": "MusicGroup";
|
|
44
|
+
name: string;
|
|
45
|
+
alternateName: string[];
|
|
46
|
+
url: string;
|
|
47
|
+
genre: string[];
|
|
48
|
+
description: string;
|
|
49
|
+
sameAs: string[];
|
|
50
|
+
track: MusicRecordingSchema[];
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const serializeJsonLd = (value: unknown): string =>
|
|
54
|
+
JSON.stringify(value).replace(/</g, "\\u003c");
|
|
55
|
+
|
|
56
|
+
export const buildMusicGroupSchema = (
|
|
57
|
+
tracks: MusicSchemaTrack[] = [],
|
|
58
|
+
): MusicGroupSchema => ({
|
|
59
|
+
"@context": "https://schema.org",
|
|
60
|
+
"@type": "MusicGroup",
|
|
61
|
+
name: MUSIC_GROUP_NAME,
|
|
62
|
+
alternateName: MUSIC_GROUP_ALTERNATE_NAMES,
|
|
63
|
+
url: MUSIC_GROUP_URL,
|
|
64
|
+
genre: MUSIC_GROUP_GENRES,
|
|
65
|
+
description: MUSIC_GROUP_DESCRIPTION,
|
|
66
|
+
sameAs: MUSIC_GROUP_SAME_AS,
|
|
67
|
+
track: Array.isArray(tracks)
|
|
68
|
+
? tracks
|
|
69
|
+
.filter(
|
|
70
|
+
(track): track is MusicSchemaTrack =>
|
|
71
|
+
Boolean(track) &&
|
|
72
|
+
typeof track.title === "string" &&
|
|
73
|
+
typeof track.url === "string",
|
|
74
|
+
)
|
|
75
|
+
.map((track) => ({
|
|
76
|
+
"@type": "MusicRecording",
|
|
77
|
+
name: track.title,
|
|
78
|
+
url: track.url,
|
|
79
|
+
byArtist: {
|
|
80
|
+
"@type": "MusicGroup",
|
|
81
|
+
name: MUSIC_GROUP_PRIMARY_ALIAS,
|
|
82
|
+
},
|
|
83
|
+
}))
|
|
84
|
+
: [],
|
|
85
|
+
});
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
export type AssistantConfig = {
|
|
2
|
+
endpoint: string;
|
|
3
|
+
model: string;
|
|
4
|
+
apiKey: string;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
type ModelOption = {
|
|
8
|
+
id: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
type OpenAiModelsResponse = {
|
|
12
|
+
data?: Array<{ id?: string }>;
|
|
13
|
+
models?: Array<{ id?: string } | string>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type OpenAiChatResponse = {
|
|
17
|
+
choices?: Array<{
|
|
18
|
+
message?: {
|
|
19
|
+
content?: string;
|
|
20
|
+
};
|
|
21
|
+
text?: string;
|
|
22
|
+
}>;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const STORAGE_KEY = "ok_go_v1";
|
|
26
|
+
const MAX_ASSISTANT_RESPONSE_CHARS = 512;
|
|
27
|
+
|
|
28
|
+
const normalizeEndpoint = (endpoint: string) => endpoint.trim().replace(/\/+$/, "");
|
|
29
|
+
|
|
30
|
+
const constrainAssistantText = (text: string) =>
|
|
31
|
+
text.trim().slice(0, MAX_ASSISTANT_RESPONSE_CHARS);
|
|
32
|
+
|
|
33
|
+
const buildHeaders = (apiKey: string): HeadersInit => {
|
|
34
|
+
const headers: HeadersInit = {
|
|
35
|
+
"Content-Type": "application/json",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
if (apiKey.trim()) {
|
|
39
|
+
headers.Authorization = `Bearer ${apiKey.trim()}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return headers;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const parseModelOptions = (payload: unknown): ModelOption[] => {
|
|
46
|
+
if (!payload || typeof payload !== "object") {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const candidate = payload as OpenAiModelsResponse;
|
|
51
|
+
const options = new Set<string>();
|
|
52
|
+
|
|
53
|
+
if (Array.isArray(candidate.data)) {
|
|
54
|
+
candidate.data.forEach((item) => {
|
|
55
|
+
if (item?.id && typeof item.id === "string") {
|
|
56
|
+
options.add(item.id);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (Array.isArray(candidate.models)) {
|
|
62
|
+
candidate.models.forEach((item) => {
|
|
63
|
+
if (typeof item === "string") {
|
|
64
|
+
options.add(item);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (item?.id && typeof item.id === "string") {
|
|
69
|
+
options.add(item.id);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return Array.from(options)
|
|
75
|
+
.sort((a, b) => a.localeCompare(b))
|
|
76
|
+
.map((id) => ({ id }));
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const fetchModelsFrom = async (url: string, apiKey: string): Promise<ModelOption[]> => {
|
|
80
|
+
const response = await fetch(url, {
|
|
81
|
+
method: "GET",
|
|
82
|
+
headers: buildHeaders(apiKey),
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
if (!response.ok) {
|
|
86
|
+
throw new Error(`HTTP ${response.status}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const payload: unknown = await response.json();
|
|
90
|
+
return parseModelOptions(payload);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export const loadAssistantConfig = (): AssistantConfig => {
|
|
94
|
+
try {
|
|
95
|
+
const raw = window.localStorage.getItem(STORAGE_KEY);
|
|
96
|
+
if (!raw) {
|
|
97
|
+
return { endpoint: "", model: "", apiKey: "" };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const parsed = JSON.parse(raw) as Partial<AssistantConfig>;
|
|
101
|
+
return {
|
|
102
|
+
endpoint: typeof parsed.endpoint === "string" ? parsed.endpoint : "",
|
|
103
|
+
model: typeof parsed.model === "string" ? parsed.model : "",
|
|
104
|
+
apiKey: typeof parsed.apiKey === "string" ? parsed.apiKey : "",
|
|
105
|
+
};
|
|
106
|
+
} catch {
|
|
107
|
+
return { endpoint: "", model: "", apiKey: "" };
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export const saveAssistantConfig = (config: AssistantConfig) => {
|
|
112
|
+
window.localStorage.setItem(
|
|
113
|
+
STORAGE_KEY,
|
|
114
|
+
JSON.stringify({
|
|
115
|
+
endpoint: normalizeEndpoint(config.endpoint),
|
|
116
|
+
model: config.model.trim(),
|
|
117
|
+
apiKey: config.apiKey,
|
|
118
|
+
}),
|
|
119
|
+
);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export const discoverAssistantModels = async (
|
|
123
|
+
endpoint: string,
|
|
124
|
+
apiKey: string,
|
|
125
|
+
): Promise<ModelOption[]> => {
|
|
126
|
+
const base = normalizeEndpoint(endpoint);
|
|
127
|
+
if (!base) {
|
|
128
|
+
throw new Error("Endpoint is required.");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const candidates = [
|
|
132
|
+
`${base}/v1/models`,
|
|
133
|
+
`${base}/models`,
|
|
134
|
+
`${base}/api/models`,
|
|
135
|
+
`${base}/openai/models`,
|
|
136
|
+
];
|
|
137
|
+
|
|
138
|
+
for (const candidateUrl of candidates) {
|
|
139
|
+
try {
|
|
140
|
+
const options = await fetchModelsFrom(candidateUrl, apiKey);
|
|
141
|
+
if (options.length > 0) {
|
|
142
|
+
return options;
|
|
143
|
+
}
|
|
144
|
+
} catch {
|
|
145
|
+
// Try next compatible endpoint.
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
throw new Error("No models found from the provided endpoint.");
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const chatCompletionCandidates = (endpoint: string) => {
|
|
153
|
+
const base = normalizeEndpoint(endpoint);
|
|
154
|
+
return [
|
|
155
|
+
`${base}/v1/chat/completions`,
|
|
156
|
+
`${base}/chat/completions`,
|
|
157
|
+
`${base}/api/chat/completions`,
|
|
158
|
+
];
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const extractChatText = (payload: unknown): string => {
|
|
162
|
+
if (!payload || typeof payload !== "object") {
|
|
163
|
+
return "";
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const candidate = payload as OpenAiChatResponse;
|
|
167
|
+
const first = candidate.choices?.[0];
|
|
168
|
+
const content = first?.message?.content ?? first?.text ?? "";
|
|
169
|
+
|
|
170
|
+
return typeof content === "string" ? constrainAssistantText(content) : "";
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
export const requestAssistantCompletion = async (
|
|
174
|
+
config: AssistantConfig,
|
|
175
|
+
prompt: string,
|
|
176
|
+
options?: { conversationPrompt?: boolean },
|
|
177
|
+
): Promise<string> => {
|
|
178
|
+
const endpoint = normalizeEndpoint(config.endpoint);
|
|
179
|
+
const model = config.model.trim();
|
|
180
|
+
|
|
181
|
+
if (!endpoint) {
|
|
182
|
+
throw new Error("Missing endpoint configuration.");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (!model) {
|
|
186
|
+
throw new Error("Missing model configuration.");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const systemInstructionBase =
|
|
190
|
+
"Operate in lowest reasoning mode. Keep the response at or below 512 characters. Markdown is forbidden except optional inline code delimited by single backticks, and that inline code form must be used exclusively to indicate emphasis. Reply in 1-3 concise, practical sentences.";
|
|
191
|
+
// Wisdom framing is disabled in production while CORS-related wisdom flow is paused.
|
|
192
|
+
// const conversationExtension =
|
|
193
|
+
// "Accept conversation about any topic with no specialization or assumed input format, and ensure the response still resembles practical wisdom.";
|
|
194
|
+
|
|
195
|
+
const body = {
|
|
196
|
+
model,
|
|
197
|
+
messages: [
|
|
198
|
+
{
|
|
199
|
+
role: "system",
|
|
200
|
+
content: options?.conversationPrompt
|
|
201
|
+
// ? `${systemInstructionBase} ${conversationExtension}`
|
|
202
|
+
? systemInstructionBase
|
|
203
|
+
: systemInstructionBase,
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
role: "user",
|
|
207
|
+
content: prompt,
|
|
208
|
+
},
|
|
209
|
+
],
|
|
210
|
+
temperature: 1.1,
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
let lastError = "Request failed.";
|
|
214
|
+
|
|
215
|
+
for (const url of chatCompletionCandidates(endpoint)) {
|
|
216
|
+
try {
|
|
217
|
+
const response = await fetch(url, {
|
|
218
|
+
method: "POST",
|
|
219
|
+
headers: buildHeaders(config.apiKey),
|
|
220
|
+
body: JSON.stringify(body),
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
if (!response.ok) {
|
|
224
|
+
lastError = `HTTP ${response.status}`;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const payload: unknown = await response.json();
|
|
229
|
+
const text = extractChatText(payload);
|
|
230
|
+
if (text) {
|
|
231
|
+
return text;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
lastError = "Response did not include text.";
|
|
235
|
+
} catch (error) {
|
|
236
|
+
lastError = error instanceof Error ? error.message : "Network error";
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
throw new Error(lastError);
|
|
241
|
+
};
|