Skip to content

Commit e5cc034

Browse files
authored
feat: kilo-chat — plugin, backend, event service, and web UI (#2361)
1 parent c61948d commit e5cc034

220 files changed

Lines changed: 48606 additions & 153 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,12 @@ jobs:
9292
- name: Install dependencies
9393
run: pnpm install --frozen-lockfile
9494

95+
- name: Plugin shared-schemas sync check
96+
run: bash scripts/sync-plugin-shared.sh --check
97+
98+
- name: Plugin openclaw peer-dep pin check
99+
run: bash scripts/check-plugin-openclaw-pin.sh
100+
95101
- name: Typecheck
96102
run: scripts/typecheck-all.sh
97103

.github/workflows/deploy-kiloclaw.yml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ jobs:
4848
# - controller/ (COPY controller/ → compiled to controller.js)
4949
# - container/ (COPY container/TOOLS.md → /usr/local/share/kiloclaw/)
5050
# - plugins/kiloclaw-customizer/ (COPY plugin package for image install)
51+
# - plugins/kilo-chat/ (COPY plugin package for image install)
5152
# - openclaw-pairing-list.js, openclaw-device-pairing-list.js (COPY)
5253
# - skills/ (COPY skills/ → /root/clawd/skills/)
5354
#
@@ -60,7 +61,7 @@ jobs:
6061
working-directory: services/kiloclaw
6162
run: |
6263
# Validate all expected paths exist before hashing
63-
for path in Dockerfile controller container plugins/kiloclaw-customizer plugins/kiloclaw-morning-briefing skills \
64+
for path in Dockerfile controller container plugins/kiloclaw-customizer plugins/kilo-chat plugins/kiloclaw-morning-briefing skills \
6465
openclaw-pairing-list.js openclaw-device-pairing-list.js; do
6566
if [ ! -e "$path" ]; then
6667
echo "::error::Required path not found: $path"
@@ -69,7 +70,7 @@ jobs:
6970
done
7071
7172
CONTENT_HASH=$(
72-
find Dockerfile controller/ container/ plugins/kiloclaw-customizer/ plugins/kiloclaw-morning-briefing/ skills/ \
73+
find Dockerfile controller/ container/ plugins/kiloclaw-customizer/ plugins/kilo-chat/ plugins/kiloclaw-morning-briefing/ skills/ \
7374
openclaw-pairing-list.js openclaw-device-pairing-list.js \
7475
-type f \
7576
| sort \

.github/workflows/push-dev-kiloclaw.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ jobs:
4040
id: content-hash
4141
working-directory: services/kiloclaw
4242
run: |
43-
for path in Dockerfile controller container plugins/kiloclaw-customizer plugins/kiloclaw-morning-briefing skills \
43+
for path in Dockerfile controller container plugins/kiloclaw-customizer plugins/kilo-chat plugins/kiloclaw-morning-briefing skills \
4444
openclaw-pairing-list.js openclaw-device-pairing-list.js; do
4545
if [ ! -e "$path" ]; then
4646
echo "::error::Required path not found: $path"
@@ -49,7 +49,7 @@ jobs:
4949
done
5050
5151
CONTENT_HASH=$(
52-
find Dockerfile controller/ container/ plugins/kiloclaw-customizer/ plugins/kiloclaw-morning-briefing/ skills/ \
52+
find Dockerfile controller/ container/ plugins/kiloclaw-customizer/ plugins/kilo-chat/ plugins/kiloclaw-morning-briefing/ skills/ \
5353
openclaw-pairing-list.js openclaw-device-pairing-list.js \
5454
-type f \
5555
| sort \

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ yarn-error.log*
6666
/prototypes/
6767
.plan/
6868
.kilo/plans
69+
.superpowers/
70+
docs/superpowers/
6971
.kilo/node_modules
7072
.kilo/package.json
7173
.kilo/package-lock.json
@@ -97,6 +99,9 @@ supabase/.temp
9799
# husky generated hook shims
98100
.husky/_/
99101

102+
# agent plans
103+
.opencode/plans/
104+
100105
# misc
101106
TMP_CI_commit_msg.txt
102107
*.csv

apps/web/.env.development.local.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,9 @@ NEXT_PUBLIC_CLOUD_AGENT_WS_URL=ws://localhost:8794
3636

3737
# @url cloud-agent-next
3838
NEXT_PUBLIC_CLOUD_AGENT_NEXT_WS_URL=ws://localhost:8794
39+
40+
# @url kilo-chat
41+
NEXT_PUBLIC_KILO_CHAT_URL=http://localhost:8808
42+
43+
# @url event-service
44+
NEXT_PUBLIC_EVENT_SERVICE_URL=ws://localhost:8809

apps/web/.env.test

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ OPENROUTER_API_KEY=invalid-mock-api-key
1111
STYTCH_PROJECT_ID=test-fake-project-id
1212
STYTCH_PROJECT_SECRET=test-fake-project-secret
1313
NEXT_PUBLIC_STYTCH_PROJECT_ENV=test
14+
NEXT_PUBLIC_GASTOWN_URL=https://gastown.test.invalid
15+
NEXT_PUBLIC_KILO_CHAT_URL=https://kilo-chat.test.invalid
16+
NEXT_PUBLIC_EVENT_SERVICE_URL=https://event-service.test.invalid
1417
JEST_SILENT=false
1518
DEBUG_SHOW_DEV_UI=1
1619
IS_IN_AUTOMATED_TEST=1

apps/web/package.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,13 @@
3838
"@chat-adapter/slack": "^4.20.1",
3939
"@chat-adapter/state-memory": "^4.20.1",
4040
"@chat-adapter/state-redis": "^4.20.1",
41+
"@emoji-mart/data": "^1.2.1",
42+
"@emoji-mart/react": "^1.1.1",
4143
"google-auth-library": "^10.4.1",
4244
"@kilocode/db": "workspace:*",
4345
"@kilocode/encryption": "workspace:*",
46+
"@kilocode/event-service": "workspace:*",
47+
"@kilocode/kilo-chat": "workspace:*",
4448
"@kilocode/kiloclaw-secret-catalog": "workspace:*",
4549
"@kilocode/worker-utils": "workspace:*",
4650
"@lottiefiles/dotlottie-react": "^0.17.15",
@@ -107,6 +111,7 @@
107111
"discord-interactions": "^4.4.0",
108112
"discord.js": "^14.25.1",
109113
"drizzle-orm": "catalog:",
114+
"emoji-mart": "^5.6.0",
110115
"event-source-polyfill": "^1.0.31",
111116
"eventsource-parser": "^3.0.6",
112117
"fflate": "^0.8.2",
@@ -124,6 +129,7 @@
124129
"next": "^16.1.6",
125130
"next-auth": "^4.24.13",
126131
"openai": "^6.29.0",
132+
"p-limit": "catalog:",
127133
"posthog-js": "^1.360.2",
128134
"posthog-node": "5.10.0",
129135
"react": "^19.2.4",
@@ -142,6 +148,7 @@
142148
"stytch": "^12.43.1",
143149
"tailwind-merge": "^3.5.0",
144150
"tldts": "^7.0.28",
151+
"ulid": "3.0.1",
145152
"uuid": "11.1.0",
146153
"vaul": "^1.1.2",
147154
"zod": "catalog:"
@@ -167,7 +174,6 @@
167174
"jest": "^30.3.0",
168175
"knip": "^5.86.0",
169176
"madge": "^8.0.0",
170-
"p-limit": "^7.3.0",
171177
"postcss": "^8.5.8",
172178
"tailwindcss": "^4.2.1",
173179
"ts-jest": "^29.4.6",
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
'use client';
2+
3+
import { useEffect } from 'react';
4+
import { useParams, useRouter } from 'next/navigation';
5+
import { toast } from 'sonner';
6+
import { KiloChatApiError } from '@kilocode/kilo-chat';
7+
import { useKiloChatContext } from '../components/kiloChatContext';
8+
import { useConversationDetail } from '../hooks/useConversations';
9+
import { MessageArea } from '../components/MessageArea';
10+
11+
export default function KiloChatConversationPage() {
12+
const params = useParams<{ conversationId: string }>();
13+
const router = useRouter();
14+
const { kiloChatClient, leavingConversationId, basePath } = useKiloChatContext();
15+
const isLeaving = leavingConversationId === params.conversationId;
16+
const conversationDetail = useConversationDetail(
17+
kiloChatClient,
18+
isLeaving ? null : params.conversationId
19+
);
20+
21+
useEffect(() => {
22+
if (conversationDetail.isError && !isLeaving) {
23+
const status =
24+
conversationDetail.error instanceof KiloChatApiError
25+
? conversationDetail.error.status
26+
: undefined;
27+
const message =
28+
status === 400 || status === 403 || status === 404
29+
? 'Conversation not found'
30+
: 'Failed to load conversation';
31+
toast.error(message);
32+
router.replace(basePath);
33+
}
34+
}, [conversationDetail.isError, conversationDetail.error, isLeaving, router, basePath]);
35+
36+
if (isLeaving) {
37+
return null;
38+
}
39+
40+
if (conversationDetail.isError) {
41+
return null;
42+
}
43+
44+
return <MessageArea key={params.conversationId} conversationId={params.conversationId} />;
45+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
'use client';
2+
3+
import { useEffect, useState } from 'react';
4+
5+
export type BotPresence = {
6+
online: boolean;
7+
lastAt: number;
8+
};
9+
10+
export type BotDisplayState = 'online' | 'idle' | 'offline' | 'unknown';
11+
12+
type BotDisplay = {
13+
state: BotDisplayState;
14+
label: 'Online' | 'Idle' | 'Offline' | 'Unknown';
15+
};
16+
17+
export function computeBotDisplay(params: {
18+
instanceStatus: string | null;
19+
presence: BotPresence | undefined;
20+
now: number;
21+
}): BotDisplay {
22+
if (params.instanceStatus !== 'running') return { state: 'offline', label: 'Offline' };
23+
if (!params.presence) return { state: 'unknown', label: 'Unknown' };
24+
if (!params.presence.online) return { state: 'offline', label: 'Offline' };
25+
const elapsed = params.now - params.presence.lastAt;
26+
if (elapsed > 90_000) return { state: 'offline', label: 'Offline' };
27+
if (elapsed > 30_000) return { state: 'idle', label: 'Idle' };
28+
return { state: 'online', label: 'Online' };
29+
}
30+
31+
const DOT_CLASS: Record<BotDisplayState, string> = {
32+
online: 'bg-green-500',
33+
idle: 'bg-amber-500',
34+
offline: 'bg-muted-foreground/50',
35+
unknown: 'bg-muted-foreground/30',
36+
};
37+
38+
type BotStatusProps = {
39+
instanceStatus: string | null;
40+
presence?: BotPresence;
41+
model?: string | null;
42+
};
43+
44+
export function BotStatus({ instanceStatus, presence, model }: BotStatusProps) {
45+
const now = useNowTicker(10_000);
46+
const display = computeBotDisplay({ instanceStatus, presence, now });
47+
const tooltip = buildTooltip(display.state, presence, now, model ?? null);
48+
return (
49+
<div className="flex items-center gap-1.5" title={tooltip}>
50+
<div className={`h-2 w-2 rounded-full ${DOT_CLASS[display.state]}`} />
51+
<span className="text-muted-foreground text-xs">{display.label}</span>
52+
</div>
53+
);
54+
}
55+
56+
// Staleness ticker: keeps re-renders scoped to the subtree that uses it so
57+
// sibling components (memoized message bubbles, etc.) are not invalidated
58+
// every tick. Exported so MessageArea can reuse it for the send-gate that
59+
// reacts to presence going stale without any user interaction.
60+
export function useNowTicker(intervalMs: number): number {
61+
const [now, setNow] = useState(() => Date.now());
62+
useEffect(() => {
63+
const id = setInterval(() => setNow(Date.now()), intervalMs);
64+
return () => clearInterval(id);
65+
}, [intervalMs]);
66+
return now;
67+
}
68+
69+
function buildTooltip(
70+
state: BotDisplayState,
71+
presence: BotPresence | undefined,
72+
now: number,
73+
model: string | null
74+
): string {
75+
if (state === 'unknown' || !presence) return 'Bot status unknown';
76+
if (state === 'offline') return 'Bot is offline';
77+
const seconds = Math.max(0, Math.round((now - presence.lastAt) / 1000));
78+
const bits = [`Last heartbeat ${seconds}s ago`];
79+
if (model) bits.push(`model: ${model}`);
80+
return bits.join(' · ');
81+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
'use client';
2+
3+
type ContextUsageRingProps = {
4+
contextTokens: number;
5+
contextWindow: number;
6+
};
7+
8+
const SIZE = 18;
9+
const STROKE = 2.5;
10+
const RADIUS = (SIZE - STROKE) / 2;
11+
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
12+
13+
function strokeColorClass(pct: number): string {
14+
if (pct >= 80) return 'text-red-500';
15+
if (pct >= 50) return 'text-amber-500';
16+
return 'text-muted-foreground';
17+
}
18+
19+
export function ContextUsageRing({ contextTokens, contextWindow }: ContextUsageRingProps) {
20+
if (!contextWindow || contextWindow <= 0) return null;
21+
22+
const rawPct = (contextTokens / contextWindow) * 100;
23+
const pct = Math.max(0, Math.min(100, rawPct));
24+
const dashOffset = CIRCUMFERENCE * (1 - pct / 100);
25+
const tooltip = `${contextTokens.toLocaleString()} / ${contextWindow.toLocaleString()} tokens`;
26+
27+
return (
28+
<div className="flex items-center gap-1.5" title={tooltip}>
29+
<svg width={SIZE} height={SIZE} className={strokeColorClass(pct)} aria-hidden>
30+
<circle
31+
cx={SIZE / 2}
32+
cy={SIZE / 2}
33+
r={RADIUS}
34+
fill="none"
35+
strokeWidth={STROKE}
36+
className="stroke-muted-foreground/20"
37+
/>
38+
<circle
39+
cx={SIZE / 2}
40+
cy={SIZE / 2}
41+
r={RADIUS}
42+
fill="none"
43+
stroke="currentColor"
44+
strokeWidth={STROKE}
45+
strokeLinecap="round"
46+
strokeDasharray={CIRCUMFERENCE}
47+
strokeDashoffset={dashOffset}
48+
transform={`rotate(-90 ${SIZE / 2} ${SIZE / 2})`}
49+
/>
50+
</svg>
51+
<span className="text-muted-foreground text-xs tabular-nums">{Math.round(pct)}%</span>
52+
</div>
53+
);
54+
}

0 commit comments

Comments
 (0)