kcg-code 0.0.0-stage → 0.1.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 +228 -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 +83 -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__/dashboard.test.ts +62 -0
- package/src/cli/args.ts +123 -0
- package/src/cli/commands.ts +174 -0
- package/src/cli/dashboard.tsx +343 -0
- package/src/cli/index.ts +37 -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 +142 -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 +250 -0
- package/src/components/projects/NewProjectDialog.tsx +249 -0
- package/src/components/projects/PanelSection.tsx +102 -0
- package/src/components/projects/ProjectInstructions.tsx +229 -0
- package/src/components/projects/ProjectRow.tsx +58 -0
- package/src/components/projects/ProjectSidePanel.tsx +448 -0
- package/src/components/sessions/AgentModeList.tsx +265 -0
- package/src/components/sessions/AgentPicker.tsx +123 -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 +72 -0
- package/src/components/sessions/ContextPanel.tsx +561 -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 +209 -0
- package/src/components/sessions/ModelSearchList.tsx +151 -0
- package/src/components/sessions/NewSessionDialog.tsx +189 -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 +640 -0
- package/src/components/sessions/SessionHeader.tsx +175 -0
- package/src/components/sessions/SessionList.tsx +396 -0
- package/src/components/sessions/SessionRow.tsx +137 -0
- package/src/components/sessions/SessionTimeline.tsx +275 -0
- package/src/components/sessions/SessionTimelineSkeleton.tsx +83 -0
- package/src/components/sessions/SessionView.tsx +236 -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 +220 -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 +262 -0
- package/src/components/settings/PasskeysGroup.tsx +551 -0
- package/src/components/settings/RemoteAccessGroup.tsx +1226 -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 +265 -0
- package/src/components/skills/InstallNotifications.tsx +325 -0
- package/src/components/skills/InstallSkillDialog.tsx +233 -0
- package/src/components/skills/SkillCards.tsx +210 -0
- package/src/components/skills/SkillsProjectPicker.tsx +178 -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/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 +85 -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 +97 -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 +234 -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 +52 -0
- package/src/lib/__tests__/project-overview.test.ts +173 -0
- package/src/lib/__tests__/references.test.ts +121 -0
- package/src/lib/__tests__/routes.test.ts +134 -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 +134 -0
- package/src/lib/__tests__/skills-catalog.test.ts +96 -0
- package/src/lib/__tests__/tone.test.ts +37 -0
- package/src/lib/__tests__/turns-delta.test.ts +55 -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 +74 -0
- package/src/lib/project-overview.ts +117 -0
- package/src/lib/prompts.ts +87 -0
- package/src/lib/references.ts +106 -0
- package/src/lib/routes.ts +115 -0
- package/src/lib/session-handoff.ts +25 -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 +360 -0
- package/src/lib/skills-catalog.ts +99 -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 +130 -0
- package/src/pages/projects/ProjectsPage.tsx +97 -0
- package/src/pages/sessions/SessionPage.tsx +122 -0
- package/src/pages/settings/SettingsPage.tsx +299 -0
- package/src/pages/settings/__tests__/SettingsPage.test.ts +9 -0
- package/src/pages/skills/SkillsPage.tsx +701 -0
- package/src/paths.ts +121 -0
- package/src/runtime.ts +24 -0
- package/src/server/__tests__/app.test.ts +1031 -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 +415 -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 +157 -0
- package/src/server/routes/sessions.routes.ts +197 -0
- package/src/server/routes/skills.routes.ts +117 -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__/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 +3469 -0
- package/src/server/services/__tests__/skill-install-jobs.test.ts +226 -0
- package/src/server/services/__tests__/skills-registry.test.ts +359 -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/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 +1374 -0
- package/src/server/services/skill-install-jobs.ts +262 -0
- package/src/server/services/skills-registry.ts +468 -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,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Jenis lampiran upload — logika murni, dipakai server (routing ke model)
|
|
3
|
+
* maupun browser (thumbnail vs kartu file).
|
|
4
|
+
*
|
|
5
|
+
* Provider AI yang dibungkus opencode hanya menerima part `file` bermime
|
|
6
|
+
* `image/*` (format umum), `application/pdf`, dan `text/plain`. Mime lain
|
|
7
|
+
* (mis. `text/csv`, `application/json`) memicu error "functionality not
|
|
8
|
+
* supported" dan — karena part tersimpan di riwayat — merusak seluruh turn
|
|
9
|
+
* berikutnya. Karena itu:
|
|
10
|
+
* - gambar umum & PDF dikirim apa adanya,
|
|
11
|
+
* - file teks/kode dikirim ulang sebagai `text/plain` (opencode membacanya
|
|
12
|
+
* lewat tool Read, sama seperti `@file`),
|
|
13
|
+
* - file biner lain TIDAK dikirim sebagai part; model hanya diberi tahu
|
|
14
|
+
* path-nya agar bisa memakai tool bila perlu.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Batas ukuran upload (byte) — mengikuti limit attachment opencode (20 MiB). */
|
|
18
|
+
export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
|
|
19
|
+
|
|
20
|
+
/** Gambar yang bisa dipratinjau browser & diterima provider sebagai gambar. */
|
|
21
|
+
const PREVIEWABLE_IMAGE_MIME = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
|
|
22
|
+
|
|
23
|
+
/** Ekstensi file teks/kode yang aman dibaca sebagai teks. */
|
|
24
|
+
const TEXT_EXTENSIONS = new Set([
|
|
25
|
+
"txt",
|
|
26
|
+
"md",
|
|
27
|
+
"markdown",
|
|
28
|
+
"mdx",
|
|
29
|
+
"rst",
|
|
30
|
+
"log",
|
|
31
|
+
"csv",
|
|
32
|
+
"tsv",
|
|
33
|
+
"json",
|
|
34
|
+
"jsonc",
|
|
35
|
+
"json5",
|
|
36
|
+
"ndjson",
|
|
37
|
+
"yaml",
|
|
38
|
+
"yml",
|
|
39
|
+
"toml",
|
|
40
|
+
"ini",
|
|
41
|
+
"cfg",
|
|
42
|
+
"conf",
|
|
43
|
+
"env",
|
|
44
|
+
"xml",
|
|
45
|
+
"html",
|
|
46
|
+
"htm",
|
|
47
|
+
"css",
|
|
48
|
+
"scss",
|
|
49
|
+
"sass",
|
|
50
|
+
"less",
|
|
51
|
+
"svg",
|
|
52
|
+
"js",
|
|
53
|
+
"mjs",
|
|
54
|
+
"cjs",
|
|
55
|
+
"jsx",
|
|
56
|
+
"ts",
|
|
57
|
+
"mts",
|
|
58
|
+
"cts",
|
|
59
|
+
"tsx",
|
|
60
|
+
"vue",
|
|
61
|
+
"svelte",
|
|
62
|
+
"astro",
|
|
63
|
+
"py",
|
|
64
|
+
"rb",
|
|
65
|
+
"php",
|
|
66
|
+
"java",
|
|
67
|
+
"kt",
|
|
68
|
+
"kts",
|
|
69
|
+
"scala",
|
|
70
|
+
"go",
|
|
71
|
+
"rs",
|
|
72
|
+
"c",
|
|
73
|
+
"h",
|
|
74
|
+
"cc",
|
|
75
|
+
"cpp",
|
|
76
|
+
"hpp",
|
|
77
|
+
"cs",
|
|
78
|
+
"swift",
|
|
79
|
+
"m",
|
|
80
|
+
"dart",
|
|
81
|
+
"lua",
|
|
82
|
+
"pl",
|
|
83
|
+
"r",
|
|
84
|
+
"jl",
|
|
85
|
+
"ex",
|
|
86
|
+
"exs",
|
|
87
|
+
"erl",
|
|
88
|
+
"hs",
|
|
89
|
+
"clj",
|
|
90
|
+
"sql",
|
|
91
|
+
"graphql",
|
|
92
|
+
"gql",
|
|
93
|
+
"proto",
|
|
94
|
+
"sh",
|
|
95
|
+
"bash",
|
|
96
|
+
"zsh",
|
|
97
|
+
"fish",
|
|
98
|
+
"ps1",
|
|
99
|
+
"bat",
|
|
100
|
+
"dockerfile",
|
|
101
|
+
"makefile",
|
|
102
|
+
"gradle",
|
|
103
|
+
"tf",
|
|
104
|
+
"hcl",
|
|
105
|
+
"nix",
|
|
106
|
+
"lock",
|
|
107
|
+
"diff",
|
|
108
|
+
"patch",
|
|
109
|
+
"tex",
|
|
110
|
+
"bib",
|
|
111
|
+
"srt",
|
|
112
|
+
"vtt",
|
|
113
|
+
]);
|
|
114
|
+
|
|
115
|
+
/** Nama file tanpa ekstensi yang umum berisi teks. */
|
|
116
|
+
const TEXT_BASENAMES = new Set(["dockerfile", "makefile", "readme", "license", "gemfile"]);
|
|
117
|
+
|
|
118
|
+
export type AttachmentKind = "image" | "pdf" | "text" | "binary";
|
|
119
|
+
|
|
120
|
+
/** Ekstensi huruf kecil (tanpa titik), "" bila tidak ada. */
|
|
121
|
+
export function fileExtension(filename: string): string {
|
|
122
|
+
const base = filename.split(/[\\/]/).pop() ?? "";
|
|
123
|
+
const dot = base.lastIndexOf(".");
|
|
124
|
+
return dot > 0 ? base.slice(dot + 1).toLowerCase() : "";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function isTextMime(mime: string): boolean {
|
|
128
|
+
return (
|
|
129
|
+
mime.startsWith("text/") ||
|
|
130
|
+
mime === "application/json" ||
|
|
131
|
+
mime === "application/xml" ||
|
|
132
|
+
mime === "application/javascript" ||
|
|
133
|
+
mime === "application/x-sh" ||
|
|
134
|
+
mime === "application/sql" ||
|
|
135
|
+
mime === "application/yaml" ||
|
|
136
|
+
mime === "application/toml" ||
|
|
137
|
+
mime.endsWith("+json") ||
|
|
138
|
+
mime.endsWith("+xml")
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Gambar yang boleh ditampilkan inline di browser (thumbnail / bubble). */
|
|
143
|
+
export function isPreviewableImage(mime: string): boolean {
|
|
144
|
+
return PREVIEWABLE_IMAGE_MIME.has(mime);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Golongkan lampiran. Ekstensi dicek lebih dulu untuk file kode karena
|
|
149
|
+
* browser sering salah menebak mime-nya (mis. `.ts` jadi `video/mp2t`,
|
|
150
|
+
* `.rs` kosong).
|
|
151
|
+
*/
|
|
152
|
+
export function attachmentKind(mime: string, filename: string): AttachmentKind {
|
|
153
|
+
const m = mime.toLowerCase();
|
|
154
|
+
if (isPreviewableImage(m)) return "image";
|
|
155
|
+
if (m === "application/pdf") return "pdf";
|
|
156
|
+
const ext = fileExtension(filename);
|
|
157
|
+
const base = (filename.split(/[\\/]/).pop() ?? "").toLowerCase();
|
|
158
|
+
if (TEXT_EXTENSIONS.has(ext) || (ext === "" && TEXT_BASENAMES.has(base))) return "text";
|
|
159
|
+
if (isTextMime(m)) return "text";
|
|
160
|
+
return "binary";
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Mime part `file` yang dikirim ke model, atau null bila file tidak boleh
|
|
165
|
+
* dikirim sebagai part (biner) — cukup dirujuk lewat path.
|
|
166
|
+
*/
|
|
167
|
+
export function modelFacingMime(mime: string, filename: string): string | null {
|
|
168
|
+
switch (attachmentKind(mime, filename)) {
|
|
169
|
+
case "image":
|
|
170
|
+
return mime.toLowerCase();
|
|
171
|
+
case "pdf":
|
|
172
|
+
return "application/pdf";
|
|
173
|
+
case "text":
|
|
174
|
+
return "text/plain";
|
|
175
|
+
default:
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Mime yang disimpan server: hanya bentuk `type/subtype` sederhana yang
|
|
182
|
+
* diterima (header Content-Type tidak boleh bisa disuntik); lainnya jadi
|
|
183
|
+
* `application/octet-stream`.
|
|
184
|
+
*/
|
|
185
|
+
export function normalizeMime(mime: string): string {
|
|
186
|
+
const m = mime.trim().toLowerCase();
|
|
187
|
+
return /^[a-z0-9][a-z0-9!#$&^_.+-]{0,63}\/[a-z0-9][a-z0-9!#$&^_.+-]{0,127}$/.test(m)
|
|
188
|
+
? m
|
|
189
|
+
: "application/octet-stream";
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Ukuran file ramah baca (mis. "1.2 MB"). */
|
|
193
|
+
export function formatBytes(bytes: number): string {
|
|
194
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
195
|
+
const units = ["KB", "MB", "GB"];
|
|
196
|
+
let v = bytes / 1024;
|
|
197
|
+
let i = 0;
|
|
198
|
+
while (v >= 1024 && i < units.length - 1) {
|
|
199
|
+
v /= 1024;
|
|
200
|
+
i++;
|
|
201
|
+
}
|
|
202
|
+
return `${v < 10 ? v.toFixed(1) : Math.round(v)} ${units[i]}`;
|
|
203
|
+
}
|
package/src/lib/auth.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Store global kunci aplikasi (FE): status dari server + pemicu lock screen.
|
|
3
|
+
*
|
|
4
|
+
* - `refreshAuth()` memuat `GET /api/auth/status` (publik).
|
|
5
|
+
* - 401 `AUTH_REQUIRED` dari API mana pun -> `markLocked()` (lock screen
|
|
6
|
+
* muncul tanpa reload; data di belakangnya tidak lagi bisa dimuat).
|
|
7
|
+
* - `lockNow()` = tombol Lock: hapus sesi di server lalu tampilkan lock screen.
|
|
8
|
+
* - Kunci otomatis ditegakkan SERVER (sesi idle kedaluwarsa); klien hanya
|
|
9
|
+
* mengetahuinya lebih cepat lewat timer idle lokal + heartbeat.
|
|
10
|
+
*/
|
|
11
|
+
import { useSyncExternalStore } from "react";
|
|
12
|
+
import { apiFetch, setAuthRequiredHandler } from "@/lib/api";
|
|
13
|
+
import type { AuthStatus } from "@/server/services/auth";
|
|
14
|
+
|
|
15
|
+
export type AuthState =
|
|
16
|
+
| { phase: "loading" }
|
|
17
|
+
/** Server tak terjangkau saat memuat status pertama kali. */
|
|
18
|
+
| { phase: "error" }
|
|
19
|
+
| { phase: "ready"; status: AuthStatus };
|
|
20
|
+
|
|
21
|
+
let state: AuthState = { phase: "loading" };
|
|
22
|
+
const listeners = new Set<() => void>();
|
|
23
|
+
|
|
24
|
+
function set(next: AuthState): void {
|
|
25
|
+
state = next;
|
|
26
|
+
for (const l of listeners) l();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function refreshAuth(): Promise<AuthStatus | null> {
|
|
30
|
+
try {
|
|
31
|
+
const res = await fetch("/api/auth/status", { cache: "no-store" });
|
|
32
|
+
if (!res.ok) throw new Error(String(res.status));
|
|
33
|
+
const status = (await res.json()) as AuthStatus;
|
|
34
|
+
// Status sama persis -> jangan picu render ulang (heartbeat berkala).
|
|
35
|
+
if (state.phase !== "ready" || JSON.stringify(state.status) !== JSON.stringify(status)) {
|
|
36
|
+
set({ phase: "ready", status });
|
|
37
|
+
}
|
|
38
|
+
return status;
|
|
39
|
+
} catch {
|
|
40
|
+
if (state.phase === "loading") set({ phase: "error" });
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Server menolak sesi (401) -> kunci UI segera, lalu sinkronkan status. */
|
|
46
|
+
export function markLocked(): void {
|
|
47
|
+
if (state.phase === "ready" && state.status.protected && state.status.authenticated) {
|
|
48
|
+
set({ phase: "ready", status: { ...state.status, authenticated: false } });
|
|
49
|
+
}
|
|
50
|
+
void refreshAuth();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Tombol Lock: akhiri sesi perangkat ini. */
|
|
54
|
+
export async function lockNow(): Promise<void> {
|
|
55
|
+
try {
|
|
56
|
+
await apiFetch("/api/auth/logout", { method: "POST" });
|
|
57
|
+
} catch {
|
|
58
|
+
// Sesi mungkin sudah tidak berlaku — tetap tampilkan lock screen.
|
|
59
|
+
}
|
|
60
|
+
markLocked();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
setAuthRequiredHandler(markLocked);
|
|
64
|
+
|
|
65
|
+
function subscribe(l: () => void): () => void {
|
|
66
|
+
listeners.add(l);
|
|
67
|
+
return () => listeners.delete(l);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function snapshot(): AuthState {
|
|
71
|
+
return state;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function useAuth(): AuthState {
|
|
75
|
+
return useSyncExternalStore(subscribe, snapshot, snapshot);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Label jenis kunci untuk UI. */
|
|
79
|
+
export function lockLabel(kind: "pin" | "password"): string {
|
|
80
|
+
return kind === "pin" ? "PIN" : "password";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Inisial nickname untuk avatar pengganti ("Irsyad Ibad" -> "II"). */
|
|
84
|
+
export function initialsOf(nickname: string | null): string {
|
|
85
|
+
if (!nickname) return "";
|
|
86
|
+
const words = nickname.trim().split(/\s+/).filter(Boolean);
|
|
87
|
+
const letters = words.length > 1 ? [words[0], words[words.length - 1]] : [words[0]];
|
|
88
|
+
return letters
|
|
89
|
+
.map((w) => [...(w ?? "")][0] ?? "")
|
|
90
|
+
.join("")
|
|
91
|
+
.toUpperCase();
|
|
92
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Katalog avatar bawaan (preset). Dibagi server & FE:
|
|
3
|
+
* - Server hanya memvalidasi id (`isAvatarPresetId`) — tidak mengimpor ikon.
|
|
4
|
+
* - FE memetakan id -> ikon lucide + warna di `components/auth/avatar-icons.tsx`.
|
|
5
|
+
*
|
|
6
|
+
* Menambah preset: tambahkan id di sini DAN pemetaannya di FE (dijaga test).
|
|
7
|
+
*/
|
|
8
|
+
export const AVATAR_PRESET_IDS = [
|
|
9
|
+
"cat",
|
|
10
|
+
"dog",
|
|
11
|
+
"rabbit",
|
|
12
|
+
"panda",
|
|
13
|
+
"bird",
|
|
14
|
+
"fish",
|
|
15
|
+
"turtle",
|
|
16
|
+
"squirrel",
|
|
17
|
+
"rocket",
|
|
18
|
+
"bot",
|
|
19
|
+
"ghost",
|
|
20
|
+
"gamepad",
|
|
21
|
+
"flower",
|
|
22
|
+
"leaf",
|
|
23
|
+
"mountain",
|
|
24
|
+
"coffee",
|
|
25
|
+
] as const;
|
|
26
|
+
|
|
27
|
+
export type AvatarPresetId = (typeof AVATAR_PRESET_IDS)[number];
|
|
28
|
+
|
|
29
|
+
export function isAvatarPresetId(v: unknown): v is AvatarPresetId {
|
|
30
|
+
return typeof v === "string" && (AVATAR_PRESET_IDS as readonly string[]).includes(v);
|
|
31
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* State collapsible thinking block per pesan (Requirement 8.3).
|
|
3
|
+
*
|
|
4
|
+
* Logika murni dan bebas DOM agar dapat diuji dengan property test
|
|
5
|
+
* (Property 25) lewat `bun test` tanpa rendering React.
|
|
6
|
+
*
|
|
7
|
+
* Representasi `Record<messageId, boolean>` dijaga aman terhadap key khusus
|
|
8
|
+
* prototipe (`__proto__`, `toString`, dst.): seluruh baca/tulis memakai
|
|
9
|
+
* own-property eksplisit sehingga id pesan apa pun (hasil generator property
|
|
10
|
+
* test) tidak mencemari prototipe objek state.
|
|
11
|
+
*/
|
|
12
|
+
export type CollapsibleState = Record<string, boolean>;
|
|
13
|
+
|
|
14
|
+
/** Definisikan key sebagai own-property enumerable (aman untuk `__proto__`). */
|
|
15
|
+
function setOwn(state: CollapsibleState, key: string, value: boolean): void {
|
|
16
|
+
Object.defineProperty(state, key, {
|
|
17
|
+
value,
|
|
18
|
+
enumerable: true,
|
|
19
|
+
writable: true,
|
|
20
|
+
configurable: true,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** State awal: seluruh pesan tampil (expanded) sebelum toggle pertama (Req 8.3). */
|
|
25
|
+
export function initialCollapsibleState(messageIds: readonly string[]): CollapsibleState {
|
|
26
|
+
const state: CollapsibleState = {};
|
|
27
|
+
for (const id of messageIds) setOwn(state, id, true);
|
|
28
|
+
return state;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* State awal: seluruh reasoning tertutup (collapsed) — hanya baris "Thinking"
|
|
33
|
+
* yang tampil; isi dibuka lewat toggle. Tinggi konten tetap dibatasi
|
|
34
|
+
* (`max-h` + scroll) walau di-expand.
|
|
35
|
+
*/
|
|
36
|
+
export function initialCollapsedState(messageIds: readonly string[]): CollapsibleState {
|
|
37
|
+
return extendCollapsed({}, messageIds);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Perluas state dengan key baru (mis. reasoning pesan baru lewat event
|
|
42
|
+
* `message`) dalam keadaan collapsed, tanpa mengubah status key yang sudah
|
|
43
|
+
* ada. Mengembalikan state baru (immutable).
|
|
44
|
+
*/
|
|
45
|
+
export function extendCollapsed(
|
|
46
|
+
state: CollapsibleState,
|
|
47
|
+
messageIds: readonly string[],
|
|
48
|
+
): CollapsibleState {
|
|
49
|
+
if (messageIds.length === 0) return state;
|
|
50
|
+
const next: CollapsibleState = {};
|
|
51
|
+
for (const [key, value] of Object.entries(state)) setOwn(next, key, value);
|
|
52
|
+
for (const id of messageIds) {
|
|
53
|
+
if (!Object.hasOwn(next, id)) setOwn(next, id, false);
|
|
54
|
+
}
|
|
55
|
+
return next;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Status tampil saat ini untuk sebuah pesan (default: expanded, Req 8.3). */
|
|
59
|
+
export function isCollapsibleExpanded(state: CollapsibleState, messageId: string): boolean {
|
|
60
|
+
if (Object.hasOwn(state, messageId)) {
|
|
61
|
+
return Boolean(state[messageId]);
|
|
62
|
+
}
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Toggle satu pesan tanpa memengaruhi pesan lain (Property 25).
|
|
68
|
+
* Mengembalikan state baru (immutable) agar mudah diverifikasi.
|
|
69
|
+
*/
|
|
70
|
+
export function toggleCollapsible(state: CollapsibleState, messageId: string): CollapsibleState {
|
|
71
|
+
const next: CollapsibleState = {};
|
|
72
|
+
for (const [key, value] of Object.entries(state)) setOwn(next, key, value);
|
|
73
|
+
setOwn(next, messageId, !isCollapsibleExpanded(state, messageId));
|
|
74
|
+
return next;
|
|
75
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composer chat — logika murni (tanpa DOM), dipisah agar dapat diuji
|
|
3
|
+
* `bun test` seperti helper `lib/` lainnya.
|
|
4
|
+
*
|
|
5
|
+
* Isinya keputusan teks placeholder: di layar HP input jauh lebih sempit,
|
|
6
|
+
* sehingga placeholder panjang terpotong di tengah kata dan justru tidak
|
|
7
|
+
* memberi informasi apa pun. Versi mobile dibuat pendek; petunjuk lengkap
|
|
8
|
+
* (`@` untuk file, drop/tempel file) tetap tersedia di layar lebar.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Kondisi composer yang menentukan teks placeholder. */
|
|
12
|
+
export interface ComposerPlaceholderState {
|
|
13
|
+
/** Model sedang merespon (turn aktif). */
|
|
14
|
+
busy: boolean;
|
|
15
|
+
/** Input dapat dipakai (WS terhubung, Session running, tidak sibuk). */
|
|
16
|
+
canInput: boolean;
|
|
17
|
+
/** Viewport sempit — pakai teks pendek. */
|
|
18
|
+
compact: boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Ada lampiran (file) — tombol kirim muncul dan ruang teks menyempit,
|
|
21
|
+
* jadi petunjuk panjang diganti ajakan singkat yang relevan.
|
|
22
|
+
*/
|
|
23
|
+
hasAttachments?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Placeholder textarea composer. Urutan pemeriksaan penting: status sibuk
|
|
28
|
+
* dan Session mati lebih informatif daripada petunjuk mengetik, jadi
|
|
29
|
+
* keduanya diperiksa lebih dulu.
|
|
30
|
+
*/
|
|
31
|
+
export function composerPlaceholder(state: ComposerPlaceholderState): string {
|
|
32
|
+
if (state.busy) return state.compact ? "Responding…" : "Model is responding…";
|
|
33
|
+
if (!state.canInput) return "Session is not active";
|
|
34
|
+
if (state.hasAttachments) return "Add a message…";
|
|
35
|
+
return state.compact
|
|
36
|
+
? "Type a message…"
|
|
37
|
+
: "Type a message… type @ for file references, or drop files here";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Ekstrak referensi `@path` dari teks untuk dikirim sebagai part `file`
|
|
42
|
+
* terpisah di prompt. Referensi dianggap file bila path-nya pernah disarankan
|
|
43
|
+
* autocomplete ATAU memuat `/` (kemungkinan besar path file, bukan kata
|
|
44
|
+
* biasa seperti `@user`).
|
|
45
|
+
*
|
|
46
|
+
* Teks asli tidak diubah — part `file` hanya penanda tambahan agar isi file
|
|
47
|
+
* benar-benar dibaca opencode; urutan kemunculan & duplikat dipertahankan
|
|
48
|
+
* (tiap kemunculan menjadi satu part `file`).
|
|
49
|
+
*/
|
|
50
|
+
export function extractMentionedFiles(text: string, knownPaths: ReadonlySet<string>): string[] {
|
|
51
|
+
const files: string[] = [];
|
|
52
|
+
for (const match of text.matchAll(/(^|\s)@([^\s]+)/g)) {
|
|
53
|
+
const path = match[2] ?? "";
|
|
54
|
+
if (path.length > 0 && (knownPaths.has(path) || path.includes("/"))) {
|
|
55
|
+
files.push(path);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return files;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Potongan teks composer untuk lapisan sorot: teks biasa atau mention file. */
|
|
62
|
+
export type ComposerSegment =
|
|
63
|
+
| { kind: "text"; text: string }
|
|
64
|
+
| { kind: "mention"; text: string; path: string };
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Pecah teks composer menjadi potongan teks biasa & mention `@path` yang
|
|
68
|
+
* dikenali (aturan sama dengan `extractMentionedFiles`: path pernah
|
|
69
|
+
* disarankan autocomplete ATAU memuat `/`). Gabungan `text` seluruh
|
|
70
|
+
* potongan SELALU identik dengan input — lapisan sorot di belakang textarea
|
|
71
|
+
* bergantung pada ini agar posisinya pas karakter demi karakter.
|
|
72
|
+
*/
|
|
73
|
+
export function splitComposerMentions(
|
|
74
|
+
text: string,
|
|
75
|
+
knownPaths: ReadonlySet<string>,
|
|
76
|
+
): ComposerSegment[] {
|
|
77
|
+
const out: ComposerSegment[] = [];
|
|
78
|
+
let last = 0;
|
|
79
|
+
for (const match of text.matchAll(/(^|\s)@([^\s]+)/g)) {
|
|
80
|
+
const path = match[2] ?? "";
|
|
81
|
+
if (path.length === 0 || !(knownPaths.has(path) || path.includes("/"))) continue;
|
|
82
|
+
const start = (match.index ?? 0) + (match[1]?.length ?? 0);
|
|
83
|
+
if (start > last) out.push({ kind: "text", text: text.slice(last, start) });
|
|
84
|
+
out.push({ kind: "mention", text: `@${path}`, path });
|
|
85
|
+
last = start + 1 + path.length;
|
|
86
|
+
}
|
|
87
|
+
if (last < text.length) out.push({ kind: "text", text: text.slice(last) });
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sinyal "data Project/Session berubah" antar bagian UI yang memuat datanya
|
|
3
|
+
* sendiri (mis. homepage membuat Project -> sidebar perlu memuat ulang).
|
|
4
|
+
*/
|
|
5
|
+
const DATA_CHANGED_EVENT = "kcg-data-changed";
|
|
6
|
+
|
|
7
|
+
export function notifyDataChanged(): void {
|
|
8
|
+
if (typeof window === "undefined") return;
|
|
9
|
+
window.dispatchEvent(new Event(DATA_CHANGED_EVENT));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Berlangganan sinyal; mengembalikan fungsi berhenti berlangganan. */
|
|
13
|
+
export function onDataChanged(listener: () => void): () => void {
|
|
14
|
+
if (typeof window === "undefined") return () => {};
|
|
15
|
+
window.addEventListener(DATA_CHANGED_EVENT, listener);
|
|
16
|
+
return () => window.removeEventListener(DATA_CHANGED_EVENT, listener);
|
|
17
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headline sambutan homepage — dipilih acak tiap kali halaman dibuka.
|
|
3
|
+
* Logika murni (tanpa DOM) agar dapat diuji `bun test`.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const HOME_HEADLINES: readonly string[] = [
|
|
7
|
+
"What are we building today?",
|
|
8
|
+
"Ready when you are.",
|
|
9
|
+
"Let's ship something great.",
|
|
10
|
+
"What should we tackle next?",
|
|
11
|
+
"Got a bug? Let's hunt it down.",
|
|
12
|
+
"Describe it, I'll code it.",
|
|
13
|
+
"Pick a project and let's go.",
|
|
14
|
+
"Where were we?",
|
|
15
|
+
"Time to make the code purr.",
|
|
16
|
+
"What's on your mind?",
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Pilih satu headline secara acak. `random` diinjeksi agar deterministik
|
|
21
|
+
* saat diuji (default `Math.random`). List kosong -> string kosong.
|
|
22
|
+
*/
|
|
23
|
+
export function pickHeadline(
|
|
24
|
+
headlines: readonly string[] = HOME_HEADLINES,
|
|
25
|
+
random: () => number = Math.random,
|
|
26
|
+
): string {
|
|
27
|
+
if (headlines.length === 0) return "";
|
|
28
|
+
const index = Math.min(headlines.length - 1, Math.floor(random() * headlines.length));
|
|
29
|
+
return headlines[Math.max(0, index)] ?? "";
|
|
30
|
+
}
|