kcg-code 0.0.0-stage → 0.2.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/README.md +230 -2
- package/bin/kcgcode.ts +13 -0
- package/bun-env.d.ts +19 -0
- package/kcg-code.config.example.json +3 -0
- package/package.json +84 -4
- package/public/logo.svg +1 -0
- package/public/manifest.json +18 -0
- package/public/sw.js +49 -0
- package/src/App.tsx +39 -0
- package/src/__tests__/manifest.test.ts +61 -0
- package/src/build-config.ts +9 -0
- package/src/cli/__tests__/daemon.test.ts +84 -0
- package/src/cli/__tests__/dashboard.test.ts +224 -0
- package/src/cli/__tests__/keep-awake.test.ts +44 -0
- package/src/cli/__tests__/port.test.ts +86 -0
- package/src/cli/args.ts +186 -0
- package/src/cli/attach.ts +154 -0
- package/src/cli/commands.ts +327 -0
- package/src/cli/daemon.ts +275 -0
- package/src/cli/dashboard.tsx +1060 -0
- package/src/cli/index.ts +47 -0
- package/src/cli/keep-awake.ts +148 -0
- package/src/cli/port.ts +92 -0
- package/src/cli/theme.ts +100 -0
- package/src/components/auth/AuthGate.tsx +182 -0
- package/src/components/auth/AvatarEditor.tsx +236 -0
- package/src/components/auth/LockScreen.tsx +714 -0
- package/src/components/auth/ProfileAvatar.tsx +60 -0
- package/src/components/auth/WelcomeScreen.tsx +672 -0
- package/src/components/auth/__tests__/WelcomeScreen.test.tsx +42 -0
- package/src/components/auth/avatar-icons.tsx +114 -0
- package/src/components/layout/AppShell.tsx +147 -0
- package/src/components/layout/AppSidebar.tsx +419 -0
- package/src/components/layout/SearchDialog.tsx +189 -0
- package/src/components/layout/SidebarAccount.tsx +111 -0
- package/src/components/layout/SidebarSessionMenu.tsx +163 -0
- package/src/components/layout/ThemeToggle.tsx +67 -0
- package/src/components/projects/FolderBrowser.tsx +142 -0
- package/src/components/projects/HomeChat.tsx +287 -0
- package/src/components/projects/HomeRecentSessions.tsx +90 -0
- package/src/components/projects/NewProjectDialog.tsx +249 -0
- package/src/components/projects/ProjectInstructions.tsx +218 -0
- package/src/components/projects/ProjectRow.tsx +58 -0
- package/src/components/projects/ProjectSidePanel.tsx +514 -0
- package/src/components/sessions/AgentLogo.tsx +42 -0
- package/src/components/sessions/AgentModeList.tsx +265 -0
- package/src/components/sessions/AgentPicker.tsx +132 -0
- package/src/components/sessions/AssistantTurn.tsx +145 -0
- package/src/components/sessions/AttachmentTile.tsx +161 -0
- package/src/components/sessions/CodeBlock.tsx +107 -0
- package/src/components/sessions/ComposerActionSheet.tsx +96 -0
- package/src/components/sessions/ConfirmDeleteProjectDialog.tsx +80 -0
- package/src/components/sessions/ConfirmSessionDeleteDialog.tsx +92 -0
- package/src/components/sessions/ContextPanel.tsx +661 -0
- package/src/components/sessions/DropPlate.tsx +165 -0
- package/src/components/sessions/FileDropOverlay.tsx +232 -0
- package/src/components/sessions/FileMentionDropdown.tsx +47 -0
- package/src/components/sessions/ImageLightbox.tsx +173 -0
- package/src/components/sessions/MarkdownContent.tsx +187 -0
- package/src/components/sessions/Mascot.tsx +47 -0
- package/src/components/sessions/MessageActions.tsx +60 -0
- package/src/components/sessions/MessageRail.tsx +121 -0
- package/src/components/sessions/ModelPicker.tsx +247 -0
- package/src/components/sessions/ModelSearchList.tsx +231 -0
- package/src/components/sessions/NewSessionDialog.tsx +172 -0
- package/src/components/sessions/PendingAttachments.tsx +127 -0
- package/src/components/sessions/PromptCard.tsx +1085 -0
- package/src/components/sessions/PromptPanel.tsx +112 -0
- package/src/components/sessions/SessionComposer.tsx +641 -0
- package/src/components/sessions/SessionHeader.tsx +176 -0
- package/src/components/sessions/SessionList.tsx +503 -0
- package/src/components/sessions/SessionRow.tsx +237 -0
- package/src/components/sessions/SessionTimeline.tsx +275 -0
- package/src/components/sessions/SessionTimelineSkeleton.tsx +83 -0
- package/src/components/sessions/SessionView.tsx +240 -0
- package/src/components/sessions/ThoughtProcessBlock.tsx +199 -0
- package/src/components/sessions/UserMessage.tsx +177 -0
- package/src/components/sessions/__tests__/ContextPanel.test.tsx +256 -0
- package/src/components/sessions/__tests__/MarkdownContent.test.tsx +89 -0
- package/src/components/sessions/__tests__/MessageRail.test.tsx +40 -0
- package/src/components/sessions/__tests__/PermissionCard.test.tsx +79 -0
- package/src/components/sessions/__tests__/PromptCard.test.ts +166 -0
- package/src/components/sessions/__tests__/QuestionCard.test.tsx +166 -0
- package/src/components/sessions/__tests__/SessionTimelineSkeleton.test.tsx +32 -0
- package/src/components/sessions/__tests__/SessionView.test.ts +386 -0
- package/src/components/settings/AboutGroup.tsx +170 -0
- package/src/components/settings/AccountGroup.tsx +122 -0
- package/src/components/settings/DevicesGroup.tsx +151 -0
- package/src/components/settings/LhrRemoteAccess.tsx +257 -0
- package/src/components/settings/PasskeysGroup.tsx +551 -0
- package/src/components/settings/RemoteAccessGroup.tsx +1201 -0
- package/src/components/settings/SecurityGroup.tsx +566 -0
- package/src/components/settings/WorkspaceGroup.tsx +370 -0
- package/src/components/settings/__tests__/AboutGroup.test.tsx +47 -0
- package/src/components/settings/__tests__/PasskeysGroup.test.tsx +109 -0
- package/src/components/settings/__tests__/WorkspaceGroup.test.tsx +49 -0
- package/src/components/settings/prefs.tsx +333 -0
- package/src/components/skills/InstallNotifications.tsx +326 -0
- package/src/components/skills/InstallSkillDialog.tsx +390 -0
- package/src/components/skills/SkillCards.tsx +254 -0
- package/src/components/skills/SkillsProjectPicker.tsx +178 -0
- package/src/components/ui/adw-dialog.tsx +114 -0
- package/src/components/ui/adw-toggle-group.tsx +75 -0
- package/src/components/ui/alert-dialog.tsx +179 -0
- package/src/components/ui/alert.tsx +60 -0
- package/src/components/ui/badge.tsx +46 -0
- package/src/components/ui/bubble.tsx +125 -0
- package/src/components/ui/button.tsx +62 -0
- package/src/components/ui/card.tsx +75 -0
- package/src/components/ui/collapsible.tsx +19 -0
- package/src/components/ui/dialog.tsx +142 -0
- package/src/components/ui/dropdown-menu.tsx +225 -0
- package/src/components/ui/empty.tsx +94 -0
- package/src/components/ui/field.tsx +234 -0
- package/src/components/ui/input-group.tsx +161 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/label.tsx +19 -0
- package/src/components/ui/message-scroller.tsx +131 -0
- package/src/components/ui/message.tsx +85 -0
- package/src/components/ui/popover.tsx +46 -0
- package/src/components/ui/search-entry.tsx +70 -0
- package/src/components/ui/select.tsx +172 -0
- package/src/components/ui/separator.tsx +28 -0
- package/src/components/ui/sheet.tsx +138 -0
- package/src/components/ui/sidebar.tsx +699 -0
- package/src/components/ui/skeleton.tsx +15 -0
- package/src/components/ui/sonner.tsx +45 -0
- package/src/components/ui/spinner.tsx +16 -0
- package/src/components/ui/switch.tsx +32 -0
- package/src/components/ui/textarea.tsx +18 -0
- package/src/components/ui/tooltip.tsx +51 -0
- package/src/config.ts +114 -0
- package/src/db.ts +146 -0
- package/src/frontend.tsx +51 -0
- package/src/hooks/__tests__/useMention.test.ts +79 -0
- package/src/hooks/__tests__/useResyncOnReconnect.test.ts +128 -0
- package/src/hooks/__tests__/useRouter.test.ts +17 -0
- package/src/hooks/__tests__/useSmoothText.react.test.ts +194 -0
- package/src/hooks/__tests__/useSmoothText.test.ts +61 -0
- package/src/hooks/__tests__/useTheme.test.ts +118 -0
- package/src/hooks/use-mobile.ts +16 -0
- package/src/hooks/useAgentPicker.ts +101 -0
- package/src/hooks/useMediaQuery.ts +49 -0
- package/src/hooks/useMention.ts +236 -0
- package/src/hooks/useProjects.ts +88 -0
- package/src/hooks/useResyncOnReconnect.ts +28 -0
- package/src/hooks/useRouter.ts +92 -0
- package/src/hooks/useSessionChat.ts +427 -0
- package/src/hooks/useSessionContext.ts +106 -0
- package/src/hooks/useSessionList.ts +327 -0
- package/src/hooks/useSidebarData.ts +108 -0
- package/src/hooks/useSmoothText.ts +122 -0
- package/src/hooks/useTheme.ts +164 -0
- package/src/hooks/useWebSocket.ts +196 -0
- package/src/index.css +27 -0
- package/src/index.html +14 -0
- package/src/index.ts +49 -0
- package/src/lib/__tests__/attachments.test.ts +58 -0
- package/src/lib/__tests__/avatar-presets.test.ts +25 -0
- package/src/lib/__tests__/collapsible.test.ts +101 -0
- package/src/lib/__tests__/composer.test.ts +118 -0
- package/src/lib/__tests__/highlight.test.ts +42 -0
- package/src/lib/__tests__/home-chat.test.ts +64 -0
- package/src/lib/__tests__/lock-screen.test.ts +64 -0
- package/src/lib/__tests__/model-picker.test.ts +205 -0
- package/src/lib/__tests__/onboarding.test.ts +41 -0
- package/src/lib/__tests__/permission-info.test.ts +61 -0
- package/src/lib/__tests__/project-extensions.test.ts +83 -0
- package/src/lib/__tests__/project-overview.test.ts +188 -0
- package/src/lib/__tests__/references.test.ts +121 -0
- package/src/lib/__tests__/routes.test.ts +134 -0
- package/src/lib/__tests__/session-selection.test.ts +41 -0
- package/src/lib/__tests__/session-summary.test.ts +115 -0
- package/src/lib/__tests__/session-title.test.ts +58 -0
- package/src/lib/__tests__/sidebar-groups.test.ts +100 -0
- package/src/lib/__tests__/skill-installs.test.ts +135 -0
- package/src/lib/__tests__/skills-catalog.test.ts +124 -0
- package/src/lib/__tests__/tone.test.ts +37 -0
- package/src/lib/__tests__/turns-delta.test.ts +55 -0
- package/src/lib/agent-types.ts +49 -0
- package/src/lib/api.ts +306 -0
- package/src/lib/attachments.ts +203 -0
- package/src/lib/auth.ts +92 -0
- package/src/lib/avatar-presets.ts +31 -0
- package/src/lib/collapsible.ts +75 -0
- package/src/lib/composer.ts +89 -0
- package/src/lib/data-events.ts +17 -0
- package/src/lib/headlines.ts +30 -0
- package/src/lib/highlight.ts +186 -0
- package/src/lib/layout.ts +20 -0
- package/src/lib/lock-screen.ts +54 -0
- package/src/lib/model-picker.ts +133 -0
- package/src/lib/onboarding.ts +38 -0
- package/src/lib/passkeys.ts +113 -0
- package/src/lib/pending-prompt.ts +53 -0
- package/src/lib/permission-info.ts +101 -0
- package/src/lib/project-extensions.ts +136 -0
- package/src/lib/project-overview.ts +123 -0
- package/src/lib/prompts.ts +87 -0
- package/src/lib/references.ts +106 -0
- package/src/lib/routes.ts +124 -0
- package/src/lib/session-handoff.ts +25 -0
- package/src/lib/session-selection.ts +40 -0
- package/src/lib/session-status.ts +45 -0
- package/src/lib/session-summary.ts +68 -0
- package/src/lib/session-title.ts +63 -0
- package/src/lib/sidebar-groups.ts +78 -0
- package/src/lib/skill-installs.ts +362 -0
- package/src/lib/skills-catalog.ts +138 -0
- package/src/lib/time.ts +22 -0
- package/src/lib/tone.ts +80 -0
- package/src/lib/turns.ts +320 -0
- package/src/lib/utils.ts +6 -0
- package/src/logo.svg +31 -0
- package/src/pages/projects/ProjectDetailPage.tsx +131 -0
- package/src/pages/projects/ProjectsPage.tsx +97 -0
- package/src/pages/sessions/SessionPage.tsx +122 -0
- package/src/pages/settings/SettingsPage.tsx +366 -0
- package/src/pages/settings/__tests__/SettingsPage.test.ts +16 -0
- package/src/pages/skills/SkillsPage.tsx +745 -0
- package/src/paths.ts +121 -0
- package/src/runtime.ts +24 -0
- package/src/server/__tests__/app.test.ts +1044 -0
- package/src/server/__tests__/auth.test.ts +370 -0
- package/src/server/__tests__/config.test.ts +101 -0
- package/src/server/__tests__/db.test.ts +485 -0
- package/src/server/__tests__/host.test.ts +14 -0
- package/src/server/__tests__/tunnel-config.test.ts +38 -0
- package/src/server/app.ts +427 -0
- package/src/server/db/auth.ts +311 -0
- package/src/server/db/messages.ts +50 -0
- package/src/server/db/projects.ts +106 -0
- package/src/server/db/prompts.ts +78 -0
- package/src/server/db/rows.ts +150 -0
- package/src/server/db/schema.ts +221 -0
- package/src/server/db/sessions.ts +239 -0
- package/src/server/db/tunnel.ts +124 -0
- package/src/server/host.ts +12 -0
- package/src/server/result.ts +15 -0
- package/src/server/routes/auth-guard.ts +156 -0
- package/src/server/routes/auth.routes.ts +287 -0
- package/src/server/routes/helpers.ts +165 -0
- package/src/server/routes/meta.routes.ts +24 -0
- package/src/server/routes/passkeys.routes.ts +161 -0
- package/src/server/routes/projects.routes.ts +167 -0
- package/src/server/routes/sessions.routes.ts +197 -0
- package/src/server/routes/skills.routes.ts +136 -0
- package/src/server/routes/tunnel.routes.ts +132 -0
- package/src/server/routes/types.ts +35 -0
- package/src/server/routes/uploads.routes.ts +54 -0
- package/src/server/routes/workspace.routes.ts +127 -0
- package/src/server/services/__tests__/app-info.test.ts +18 -0
- package/src/server/services/__tests__/attachments.test.ts +159 -0
- package/src/server/services/__tests__/auth-passkeys.test.ts +358 -0
- package/src/server/services/__tests__/auth.test.ts +341 -0
- package/src/server/services/__tests__/claude-code.test.ts +609 -0
- package/src/server/services/__tests__/claude-mcp.test.ts +90 -0
- package/src/server/services/__tests__/claude-skills.test.ts +63 -0
- package/src/server/services/__tests__/frpc-installer.test.ts +83 -0
- package/src/server/services/__tests__/git-info.test.ts +74 -0
- package/src/server/services/__tests__/opencode.test.ts +320 -0
- package/src/server/services/__tests__/project-manager.test.ts +190 -0
- package/src/server/services/__tests__/sandbox.test.ts +141 -0
- package/src/server/services/__tests__/session-manager.test.ts +3509 -0
- package/src/server/services/__tests__/skill-install-jobs.test.ts +245 -0
- package/src/server/services/__tests__/skills-registry.test.ts +365 -0
- package/src/server/services/__tests__/tunnel-lhr.test.ts +234 -0
- package/src/server/services/__tests__/tunnel.test.ts +367 -0
- package/src/server/services/__tests__/websocket-gateway.test.ts +677 -0
- package/src/server/services/__tests__/workspace.test.ts +249 -0
- package/src/server/services/app-info.ts +29 -0
- package/src/server/services/attachments.ts +193 -0
- package/src/server/services/auth-passkeys.ts +459 -0
- package/src/server/services/auth.ts +633 -0
- package/src/server/services/claude-code-client.ts +1093 -0
- package/src/server/services/claude-code-translate.ts +370 -0
- package/src/server/services/claude-mcp.ts +122 -0
- package/src/server/services/claude-skills.ts +102 -0
- package/src/server/services/frpc-installer.ts +183 -0
- package/src/server/services/git-info.ts +87 -0
- package/src/server/services/opencode-client.ts +714 -0
- package/src/server/services/opencode-server.ts +319 -0
- package/src/server/services/opencode-sse.ts +82 -0
- package/src/server/services/project-manager.ts +147 -0
- package/src/server/services/sandbox.ts +83 -0
- package/src/server/services/session-events.ts +256 -0
- package/src/server/services/session-manager.ts +1439 -0
- package/src/server/services/skill-install-jobs.ts +275 -0
- package/src/server/services/skills-registry.ts +480 -0
- package/src/server/services/tunnel-api.ts +207 -0
- package/src/server/services/tunnel-config.ts +45 -0
- package/src/server/services/tunnel-lhr.ts +387 -0
- package/src/server/services/tunnel.ts +633 -0
- package/src/server/services/turn-stream.ts +339 -0
- package/src/server/services/websocket-gateway.ts +374 -0
- package/src/server/services/workspace.ts +223 -0
- package/src/server/services/ws-transport.ts +121 -0
- package/src/types/context.ts +69 -0
- package/src/types/index.ts +13 -0
- package/src/types/message.ts +58 -0
- package/src/types/project.ts +16 -0
- package/src/types/prompt.ts +118 -0
- package/src/types/session.ts +52 -0
- package/src/types/turns.ts +47 -0
- package/src/ws-protocol.ts +93 -0
- package/styles/globals.css +311 -0
- package/tsconfig.json +37 -0
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Store global job instalasi skill (FE) — sumber data notifikasi instalasi.
|
|
3
|
+
*
|
|
4
|
+
* - `startInstall()` memanggil `POST /api/projects/:id/skills/install` (202)
|
|
5
|
+
* lalu mem-poll `GET /api/skills/installs/:id?from=<n>` sampai selesai;
|
|
6
|
+
* baris log baru ditambahkan bertahap (output "live").
|
|
7
|
+
* - Store hidup di modul (bukan komponen) sehingga instalasi & notifikasinya
|
|
8
|
+
* tetap berjalan saat user pindah halaman atau menutup dialog.
|
|
9
|
+
* - `hydrateInstalls()` memulihkan notifikasi setelah reload: job berjalan
|
|
10
|
+
* DAN job selesai yang belum ditutup (server menyimpannya ~10 menit), lengkap
|
|
11
|
+
* dengan log. Status tampilan (disembunyikan / panel output terbuka) disimpan
|
|
12
|
+
* di localStorage sehingga kembali persis seperti sebelum refresh.
|
|
13
|
+
* - Selesai sukses -> `onInstallFinished` (mis. halaman Skills memuat ulang
|
|
14
|
+
* daftar terpasang).
|
|
15
|
+
* - Sukses tapi belum aktif (`refreshed: false`, ada chat berjalan) -> polling
|
|
16
|
+
* pelan berlanjut sampai server mengaktifkannya otomatis setelah chat selesai.
|
|
17
|
+
*
|
|
18
|
+
* `useSyncExternalStore` membaca snapshot; setiap perubahan membuat objek
|
|
19
|
+
* baru (immutable) agar React me-render ulang.
|
|
20
|
+
*/
|
|
21
|
+
import { useSyncExternalStore } from "react";
|
|
22
|
+
import { ApiError, apiFetch } from "@/lib/api";
|
|
23
|
+
import type { InstallJob, InstallJobLog } from "@/server/services/skill-install-jobs";
|
|
24
|
+
import type { AgentType } from "@/types";
|
|
25
|
+
|
|
26
|
+
/** Interval polling saat job berjalan. */
|
|
27
|
+
const POLL_MS = 700;
|
|
28
|
+
/**
|
|
29
|
+
* Interval polling job sukses yang skill-nya belum aktif (menunggu chat di
|
|
30
|
+
* Project selesai lalu agent memuat ulang skill otomatis).
|
|
31
|
+
*/
|
|
32
|
+
const PENDING_ACTIVATION_POLL_MS = 3000;
|
|
33
|
+
/** Berhenti menunggu aktivasi setelah selama ini (chat sangat panjang). */
|
|
34
|
+
const PENDING_ACTIVATION_MAX_MS = 30 * 60_000;
|
|
35
|
+
/** Batas baris log yang disimpan di client (server juga membatasi). */
|
|
36
|
+
const MAX_CLIENT_LINES = 500;
|
|
37
|
+
|
|
38
|
+
export interface InstallEntry {
|
|
39
|
+
job: InstallJob;
|
|
40
|
+
lines: string[];
|
|
41
|
+
/** Indeks absolut baris berikutnya yang diminta ke server. */
|
|
42
|
+
next: number;
|
|
43
|
+
/** Sebagian baris awal sudah terbuang di server. */
|
|
44
|
+
truncated: boolean;
|
|
45
|
+
/** User menyembunyikan notifikasi ini. */
|
|
46
|
+
dismissed: boolean;
|
|
47
|
+
/** Panel log dibuka. */
|
|
48
|
+
expanded: boolean;
|
|
49
|
+
/** Pesan error jaringan saat polling (job mungkin tetap berjalan). */
|
|
50
|
+
pollError: string | null;
|
|
51
|
+
/** Permintaan pembatalan sudah dikirim (menunggu proses berhenti). */
|
|
52
|
+
cancelling: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
type Listener = () => void;
|
|
56
|
+
|
|
57
|
+
let entries: InstallEntry[] = [];
|
|
58
|
+
const listeners = new Set<Listener>();
|
|
59
|
+
const finishListeners = new Set<(job: InstallJob) => void>();
|
|
60
|
+
const polling = new Set<string>();
|
|
61
|
+
|
|
62
|
+
function emit(): void {
|
|
63
|
+
for (const l of listeners) l();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function update(jobId: string, fn: (e: InstallEntry) => InstallEntry): void {
|
|
67
|
+
let changed = false;
|
|
68
|
+
entries = entries.map((e) => {
|
|
69
|
+
if (e.job.id !== jobId) return e;
|
|
70
|
+
changed = true;
|
|
71
|
+
return fn(e);
|
|
72
|
+
});
|
|
73
|
+
if (changed) emit();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function upsert(entry: InstallEntry): void {
|
|
77
|
+
const exists = entries.some((e) => e.job.id === entry.job.id);
|
|
78
|
+
entries = exists
|
|
79
|
+
? entries.map((e) => (e.job.id === entry.job.id ? entry : e))
|
|
80
|
+
: [entry, ...entries];
|
|
81
|
+
emit();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function newEntry(job: InstallJob, expanded: boolean, dismissed = false): InstallEntry {
|
|
85
|
+
return {
|
|
86
|
+
job,
|
|
87
|
+
lines: [],
|
|
88
|
+
next: 0,
|
|
89
|
+
truncated: false,
|
|
90
|
+
dismissed,
|
|
91
|
+
expanded,
|
|
92
|
+
pollError: null,
|
|
93
|
+
cancelling: false,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ------------------------------------------------ status tampilan (persist) ----
|
|
98
|
+
|
|
99
|
+
const UI_STATE_KEY = "kcg-skill-installs-ui";
|
|
100
|
+
|
|
101
|
+
/** Status tampilan per job yang bertahan melewati refresh. */
|
|
102
|
+
export interface InstallUiState {
|
|
103
|
+
/** Job yang notifikasinya disembunyikan / ditutup user. */
|
|
104
|
+
dismissed: string[];
|
|
105
|
+
/** Job yang panel output-nya terbuka. */
|
|
106
|
+
expanded: string[];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function readUiState(): InstallUiState {
|
|
110
|
+
try {
|
|
111
|
+
const raw = localStorage.getItem(UI_STATE_KEY);
|
|
112
|
+
if (!raw) return { dismissed: [], expanded: [] };
|
|
113
|
+
const v = JSON.parse(raw) as Partial<InstallUiState>;
|
|
114
|
+
const ids = (x: unknown) => (Array.isArray(x) ? x.filter((i) => typeof i === "string") : []);
|
|
115
|
+
return { dismissed: ids(v.dismissed), expanded: ids(v.expanded) };
|
|
116
|
+
} catch {
|
|
117
|
+
return { dismissed: [], expanded: [] };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Job selesai yang ditutup tidak ada lagi di `entries`; ingat id-nya di sini. */
|
|
122
|
+
let closedIds = new Set<string>(readUiState().dismissed);
|
|
123
|
+
|
|
124
|
+
function saveUiState(): void {
|
|
125
|
+
const state: InstallUiState = {
|
|
126
|
+
dismissed: [
|
|
127
|
+
...new Set([...closedIds, ...entries.filter((e) => e.dismissed).map((e) => e.job.id)]),
|
|
128
|
+
],
|
|
129
|
+
expanded: entries.filter((e) => e.expanded).map((e) => e.job.id),
|
|
130
|
+
};
|
|
131
|
+
try {
|
|
132
|
+
localStorage.setItem(UI_STATE_KEY, JSON.stringify(state));
|
|
133
|
+
} catch {
|
|
134
|
+
// Storage tidak tersedia (mode privat) — status hanya di memori.
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Entry yang dipulihkan dari daftar job server (murni — diuji). Job berjalan
|
|
140
|
+
* selalu dipulihkan (walau disembunyikan, agar kartu tetap tahu statusnya);
|
|
141
|
+
* job selesai hanya bila belum ditutup. Urutan: terbaru dulu.
|
|
142
|
+
*/
|
|
143
|
+
export function hydrationEntries(
|
|
144
|
+
jobs: readonly InstallJob[],
|
|
145
|
+
ui: InstallUiState,
|
|
146
|
+
existingIds: ReadonlySet<string>,
|
|
147
|
+
): InstallEntry[] {
|
|
148
|
+
const dismissed = new Set(ui.dismissed);
|
|
149
|
+
const expanded = new Set(ui.expanded);
|
|
150
|
+
return jobs
|
|
151
|
+
.filter((job) => !existingIds.has(job.id))
|
|
152
|
+
.filter((job) => job.status === "running" || !dismissed.has(job.id))
|
|
153
|
+
.map((job) => newEntry(job, expanded.has(job.id), dismissed.has(job.id)));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Gabungkan potongan log baru ke entry (murni — diuji). */
|
|
157
|
+
export function mergeLog(entry: InstallEntry, log: InstallJobLog): InstallEntry {
|
|
158
|
+
// Hanya ambil baris yang belum kita punya (polling bisa tumpang tindih).
|
|
159
|
+
const skip = Math.max(0, entry.next - log.from);
|
|
160
|
+
const fresh = log.lines.slice(skip);
|
|
161
|
+
let lines = fresh.length > 0 ? [...entry.lines, ...fresh] : entry.lines;
|
|
162
|
+
let truncated = entry.truncated || log.truncated;
|
|
163
|
+
if (lines.length > MAX_CLIENT_LINES) {
|
|
164
|
+
lines = lines.slice(lines.length - MAX_CLIENT_LINES);
|
|
165
|
+
truncated = true;
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
...entry,
|
|
169
|
+
job: log.job,
|
|
170
|
+
lines,
|
|
171
|
+
next: Math.max(entry.next, log.from + log.lines.length),
|
|
172
|
+
truncated,
|
|
173
|
+
pollError: null,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function poll(jobId: string): Promise<void> {
|
|
178
|
+
if (polling.has(jobId)) return;
|
|
179
|
+
polling.add(jobId);
|
|
180
|
+
try {
|
|
181
|
+
for (;;) {
|
|
182
|
+
const cur = entries.find((e) => e.job.id === jobId);
|
|
183
|
+
if (!cur) return;
|
|
184
|
+
try {
|
|
185
|
+
const res = await apiFetch(`/api/skills/installs/${jobId}?from=${cur.next}`);
|
|
186
|
+
const log = (await res.json()) as InstallJobLog;
|
|
187
|
+
const wasRunning = cur.job.status === "running";
|
|
188
|
+
update(jobId, (e) => mergeLog(e, log));
|
|
189
|
+
if (log.job.status !== "running") {
|
|
190
|
+
if (wasRunning) for (const l of finishListeners) l(log.job);
|
|
191
|
+
// Terpasang tapi belum aktif (ada chat berjalan) -> terus pantau
|
|
192
|
+
// pelan sampai server menandai aktif, agar notifikasi ikut berubah.
|
|
193
|
+
const waiting =
|
|
194
|
+
log.job.status === "succeeded" &&
|
|
195
|
+
log.job.refreshed === false &&
|
|
196
|
+
Date.now() - (log.job.finishedAt ?? Date.now()) < PENDING_ACTIVATION_MAX_MS;
|
|
197
|
+
if (!waiting) return;
|
|
198
|
+
await new Promise((r) => setTimeout(r, PENDING_ACTIVATION_POLL_MS));
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
} catch (e) {
|
|
202
|
+
if (e instanceof ApiError && e.status === 404) {
|
|
203
|
+
// Job sudah sukses (sedang menunggu aktivasi) lalu kedaluwarsa /
|
|
204
|
+
// server restart: skill tetap terpasang — jangan ubah jadi gagal.
|
|
205
|
+
// Restart server juga memuat ulang skill, jadi anggap aktif.
|
|
206
|
+
if (cur.job.status === "succeeded") {
|
|
207
|
+
update(jobId, (en) => ({ ...en, job: { ...en.job, refreshed: true } }));
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
// Server restart saat instalasi berjalan: job hilang bersama prosesnya.
|
|
211
|
+
update(jobId, (en) => ({
|
|
212
|
+
...en,
|
|
213
|
+
job: { ...en.job, status: "failed", error: "INSTALL_JOB_LOST", finishedAt: Date.now() },
|
|
214
|
+
pollError: null,
|
|
215
|
+
}));
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
update(jobId, (en) => ({ ...en, pollError: "Connection lost — retrying…" }));
|
|
219
|
+
}
|
|
220
|
+
await new Promise((r) => setTimeout(r, POLL_MS));
|
|
221
|
+
}
|
|
222
|
+
} finally {
|
|
223
|
+
polling.delete(jobId);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Mulai instalasi. Melempar `ApiError` bila server menolak sebelum job dibuat
|
|
229
|
+
* (validasi, Project tak dikenal, instalasi lain sedang berjalan).
|
|
230
|
+
*/
|
|
231
|
+
export async function startInstall(
|
|
232
|
+
projectId: string,
|
|
233
|
+
source: string,
|
|
234
|
+
skill: string,
|
|
235
|
+
agentTypes: readonly AgentType[] = ["opencode"],
|
|
236
|
+
): Promise<InstallJob> {
|
|
237
|
+
const res = await apiFetch(`/api/projects/${projectId}/skills/install`, {
|
|
238
|
+
method: "POST",
|
|
239
|
+
body: JSON.stringify({ source, skill, agents: agentTypes }),
|
|
240
|
+
});
|
|
241
|
+
const { job } = (await res.json()) as { job: InstallJob };
|
|
242
|
+
upsert(newEntry(job, true));
|
|
243
|
+
saveUiState();
|
|
244
|
+
void poll(job.id);
|
|
245
|
+
return job;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
let hydrated = false;
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Pulihkan notifikasi setelah reload (sekali per muat halaman): job berjalan
|
|
252
|
+
* + job selesai yang belum ditutup, beserta status tampilannya. Log diambil
|
|
253
|
+
* ulang dari awal; job selesai cukup satu kali ambil.
|
|
254
|
+
*/
|
|
255
|
+
export async function hydrateInstalls(): Promise<void> {
|
|
256
|
+
if (hydrated) return;
|
|
257
|
+
hydrated = true;
|
|
258
|
+
try {
|
|
259
|
+
const res = await apiFetch("/api/skills/installs");
|
|
260
|
+
const { jobs } = (await res.json()) as { jobs: InstallJob[] };
|
|
261
|
+
const ui = readUiState();
|
|
262
|
+
const restored = hydrationEntries(jobs, ui, new Set(entries.map((e) => e.job.id)));
|
|
263
|
+
// Id yang tidak dikenal server lagi (kedaluwarsa) dibuang dari storage.
|
|
264
|
+
const known = new Set(jobs.map((j) => j.id));
|
|
265
|
+
closedIds = new Set(ui.dismissed.filter((id) => known.has(id)));
|
|
266
|
+
if (restored.length > 0) {
|
|
267
|
+
entries = [...entries, ...restored];
|
|
268
|
+
emit();
|
|
269
|
+
}
|
|
270
|
+
saveUiState();
|
|
271
|
+
for (const e of restored) void poll(e.job.id);
|
|
272
|
+
} catch {
|
|
273
|
+
hydrated = false; // coba lagi di kesempatan berikutnya
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function dismissInstall(jobId: string): void {
|
|
278
|
+
update(jobId, (e) => ({ ...e, dismissed: true }));
|
|
279
|
+
// Job selesai yang ditutup tidak perlu disimpan lagi — cukup ingat id-nya
|
|
280
|
+
// agar tidak muncul kembali setelah refresh.
|
|
281
|
+
const e = entries.find((x) => x.job.id === jobId);
|
|
282
|
+
if (e && e.job.status !== "running") {
|
|
283
|
+
closedIds.add(jobId);
|
|
284
|
+
entries = entries.filter((x) => x.job.id !== jobId);
|
|
285
|
+
emit();
|
|
286
|
+
}
|
|
287
|
+
saveUiState();
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Minta server membatalkan job; status akhir datang lewat polling. */
|
|
291
|
+
export async function cancelInstall(jobId: string): Promise<void> {
|
|
292
|
+
update(jobId, (e) => ({ ...e, cancelling: true, expanded: true }));
|
|
293
|
+
saveUiState();
|
|
294
|
+
try {
|
|
295
|
+
await apiFetch(`/api/skills/installs/${jobId}/cancel`, { method: "POST" });
|
|
296
|
+
} catch (e) {
|
|
297
|
+
// 409 = sudah selesai duluan; biarkan polling membawa status akhirnya.
|
|
298
|
+
if (!(e instanceof ApiError && e.status === 409)) {
|
|
299
|
+
update(jobId, (en) => ({
|
|
300
|
+
...en,
|
|
301
|
+
cancelling: false,
|
|
302
|
+
pollError: "Couldn't cancel — try again.",
|
|
303
|
+
}));
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export function toggleInstallLog(jobId: string): void {
|
|
309
|
+
update(jobId, (e) => ({ ...e, expanded: !e.expanded }));
|
|
310
|
+
saveUiState();
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Tampilkan lagi notifikasi job (mis. klik "Installing…" pada kartu). */
|
|
314
|
+
export function revealInstall(jobId: string): void {
|
|
315
|
+
closedIds.delete(jobId);
|
|
316
|
+
update(jobId, (e) => ({ ...e, dismissed: false, expanded: true }));
|
|
317
|
+
saveUiState();
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Berlangganan event "job selesai"; mengembalikan fungsi berhenti. */
|
|
321
|
+
export function onInstallFinished(cb: (job: InstallJob) => void): () => void {
|
|
322
|
+
finishListeners.add(cb);
|
|
323
|
+
return () => finishListeners.delete(cb);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function subscribe(l: Listener): () => void {
|
|
327
|
+
listeners.add(l);
|
|
328
|
+
return () => listeners.delete(l);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function snapshot(): InstallEntry[] {
|
|
332
|
+
return entries;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export function useInstalls(): InstallEntry[] {
|
|
336
|
+
return useSyncExternalStore(subscribe, snapshot, snapshot);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Job berjalan untuk Project + skill tertentu (untuk status tombol kartu). */
|
|
340
|
+
export function findRunningInstall(
|
|
341
|
+
list: readonly InstallEntry[],
|
|
342
|
+
projectId: string,
|
|
343
|
+
source: string,
|
|
344
|
+
skillId: string,
|
|
345
|
+
): InstallEntry | undefined {
|
|
346
|
+
return list.find(
|
|
347
|
+
(e) =>
|
|
348
|
+
e.job.status === "running" &&
|
|
349
|
+
e.job.projectId === projectId &&
|
|
350
|
+
e.job.source === source &&
|
|
351
|
+
e.job.skillId === skillId,
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Hanya untuk test: kosongkan store. */
|
|
356
|
+
export function __resetInstallsForTest(): void {
|
|
357
|
+
entries = [];
|
|
358
|
+
closedIds = new Set();
|
|
359
|
+
hydrated = false;
|
|
360
|
+
polling.clear();
|
|
361
|
+
emit();
|
|
362
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helper tampilan katalog skill (skills.sh) di halaman Skills — logika murni
|
|
3
|
+
* tanpa DOM, dipisah agar dapat diuji `bun test`.
|
|
4
|
+
*/
|
|
5
|
+
import { AGENT_TYPES } from "@/lib/agent-types";
|
|
6
|
+
import type { SkillInfo } from "@/server/services/opencode-client";
|
|
7
|
+
import type { AuditStatus, RegistrySkill } from "@/server/services/skills-registry";
|
|
8
|
+
import type { AgentType, Project } from "@/types";
|
|
9
|
+
|
|
10
|
+
/** Kunci localStorage Project terakhir yang dipilih di halaman Skills. */
|
|
11
|
+
export const LAST_SKILLS_PROJECT_KEY = "kcg-skills-project";
|
|
12
|
+
|
|
13
|
+
/** Saran kata kunci saat kolom cari masih kosong (tab Browse). */
|
|
14
|
+
export const SEARCH_SUGGESTIONS = [
|
|
15
|
+
"react",
|
|
16
|
+
"nextjs",
|
|
17
|
+
"testing",
|
|
18
|
+
"typescript",
|
|
19
|
+
"python",
|
|
20
|
+
"design",
|
|
21
|
+
"docs",
|
|
22
|
+
"git",
|
|
23
|
+
] as const;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Project terpilih di halaman Skills: `?project=` (dari sidebar) menang, lalu
|
|
27
|
+
* pilihan terakhir yang diingat, lalu satu-satunya Project bila hanya ada
|
|
28
|
+
* satu. Id yang tidak dikenal (Project terhapus) dilewati.
|
|
29
|
+
*/
|
|
30
|
+
export function resolveSkillsProject(
|
|
31
|
+
projects: readonly Project[],
|
|
32
|
+
urlId: string | null,
|
|
33
|
+
storedId: string | null,
|
|
34
|
+
): Project | null {
|
|
35
|
+
for (const id of [urlId, storedId]) {
|
|
36
|
+
if (id === null) continue;
|
|
37
|
+
const hit = projects.find((p) => p.id === id);
|
|
38
|
+
if (hit) return hit;
|
|
39
|
+
}
|
|
40
|
+
return projects.length === 1 ? (projects[0] ?? null) : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Owner GitHub dari `owner/repo`; `null` untuk sumber well-known (domain). */
|
|
44
|
+
export function githubOwner(source: string): string | null {
|
|
45
|
+
const [owner, repo, ...rest] = source.split("/");
|
|
46
|
+
if (!owner || !repo || rest.length > 0) return null;
|
|
47
|
+
return owner;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Skill terpasang + agent yang memuatnya (gabungan daftar per agent). */
|
|
51
|
+
export interface InstalledSkill extends SkillInfo {
|
|
52
|
+
/** Urut sesuai `AGENT_TYPES`; minimal satu. */
|
|
53
|
+
agents: AgentType[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Gabungkan daftar skill per agent menjadi satu daftar: skill dengan asal
|
|
58
|
+
* (`source`) dan nama (case-insensitive) sama dianggap satu skill yang
|
|
59
|
+
* dimuat beberapa agent. Deskripsi diambil dari daftar pertama yang punya.
|
|
60
|
+
*/
|
|
61
|
+
export function mergeInstalledSkills(
|
|
62
|
+
byAgent: Partial<Record<AgentType, readonly SkillInfo[]>>,
|
|
63
|
+
): InstalledSkill[] {
|
|
64
|
+
const merged = new Map<string, InstalledSkill>();
|
|
65
|
+
for (const agent of AGENT_TYPES) {
|
|
66
|
+
for (const skill of byAgent[agent] ?? []) {
|
|
67
|
+
const key = `${skill.source}:${skill.name.toLowerCase()}`;
|
|
68
|
+
const hit = merged.get(key);
|
|
69
|
+
if (!hit) {
|
|
70
|
+
merged.set(key, { ...skill, agents: [agent] });
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (!hit.agents.includes(agent)) hit.agents.push(agent);
|
|
74
|
+
if (hit.description === null) hit.description = skill.description;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return [...merged.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Agent yang sudah memuat skill katalog ini dari folder Project (cocokkan
|
|
82
|
+
* nama folder / nama). Skill global sengaja tidak dihitung: instalasi di
|
|
83
|
+
* halaman ini memasang ke folder Project.
|
|
84
|
+
*/
|
|
85
|
+
export function installedAgentsFor(
|
|
86
|
+
skill: RegistrySkill,
|
|
87
|
+
installed: readonly InstalledSkill[],
|
|
88
|
+
): AgentType[] {
|
|
89
|
+
const names = new Set([skill.skillId.toLowerCase(), skill.name.toLowerCase()]);
|
|
90
|
+
const agents = new Set<AgentType>();
|
|
91
|
+
for (const s of installed) {
|
|
92
|
+
if (s.source === "project" && names.has(s.name.toLowerCase())) {
|
|
93
|
+
for (const a of s.agents) agents.add(a);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return AGENT_TYPES.filter((a) => agents.has(a));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Skill terpasang yang relevan untuk halaman Skills: tanpa bawaan opencode,
|
|
101
|
+
* Project dulu lalu global, difilter kata kunci (nama / deskripsi).
|
|
102
|
+
*/
|
|
103
|
+
export function visibleInstalledSkills<T extends SkillInfo>(
|
|
104
|
+
skills: readonly T[],
|
|
105
|
+
query: string,
|
|
106
|
+
): T[] {
|
|
107
|
+
const q = query.trim().toLowerCase();
|
|
108
|
+
const match = (s: T) =>
|
|
109
|
+
q === "" || s.name.toLowerCase().includes(q) || (s.description ?? "").toLowerCase().includes(q);
|
|
110
|
+
return [
|
|
111
|
+
...skills.filter((s) => s.source === "project" && match(s)),
|
|
112
|
+
...skills.filter((s) => s.source === "global" && match(s)),
|
|
113
|
+
];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Jumlah install ringkas: 950, 1.2K, 752K, 1.3M. */
|
|
117
|
+
export function formatInstalls(n: number): string {
|
|
118
|
+
if (n < 1000) return String(n);
|
|
119
|
+
const fmt = (v: number, unit: string) => {
|
|
120
|
+
const s = v >= 100 ? Math.round(v).toString() : v.toFixed(1).replace(/\.0$/, "");
|
|
121
|
+
return `${s}${unit}`;
|
|
122
|
+
};
|
|
123
|
+
if (n < 1_000_000) return fmt(n / 1000, "K");
|
|
124
|
+
return fmt(n / 1_000_000, "M");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Label & gaya ringkasan audit keamanan. */
|
|
128
|
+
export const AUDIT_LABEL: Record<AuditStatus, string> = {
|
|
129
|
+
pass: "Passed",
|
|
130
|
+
warn: "Review",
|
|
131
|
+
fail: "Flagged",
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
export const AUDIT_BADGE_CLASS: Record<AuditStatus, string> = {
|
|
135
|
+
pass: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400",
|
|
136
|
+
warn: "bg-amber-500/10 text-amber-700 dark:text-amber-400",
|
|
137
|
+
fail: "bg-destructive/10 text-destructive",
|
|
138
|
+
};
|
package/src/lib/time.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Format waktu & durasi untuk label UI (jam footer pesan, durasi thinking).
|
|
3
|
+
* Logika murni agar mudah diuji tanpa DOM.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Jam lokal `HH:MM:SS` untuk footer pesan. */
|
|
7
|
+
export function formatTime(ts: number): string {
|
|
8
|
+
return new Date(ts).toLocaleTimeString(undefined, {
|
|
9
|
+
hour: "2-digit",
|
|
10
|
+
minute: "2-digit",
|
|
11
|
+
second: "2-digit",
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Format durasi ms ke string ringkas: "1s", "12s", "1m 5s". */
|
|
16
|
+
export function formatDuration(ms: number): string {
|
|
17
|
+
const s = Math.round(ms / 1000);
|
|
18
|
+
if (s < 60) return `${s}s`;
|
|
19
|
+
const m = Math.floor(s / 60);
|
|
20
|
+
const rem = s % 60;
|
|
21
|
+
return rem === 0 ? `${m}m` : `${m}m ${rem}s`;
|
|
22
|
+
}
|
package/src/lib/tone.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nada hover rail pesan (Web Audio) — tangga nada mayor.
|
|
3
|
+
*
|
|
4
|
+
* Penanda paling atas berbunyi paling rendah, makin ke bawah makin tinggi
|
|
5
|
+
* (do re mi fa sol la si do…), berhenti menaik setelah dua oktaf.
|
|
6
|
+
* Frekuensinya dihitung murni (`scaleFrequency`, bisa diuji); pemutarannya
|
|
7
|
+
* memakai satu `AudioContext` bersama.
|
|
8
|
+
*
|
|
9
|
+
* Catatan browser: `AudioContext` baru boleh berbunyi setelah ada interaksi
|
|
10
|
+
* yang dianggap "user activation" (klik / tap / tombol). Hover TIDAK termasuk,
|
|
11
|
+
* jadi nada pertama bisa senyap sampai pengguna mengklik sesuatu di halaman.
|
|
12
|
+
* Karena ini hanya hiasan, kegagalan diabaikan diam-diam.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Jarak semiton nada mayor: do re mi fa sol la si. */
|
|
16
|
+
const MAJOR_STEPS = [0, 2, 4, 5, 7, 9, 11];
|
|
17
|
+
|
|
18
|
+
/** Nada dasar (C4). */
|
|
19
|
+
export const BASE_FREQUENCY = 261.63;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Nada tertinggi = dua oktaf di atas dasar (do), yaitu derajat ke-14 tangga
|
|
23
|
+
* nada. Di atas itu nada DITAHAN (tidak berulang ke bawah): rail panjang
|
|
24
|
+
* tetap terdengar menaik, dan nadanya tidak pernah melengking (maks ±1 kHz).
|
|
25
|
+
*/
|
|
26
|
+
const MAX_DEGREE = 14;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Frekuensi (Hz) untuk penanda ke-`index` (0 = paling atas / paling rendah).
|
|
30
|
+
* Naik satu derajat tangga nada mayor per penanda, ditahan di `MAX_DEGREE`.
|
|
31
|
+
*/
|
|
32
|
+
export function scaleFrequency(index: number, base = BASE_FREQUENCY): number {
|
|
33
|
+
const degree = Math.min(Math.max(0, Math.floor(index)), MAX_DEGREE);
|
|
34
|
+
const step = MAJOR_STEPS[degree % MAJOR_STEPS.length] ?? 0;
|
|
35
|
+
const octave = Math.floor(degree / MAJOR_STEPS.length);
|
|
36
|
+
return base * 2 ** ((step + octave * 12) / 12);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type Ctx = AudioContext & { _kcgUnlocked?: boolean };
|
|
40
|
+
let ctx: Ctx | null = null;
|
|
41
|
+
|
|
42
|
+
function audioContext(): Ctx | null {
|
|
43
|
+
if (typeof window === "undefined") return null;
|
|
44
|
+
const Ctor =
|
|
45
|
+
window.AudioContext ??
|
|
46
|
+
(window as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
|
47
|
+
if (!Ctor) return null;
|
|
48
|
+
ctx ??= new Ctor();
|
|
49
|
+
return ctx;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Bunyikan satu nada pendek & lembut. Tidak pernah melempar: fitur ini
|
|
54
|
+
* sekadar umpan balik, bukan bagian dari alur utama.
|
|
55
|
+
*/
|
|
56
|
+
export function playTone(frequency: number): void {
|
|
57
|
+
try {
|
|
58
|
+
const audio = audioContext();
|
|
59
|
+
if (!audio) return;
|
|
60
|
+
if (audio.state === "suspended") void audio.resume();
|
|
61
|
+
if (audio.state !== "running") return;
|
|
62
|
+
|
|
63
|
+
const now = audio.currentTime;
|
|
64
|
+
const osc = audio.createOscillator();
|
|
65
|
+
const gain = audio.createGain();
|
|
66
|
+
// Triangle: lembut, tanpa harmonik tajam seperti square/saw.
|
|
67
|
+
osc.type = "triangle";
|
|
68
|
+
osc.frequency.value = frequency;
|
|
69
|
+
// Envelope pendek (±0.16 s) dengan attack cepat supaya terasa seperti
|
|
70
|
+
// "tik" bernada, bukan bunyi panjang yang mengganggu saat menyapu rail.
|
|
71
|
+
gain.gain.setValueAtTime(0, now);
|
|
72
|
+
gain.gain.linearRampToValueAtTime(0.06, now + 0.012);
|
|
73
|
+
gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.16);
|
|
74
|
+
osc.connect(gain).connect(audio.destination);
|
|
75
|
+
osc.start(now);
|
|
76
|
+
osc.stop(now + 0.18);
|
|
77
|
+
} catch {
|
|
78
|
+
/* Web Audio tidak tersedia / diblokir — abaikan. */
|
|
79
|
+
}
|
|
80
|
+
}
|