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,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TurnStream — state turn balasan model yang sedang di-stream (per Session).
|
|
3
|
+
*
|
|
4
|
+
* Dipisah dari `session-manager.ts`:
|
|
5
|
+
* - Satu turn = pesan assistant yang dirakit dari parts SSE
|
|
6
|
+
* (`message.part.updated`) yang diterima setelah `message.updated`
|
|
7
|
+
* role=assistant, disimpan per messageId sesuai urutan kemunculan.
|
|
8
|
+
* - `finish` (sukses `session.idle` / stop / server keluar) menyimpan parts;
|
|
9
|
+
* `discard` (interrupt user) membuang parts; `fail` menyimpan parts lalu
|
|
10
|
+
* menulis pesan error role=assistant + `onError`.
|
|
11
|
+
* - `noteError` mencatat `session.error` non-abort TANPA menutup turn —
|
|
12
|
+
* error sering tidak terminal (mis. referensi `@file` gagal dibaca) dan
|
|
13
|
+
* jawaban tetap di-stream setelahnya; pesan error hanya ditulis ke history
|
|
14
|
+
* bila turn ditutup tanpa konten apa pun.
|
|
15
|
+
* - Timer turn adalah batas KEDIAMAN (inactivity), bukan batas total: setiap
|
|
16
|
+
* tanda hidup (`keepAlive`, part baru, pesan assistant) me-reset-nya,
|
|
17
|
+
* sehingga turn panjang yang sehat (reasoning/tool/sub-agent/retry) tidak
|
|
18
|
+
* dipotong di tengah jalan.
|
|
19
|
+
*/
|
|
20
|
+
import { randomUUID } from "node:crypto";
|
|
21
|
+
import type { SessionStore } from "../../db";
|
|
22
|
+
import type { MessagePart, SessionMessage } from "../../types";
|
|
23
|
+
|
|
24
|
+
/** Satu turn balasan yang sedang di-stream (SSE `message.part.updated`). */
|
|
25
|
+
interface StreamingTurn {
|
|
26
|
+
/**
|
|
27
|
+
* Id pesan assistant di opencode (`msg_...`) yang sedang dibangun.
|
|
28
|
+
* Satu turn bisa memuat beberapa pesan assistant (mis. sub-agent/tool
|
|
29
|
+
* yang memancarkan `msg_...` sendiri), jadi disimpan sebagai Set.
|
|
30
|
+
*/
|
|
31
|
+
assistantMsgIds: Set<string>;
|
|
32
|
+
/** Parts terakumulasi per pesan (messageId -> partId -> part). */
|
|
33
|
+
parts: Map<string, Map<string, MessagePart>>;
|
|
34
|
+
/** Urutan kemunculan messageId agar penyimpanan mengikuti alur turn. */
|
|
35
|
+
order: string[];
|
|
36
|
+
/** Sudah difinalisasi (persist + onMessage) — cegah pemrosesan ganda. */
|
|
37
|
+
finalized: boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Error turn yang DITUNDA (`noteError`). opencode memancarkan
|
|
40
|
+
* `session.error` yang tidak terminal lalu turn BERLANJUT — menutup turn
|
|
41
|
+
* di sini membuang jawaban yang menyusul. Banner tetap tampil seketika;
|
|
42
|
+
* pesan error baru ditulis ke history saat turn ditutup BILA tidak ada
|
|
43
|
+
* konten baru setelah error (error memang yang terakhir terjadi).
|
|
44
|
+
*/
|
|
45
|
+
pendingError: { message: string; partsAtError: number } | null;
|
|
46
|
+
/** Timer batas idle turn; di-reset (`keepAlive`) tiap ada tanda hidup. */
|
|
47
|
+
timeout?: unknown;
|
|
48
|
+
/** Konfigurasi timer idle: durasi sunyi maksimum & pesan saat dipicu. */
|
|
49
|
+
idleTimeoutMs: number;
|
|
50
|
+
timeoutMessage: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface TurnStreamOptions {
|
|
54
|
+
store: SessionStore;
|
|
55
|
+
now: () => number;
|
|
56
|
+
/** Pesan assistant final/sementara — diteruskan ke Client. */
|
|
57
|
+
onMessage?: (message: SessionMessage) => void;
|
|
58
|
+
/** Part yang sedang di-stream — diteruskan ke Client. */
|
|
59
|
+
onMessagePart?: (sessionId: string, messageId: string, part: MessagePart) => void;
|
|
60
|
+
/**
|
|
61
|
+
* Potongan teks baru untuk satu part (`message.part.delta`) — diteruskan ke
|
|
62
|
+
* Client sebagai delta (bukan teks penuh) agar ringan tiap token.
|
|
63
|
+
*/
|
|
64
|
+
onMessagePartDelta?: (
|
|
65
|
+
sessionId: string,
|
|
66
|
+
messageId: string,
|
|
67
|
+
partId: string,
|
|
68
|
+
field: string,
|
|
69
|
+
delta: string,
|
|
70
|
+
) => void;
|
|
71
|
+
/** Error asinkron turn (mis. timeout) — banner di Client. */
|
|
72
|
+
onError?: (sessionId: string, message: string) => void;
|
|
73
|
+
/** Perubahan status turn — Client tahu kapan tombol stop aktif. */
|
|
74
|
+
onTurnChange?: (sessionId: string, active: boolean) => void;
|
|
75
|
+
/**
|
|
76
|
+
* Dipanggil saat timer idle terpicu: `false` = tunda fire dan pasang ulang
|
|
77
|
+
* timer — turn sedang menunggu sesuatu yang bukan sunyi (mis. keputusan
|
|
78
|
+
* user pada kartu permission/question). Tanpa ini, user yang lama memutus
|
|
79
|
+
* kartu kehilangan jawaban yang sebenarnya masih hidup.
|
|
80
|
+
*/
|
|
81
|
+
shouldFireIdle?: (sessionId: string) => boolean;
|
|
82
|
+
setTimeoutFn: (cb: () => void, ms: number) => unknown;
|
|
83
|
+
clearTimeoutFn: (handle: unknown) => void;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function emptyTurn(): StreamingTurn {
|
|
87
|
+
return {
|
|
88
|
+
assistantMsgIds: new Set(),
|
|
89
|
+
parts: new Map(),
|
|
90
|
+
order: [],
|
|
91
|
+
finalized: false,
|
|
92
|
+
pendingError: null,
|
|
93
|
+
idleTimeoutMs: 0,
|
|
94
|
+
timeoutMessage: "",
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export class TurnStream {
|
|
99
|
+
private readonly turns = new Map<string, StreamingTurn>();
|
|
100
|
+
|
|
101
|
+
constructor(private readonly opts: TurnStreamOptions) {}
|
|
102
|
+
|
|
103
|
+
/** Apakah ada turn streaming aktif untuk Session ini. */
|
|
104
|
+
has(sessionId: string): boolean {
|
|
105
|
+
return this.turns.has(sessionId);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Mulai turn streaming: buat entry + pasang timer idle (jaring pengaman).
|
|
110
|
+
* Timer adalah batas KEDIAMAN, bukan batas total: setiap tanda hidup (part
|
|
111
|
+
* baru, pesan assistant, status opencode) me-reset-nya lewat `keepAlive`,
|
|
112
|
+
* sehingga turn panjang yang sehat tidak dipotong. Bila sunyi terus sampai
|
|
113
|
+
* batas (mis. server mati di tengah turn), timer memanggil `fail` dengan
|
|
114
|
+
* `timeoutMessage` — parts yang sudah terkumpul tetap disimpan.
|
|
115
|
+
*/
|
|
116
|
+
begin(sessionId: string, timeoutMs: number, timeoutMessage: string): void {
|
|
117
|
+
const turn = emptyTurn();
|
|
118
|
+
turn.idleTimeoutMs = timeoutMs;
|
|
119
|
+
turn.timeoutMessage = timeoutMessage;
|
|
120
|
+
this.turns.set(sessionId, turn);
|
|
121
|
+
this.armTimer(sessionId, turn);
|
|
122
|
+
// Beri tahu Client bahwa model mulai merespon (tombol stop/interrupt aktif).
|
|
123
|
+
this.opts.onTurnChange?.(sessionId, true);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Pasang ulang timer idle turn (timer lama dibersihkan lebih dulu). */
|
|
127
|
+
private armTimer(sessionId: string, turn: StreamingTurn): void {
|
|
128
|
+
if (turn.timeout !== undefined) this.opts.clearTimeoutFn(turn.timeout);
|
|
129
|
+
// Callback memeriksa handle MASIH yang terpasang: bila timer sudah
|
|
130
|
+
// di-reset (`keepAlive`) tapi timer lama sempat terpicu (race), jangan
|
|
131
|
+
// gagalkan turn yang justru sedang hidup.
|
|
132
|
+
let handle: unknown;
|
|
133
|
+
handle = this.opts.setTimeoutFn(() => {
|
|
134
|
+
if (this.turns.get(sessionId) !== turn || turn.timeout !== handle) return;
|
|
135
|
+
// Turn menunggu input user (kartu permission pending, dsb.) -> bukan
|
|
136
|
+
// sunyi: tunda fire dan pasang ulang timer.
|
|
137
|
+
if (this.opts.shouldFireIdle?.(sessionId) === false) {
|
|
138
|
+
this.armTimer(sessionId, turn);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
this.fail(sessionId, turn.timeoutMessage);
|
|
142
|
+
}, turn.idleTimeoutMs);
|
|
143
|
+
turn.timeout = handle;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Tanda hidup opencode di tengah turn (part baru, status busy/retry):
|
|
148
|
+
* reset timer idle agar turn panjang yang sehat tidak dipicu timeout.
|
|
149
|
+
*/
|
|
150
|
+
keepAlive(sessionId: string): void {
|
|
151
|
+
const turn = this.turns.get(sessionId);
|
|
152
|
+
if (!turn || turn.finalized) return;
|
|
153
|
+
this.armTimer(sessionId, turn);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Catat error `session.error` non-abort pada turn yang masih berjalan:
|
|
158
|
+
* banner diteruskan ke Client SEKETIKA, tapi turn TIDAK ditutup — error
|
|
159
|
+
* semacam ini sering tidak terminal (mis. `@file` gagal dibaca; turn
|
|
160
|
+
* lanjut dan jawaban tetap di-stream). Saat turn ditutup, pesan error
|
|
161
|
+
* hanya ditulis ke history bila TIDAK ada konten baru setelah error
|
|
162
|
+
* (lihat `finish`) — kalau jawaban menyusul, transcript tetap bersih.
|
|
163
|
+
*/
|
|
164
|
+
noteError(sessionId: string, message: string): void {
|
|
165
|
+
const turn = this.turns.get(sessionId);
|
|
166
|
+
if (!turn || turn.finalized) return;
|
|
167
|
+
turn.pendingError = { message, partsAtError: this.countParts(turn) };
|
|
168
|
+
this.opts.onError?.(sessionId, message);
|
|
169
|
+
this.armTimer(sessionId, turn);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Catat id pesan assistant (`message.updated` role=assistant) pada turn aktif. */
|
|
173
|
+
noteAssistantMessage(sessionId: string, messageId: string): void {
|
|
174
|
+
const turn = this.turns.get(sessionId);
|
|
175
|
+
if (!turn) return;
|
|
176
|
+
turn.assistantMsgIds.add(messageId);
|
|
177
|
+
// Pesan assistant baru = tanda hidup: reset timer idle turn.
|
|
178
|
+
this.armTimer(sessionId, turn);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Terima satu part streaming dari pesan assistant yang dikenal: simpan ke
|
|
183
|
+
* akumulasi turn (per messageId) lalu forward ke Client. Part yang datang
|
|
184
|
+
* sebelum assistant dikenal / untuk messageId lain diabaikan.
|
|
185
|
+
*/
|
|
186
|
+
acceptPart(sessionId: string, messageId: string, part: MessagePart): void {
|
|
187
|
+
const turn = this.turns.get(sessionId);
|
|
188
|
+
if (!turn || turn.assistantMsgIds.size === 0) return;
|
|
189
|
+
if (!turn.assistantMsgIds.has(messageId)) return;
|
|
190
|
+
if (typeof part.type !== "string" || typeof part.id !== "string") return;
|
|
191
|
+
let byPart = turn.parts.get(messageId);
|
|
192
|
+
if (!byPart) {
|
|
193
|
+
byPart = new Map<string, MessagePart>();
|
|
194
|
+
turn.parts.set(messageId, byPart);
|
|
195
|
+
turn.order.push(messageId);
|
|
196
|
+
}
|
|
197
|
+
byPart.set(part.id, part);
|
|
198
|
+
// Part baru = tanda hidup: reset timer idle turn.
|
|
199
|
+
this.armTimer(sessionId, turn);
|
|
200
|
+
this.opts.onMessagePart?.(sessionId, messageId, part);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Terima potongan teks streaming (`message.part.delta`, opencode >= 1.x):
|
|
205
|
+
* token model tiba lewat event ini, sementara `message.part.updated` hanya
|
|
206
|
+
* dikirim di awal (teks kosong) dan di akhir (teks lengkap). Delta
|
|
207
|
+
* ditambahkan ke part yang terakumulasi (agar yang tersimpan tetap utuh
|
|
208
|
+
* walau `updated` final tidak datang) lalu diteruskan ke Client.
|
|
209
|
+
* Delta untuk part/pesan yang belum dikenal diabaikan — `updated` final
|
|
210
|
+
* tetap membawa teks lengkapnya.
|
|
211
|
+
*/
|
|
212
|
+
acceptDelta(
|
|
213
|
+
sessionId: string,
|
|
214
|
+
messageId: string,
|
|
215
|
+
partId: string,
|
|
216
|
+
field: string,
|
|
217
|
+
delta: string,
|
|
218
|
+
): void {
|
|
219
|
+
const turn = this.turns.get(sessionId);
|
|
220
|
+
if (!turn || turn.finalized || !turn.assistantMsgIds.has(messageId)) return;
|
|
221
|
+
const part = turn.parts.get(messageId)?.get(partId);
|
|
222
|
+
if (!part) return;
|
|
223
|
+
const cur = (part as Record<string, unknown>)[field];
|
|
224
|
+
if (cur !== undefined && typeof cur !== "string") return;
|
|
225
|
+
// Salinan baru: part yang sudah dikirim ke Client tidak dimutasi.
|
|
226
|
+
turn.parts.get(messageId)?.set(partId, { ...part, [field]: (cur ?? "") + delta });
|
|
227
|
+
this.armTimer(sessionId, turn);
|
|
228
|
+
this.opts.onMessagePartDelta?.(sessionId, messageId, partId, field, delta);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Simpan seluruh pesan assistant yang sudah terakumulasi dari parts SSE
|
|
233
|
+
* (satu pesan per messageId, sesuai urutan kemunculan). Dipakai baik oleh
|
|
234
|
+
* finalisasi sukses (`session.idle`) maupun gagal — parts yang sudah
|
|
235
|
+
* ter-stream tidak boleh hilang walau turn berakhir dengan error.
|
|
236
|
+
*/
|
|
237
|
+
private persistParts(sessionId: string, turn: StreamingTurn): void {
|
|
238
|
+
const createdAt = this.opts.now();
|
|
239
|
+
for (const messageId of turn.order) {
|
|
240
|
+
const byPart = turn.parts.get(messageId);
|
|
241
|
+
if (!byPart || byPart.size === 0) continue;
|
|
242
|
+
const message: SessionMessage = {
|
|
243
|
+
id: messageId,
|
|
244
|
+
sessionId,
|
|
245
|
+
role: "assistant",
|
|
246
|
+
parts: [...byPart.values()],
|
|
247
|
+
createdAt,
|
|
248
|
+
};
|
|
249
|
+
// Turn ulang atas messageId yang sama (UNIQUE) gagal disimpan — pesan
|
|
250
|
+
// tetap diteruskan agar Client tidak kehilangan balasan.
|
|
251
|
+
this.opts.store.insertMessage(message);
|
|
252
|
+
this.opts.onMessage?.(message);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Tandai turn tertutup: bersihkan timer + hapus entry + broadcast tidak aktif. */
|
|
257
|
+
private close(sessionId: string, turn: StreamingTurn): void {
|
|
258
|
+
turn.finalized = true;
|
|
259
|
+
if (turn.timeout !== undefined) this.opts.clearTimeoutFn(turn.timeout);
|
|
260
|
+
this.turns.delete(sessionId);
|
|
261
|
+
this.opts.onTurnChange?.(sessionId, false);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Tutup turn sukses: rakit pesan assistant dari parts hasil SSE, simpan,
|
|
266
|
+
* kirim ke Client. Dipanggil saat `session.idle` Session akar (atau
|
|
267
|
+
* stop/exit).
|
|
268
|
+
*
|
|
269
|
+
* Sumber kebenaran adalah parts SSE, bukan balasan `POST /message` — pada
|
|
270
|
+
* turn panjang (mis. sub-agent) koneksi POST bisa putus sebelum balasan
|
|
271
|
+
* datang, sehingga pesan final tidak akan pernah tersimpan.
|
|
272
|
+
*/
|
|
273
|
+
finish(sessionId: string): void {
|
|
274
|
+
const turn = this.turns.get(sessionId);
|
|
275
|
+
if (!turn || turn.finalized) return;
|
|
276
|
+
this.close(sessionId, turn);
|
|
277
|
+
this.persistParts(sessionId, turn);
|
|
278
|
+
// Error yang ditunda hanya menjadi pesan bila TIDAK ada konten baru
|
|
279
|
+
// setelah error (error memang yang terakhir terjadi — gagal terminal).
|
|
280
|
+
// Bila jawaban menyusul setelah error (kasus `@file` gagal dibaca),
|
|
281
|
+
// menulis error di atas jawaban asli hanya membuat error palsu.
|
|
282
|
+
// Banner sudah dikirim saat `noteError` — di sini cukup tulis history.
|
|
283
|
+
const pending = turn.pendingError;
|
|
284
|
+
if (pending && this.countParts(turn) === pending.partsAtError) {
|
|
285
|
+
this.writeErrorMessage(sessionId, pending.message, false);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Total jumlah part yang sudah terakumulasi pada turn. */
|
|
290
|
+
private countParts(turn: StreamingTurn): number {
|
|
291
|
+
let total = 0;
|
|
292
|
+
for (const byPart of turn.parts.values()) total += byPart.size;
|
|
293
|
+
return total;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Buang turn yang sedang di-stream TANPA menyimpan parts-nya. Dipakai saat
|
|
298
|
+
* user meng-interrupt: balasan parsial yang belum selesai dibuang agar
|
|
299
|
+
* tidak tersimpan & tidak muncul kembali di riwayat/Client. Broadcast turn
|
|
300
|
+
* tidak aktif tetap dikirim agar tombol stop di UI mati.
|
|
301
|
+
*/
|
|
302
|
+
discard(sessionId: string): void {
|
|
303
|
+
const turn = this.turns.get(sessionId);
|
|
304
|
+
if (!turn || turn.finalized) return;
|
|
305
|
+
this.close(sessionId, turn);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Tutup turn dalam kondisi gagal (prompt ditolak, `session.error`, atau
|
|
310
|
+
* timeout): parts yang sudah ter-stream tetap disimpan, lalu pesan error
|
|
311
|
+
* ber-role assistant (part `type: "error"`) ditulis ke history agar
|
|
312
|
+
* kegagalan terlihat dan bertahan setelah reattach. `onError` tetap
|
|
313
|
+
* dipanggil untuk banner instan di Client.
|
|
314
|
+
*/
|
|
315
|
+
fail(sessionId: string, message: string): void {
|
|
316
|
+
const turn = this.turns.get(sessionId);
|
|
317
|
+
if (!turn || turn.finalized) return;
|
|
318
|
+
this.close(sessionId, turn);
|
|
319
|
+
this.persistParts(sessionId, turn);
|
|
320
|
+
this.writeErrorMessage(sessionId, message);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Tulis pesan error role=assistant (part `type: "error"`) ke history agar
|
|
325
|
+
* kegagalan terlihat dan bertahan setelah reattach, plus banner instan ke
|
|
326
|
+
* Client — kecuali `banner=false` (banner sudah dikirim lebih dulu).
|
|
327
|
+
*/
|
|
328
|
+
private writeErrorMessage(sessionId: string, message: string, banner = true): void {
|
|
329
|
+
const errMsg: SessionMessage = {
|
|
330
|
+
id: `err_${randomUUID()}`,
|
|
331
|
+
sessionId,
|
|
332
|
+
role: "assistant",
|
|
333
|
+
parts: [{ type: "error", text: message }],
|
|
334
|
+
createdAt: this.opts.now(),
|
|
335
|
+
};
|
|
336
|
+
if (this.opts.store.insertMessage(errMsg).ok) this.opts.onMessage?.(errMsg);
|
|
337
|
+
if (banner) this.opts.onError?.(sessionId, message);
|
|
338
|
+
}
|
|
339
|
+
}
|