feat(web): define ChatEvent discriminated union, eliminate all any sites (R9)
Created web/src/lib/types.ts with discriminated unions for SSE event payloads: ChatEvent (7 variants: session, tool_use, tool_result, text_delta, text, done, error), ToolCallResult, MessageContent, and typed data shapes for live events (PlanProposedData, PlanStepEventData, QuestionRaisedData, QuestionAnsweredData, EntityTouchedData, HealthChangedData) plus WailsGlobal for the desktop bridge. Replaced all ~15 `any` sites across 7 files: - api.ts: Message.content any -> MessageContent | string; removed local ChatEvent interface (now imported from types.ts as a discriminated union); JSON.parse cast to ChatEvent. - stores/chat.ts: removed local ToolCallResult interface (imported from types.ts, re-exported for backward compat); extractApprovals accesses args with typeof guards instead of implicit any access; toChatMessages handles string|object Message.content cleanly. - stores/activity.ts: update_plan_step seq/status extracted via typeof guards instead of `as any` casts; toolActivityLabel uses a str() helper for safe string extraction from unknown args. - stores/workspace.ts: applyPlanStepEvent takes PlanStepEventData; applyEvent casts data to Record<string, unknown>; switch cases cast to typed interfaces (PlanProposedData, QuestionRaisedData, etc.) instead of `as any`; applyHealthChanged uses HealthChangedData. - Config.svelte: (window as any).wails -> typed WailsGlobal cast; catch (e: any) -> catch (e: unknown) with instanceof Error check. - utils.ts: WithoutChild/WithoutChildren `any` -> `unknown`. - vite.config.ts: authProxy proxy/proxyReq `any` -> ProxyOptions type. Result: eslint no-explicit-any warnings dropped 12 -> 0. Tests (6/6) and build pass. VERSION 0.7.10 -> 0.7.11. Plan R9 marked done.
This commit is contained in:
@@ -29,7 +29,7 @@ export interface Message {
|
||||
id: string
|
||||
session_id: string
|
||||
role: string
|
||||
content: any
|
||||
content: MessageContent | string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
@@ -102,13 +102,6 @@ export async function answerQuestion(sessionId: string, questionId: string, answ
|
||||
return res.ok
|
||||
}
|
||||
|
||||
export interface ChatEvent {
|
||||
type: string
|
||||
data: any
|
||||
session_id?: string
|
||||
iteration?: number
|
||||
}
|
||||
|
||||
export function streamChat(
|
||||
message: string,
|
||||
sessionId: string | null,
|
||||
|
||||
@@ -70,8 +70,8 @@ export const activityLog = derived([messages, planSteps, currentTask], ([$msgs,
|
||||
for (const t of $msgs[mi].tools) {
|
||||
// Track current step from update_plan_step calls
|
||||
if (t.type === 'tool_use' && t.name === 'update_plan_step') {
|
||||
const s = (t.args as any)?.seq as number | undefined
|
||||
const status = (t.args as any)?.status as string | undefined
|
||||
const s = typeof t.args?.seq === 'number' ? t.args.seq : undefined
|
||||
const status = typeof t.args?.status === 'string' ? t.args.status : undefined
|
||||
if (s && status === 'running') currentStepSeq = s
|
||||
} else if (t.name === 'set_goal' || t.name === 'propose_plan' || t.name === 'complete_task') {
|
||||
currentStepSeq = 0
|
||||
@@ -132,7 +132,7 @@ export const activityLog = derived([messages, planSteps, currentTask], ([$msgs,
|
||||
for (let mi = 0; mi < $msgs.length; mi++) {
|
||||
for (const t of $msgs[mi].tools) {
|
||||
if (t.type === 'tool_result' && t.name === 'upsert_knowledge' && !t.error) {
|
||||
const title = t.args?.title ?? ''
|
||||
const title = typeof t.args?.title === 'string' ? t.args.title : ''
|
||||
entries.push({
|
||||
id: `knowledge_${mi}`,
|
||||
type: 'knowledge',
|
||||
@@ -172,11 +172,12 @@ export const activityLog = derived([messages, planSteps, currentTask], ([$msgs,
|
||||
|
||||
function toolActivityLabel(t: ToolCallResult): string {
|
||||
const args = t.args ?? {}
|
||||
const str = (v: unknown): string => typeof v === 'string' ? v : ''
|
||||
switch (t.name) {
|
||||
case 'set_goal': return 'Set goal'
|
||||
case 'propose_plan': return 'Proposed plan'
|
||||
case 'search_knowledge': return `Research: ${args.query || ''}`
|
||||
case 'get_entity': return `Lookup: ${args.slug_or_id || ''}`
|
||||
case 'search_knowledge': return `Research: ${str(args.query)}`
|
||||
case 'get_entity': return `Lookup: ${str(args.slug_or_id)}`
|
||||
case 'get_entity_knowledge': return 'Check prior knowledge'
|
||||
case 'get_relations': return 'Check relationships'
|
||||
case 'list_lxcs': return 'List containers'
|
||||
@@ -184,8 +185,8 @@ function toolActivityLabel(t: ToolCallResult): string {
|
||||
case 'get_health_summary': return 'Fleet health'
|
||||
case 'get_state_snapshot': return 'State snapshot'
|
||||
case 'run': {
|
||||
const purpose = args.purpose as string || ''
|
||||
const target = (args.target as string) || ''
|
||||
const purpose = str(args.purpose)
|
||||
const target = str(args.target)
|
||||
if (purpose) return purpose
|
||||
if (target) return `Run on ${target}`
|
||||
return 'Run command'
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { writable, get } from 'svelte/store'
|
||||
import { streamChat, fetchSessions, fetchMessages, deleteSession as apiDeleteSession } from '$lib/api'
|
||||
import type { ChatEvent, Session, Message } from '$lib/api'
|
||||
import type { ToolCallResult } from '$lib/types'
|
||||
|
||||
export type { ToolCallResult }
|
||||
|
||||
export interface PendingApproval {
|
||||
executionId: string
|
||||
@@ -38,28 +41,21 @@ function extractApprovals(tools: ToolCallResult[]): PendingApproval[] {
|
||||
if (!text.includes('requires approval')) continue
|
||||
const m = text.match(APPROVAL_RE)
|
||||
if (m) {
|
||||
const args = t.args ?? {}
|
||||
const purpose = typeof args.purpose === 'string' ? args.purpose : undefined
|
||||
out.push({
|
||||
executionId: m[1],
|
||||
action: t.args?.purpose?.slice(0, 60) ?? t.args?.action ?? t.name ?? 'unknown',
|
||||
target: t.args?.target ?? 'unknown',
|
||||
action: purpose ? purpose.slice(0, 60) : typeof args.action === 'string' ? args.action : t.name,
|
||||
target: typeof args.target === 'string' ? args.target : 'unknown',
|
||||
destructive: /\bDESTRUCTIVE\b/.test(text),
|
||||
command: t.args?.command,
|
||||
purpose: t.args?.purpose
|
||||
command: typeof args.command === 'string' ? args.command : undefined,
|
||||
purpose
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export interface ToolCallResult {
|
||||
type: 'tool_use' | 'tool_result'
|
||||
name: string
|
||||
id?: string
|
||||
args?: any
|
||||
result?: any
|
||||
error?: string
|
||||
}
|
||||
|
||||
function mid(): string {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
@@ -119,11 +115,12 @@ function mergeToolCalls(raw: ToolCallResult[] | undefined): ToolCallResult[] {
|
||||
|
||||
function toChatMessages(msgs: Message[]): ChatMessage[] {
|
||||
return msgs.map((m) => {
|
||||
const tools = mergeToolCalls(m.content?.tool_calls)
|
||||
const content = typeof m.content === 'string' ? { text: m.content } : m.content
|
||||
const tools = mergeToolCalls(content?.tool_calls)
|
||||
return {
|
||||
id: m.id,
|
||||
role: m.role as 'user' | 'assistant',
|
||||
text: m.content?.text ?? (typeof m.content === 'string' ? m.content : ''),
|
||||
text: content?.text ?? '',
|
||||
tools,
|
||||
pendingApprovals: extractApprovals(tools)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,14 @@ import { writable, derived, get } from 'svelte/store'
|
||||
import { liveEvents, subscribeEvents } from './events'
|
||||
import { currentSession, sessions, loadSessions } from './chat'
|
||||
import { fetchPlan, fetchQuestions, type PlanStep, type SessionQuestion } from '$lib/api'
|
||||
import type {
|
||||
PlanProposedData,
|
||||
PlanStepEventData,
|
||||
QuestionRaisedData,
|
||||
QuestionAnsweredData,
|
||||
EntityTouchedData,
|
||||
HealthChangedData
|
||||
} from '$lib/types'
|
||||
|
||||
// workspace.ts is the live "what is this task doing right now" surface for the
|
||||
// TaskContextPanel: plan progress, the pinned operator question, and entities
|
||||
@@ -65,9 +73,9 @@ async function hydrate(sessionId: string) {
|
||||
questions.set(qs)
|
||||
}
|
||||
|
||||
function applyPlanStepEvent(sessionId: string, type: string, data: any) {
|
||||
const stepID = data?.step_id as string | undefined
|
||||
const seq = data?.seq as number | undefined
|
||||
function applyPlanStepEvent(sessionId: string, type: string, data: PlanStepEventData) {
|
||||
const stepID = data?.step_id
|
||||
const seq = data?.seq
|
||||
planSteps.update((steps) => {
|
||||
const i = steps.findIndex((s) => (stepID && s.id === stepID) || (seq != null && s.seq === seq))
|
||||
if (i === -1) return steps
|
||||
@@ -80,7 +88,7 @@ function applyPlanStepEvent(sessionId: string, type: string, data: any) {
|
||||
function applyEvent(ev: { type: string; correlation_id?: string | null; data?: unknown }) {
|
||||
const sid = get(currentSession)
|
||||
if (!sid || ev.correlation_id !== sid) return
|
||||
const data = (ev.data ?? {}) as any
|
||||
const data = (ev.data ?? {}) as Record<string, unknown>
|
||||
|
||||
// Task fields (status/goal/outcome) live on the session row — refetch the
|
||||
// (cheap) session list so the UI picks up the change without a
|
||||
@@ -99,46 +107,49 @@ function applyEvent(ev: { type: string; correlation_id?: string | null; data?: u
|
||||
}
|
||||
|
||||
switch (ev.type) {
|
||||
case 'plan.proposed':
|
||||
if (Array.isArray(data.steps)) {
|
||||
const incoming = data.steps.map((s: any) => ({
|
||||
case 'plan.proposed': {
|
||||
const d = data as unknown as PlanProposedData
|
||||
if (Array.isArray(d.steps)) {
|
||||
const incoming = d.steps.map((s) => ({
|
||||
id: s.id, seq: s.seq, title: s.title, detail: s.detail ?? '',
|
||||
status: 'pending' as const, target_slug: s.target_slug || undefined
|
||||
}))
|
||||
// The server appends rather than replaces once any step has started
|
||||
// (see store.go proposePlan) — mirror that here so a model that calls
|
||||
// propose_plan once per step still shows the FULL running history in
|
||||
// the panel, not just its latest call's single step.
|
||||
planSteps.update((existing) => (data.appended ? [...existing, ...incoming] : incoming))
|
||||
planSteps.update((existing) => (d.appended ? [...existing, ...incoming] : incoming))
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'plan.step.started':
|
||||
case 'plan.step.finished':
|
||||
applyPlanStepEvent(sid, ev.type, data)
|
||||
applyPlanStepEvent(sid, ev.type, data as unknown as PlanStepEventData)
|
||||
break
|
||||
case 'question.raised':
|
||||
case 'question.raised': {
|
||||
const d = data as unknown as QuestionRaisedData
|
||||
questions.update((qs) => [
|
||||
{
|
||||
id: data.question_id, prompt: data.prompt ?? '',
|
||||
context: { why: data.why, options: data.options, entities: data.entities },
|
||||
id: d.question_id, prompt: d.prompt ?? '',
|
||||
context: { why: d.why, options: d.options, entities: d.entities },
|
||||
status: 'open', created_at: new Date().toISOString()
|
||||
},
|
||||
...qs.filter((q) => q.id !== data.question_id)
|
||||
...qs.filter((q) => q.id !== d.question_id)
|
||||
])
|
||||
break
|
||||
case 'question.answered':
|
||||
}
|
||||
case 'question.answered': {
|
||||
const d = data as unknown as QuestionAnsweredData
|
||||
questions.update((qs) =>
|
||||
qs.map((q) => (q.id === data.question_id ? { ...q, status: 'answered', answer: data.answer } : q))
|
||||
qs.map((q) => (q.id === d.question_id ? { ...q, status: 'answered', answer: d.answer } : q))
|
||||
)
|
||||
break
|
||||
case 'entity.touched':
|
||||
if (data.slug) {
|
||||
}
|
||||
case 'entity.touched': {
|
||||
const d = data as unknown as EntityTouchedData
|
||||
if (d.slug) {
|
||||
const now = Date.now()
|
||||
touched.update((t) => [{ slug: data.slug, tool: data.tool ?? '', ts: now }, ...t].slice(0, TOUCHED_MAX))
|
||||
touched.update((t) => [{ slug: d.slug, tool: d.tool ?? '', ts: now }, ...t].slice(0, TOUCHED_MAX))
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'knowledge.recorded':
|
||||
// No dedicated store — knowledge cards refetch on completion signal.
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -148,11 +159,11 @@ function applyEvent(ev: { type: string; correlation_id?: string | null; data?: u
|
||||
// by correlation_id (health events don't carry one).
|
||||
function applyHealthChanged(ev: { type: string; data?: unknown }) {
|
||||
if (ev.type !== 'health.changed') return
|
||||
const data = (ev.data ?? {}) as any
|
||||
const data = (ev.data ?? {}) as HealthChangedData
|
||||
if (!data.slug) return
|
||||
const isRelevant = get(touched).some((t) => t.slug === data.slug)
|
||||
if (!isRelevant) return
|
||||
healthDiffs.update((d) => [{ slug: data.slug, from: data.from, to: data.to, ts: Date.now() }, ...d].slice(0, 8))
|
||||
healthDiffs.update((d) => [{ slug: data.slug, from: data.from ?? '', to: data.to ?? '', ts: Date.now() }, ...d].slice(0, 8))
|
||||
}
|
||||
|
||||
// startWorkspace opens the global event subscription and begins tracking the
|
||||
|
||||
115
web/src/lib/types.ts
Normal file
115
web/src/lib/types.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
// Discriminated unions for SSE event payloads — eliminates `any` in the
|
||||
// chat and workspace stores by giving each event type a concrete data shape.
|
||||
|
||||
// ---- Chat SSE events (streamChat / /agent/chat) ----
|
||||
|
||||
export interface ChatSessionEvent {
|
||||
type: 'session'
|
||||
data: string
|
||||
}
|
||||
|
||||
export interface ChatToolUseEvent {
|
||||
type: 'tool_use'
|
||||
data: { name: string; id: string; args: Record<string, unknown> }
|
||||
}
|
||||
|
||||
export interface ChatToolResultEvent {
|
||||
type: 'tool_result'
|
||||
data: { id: string; result: unknown; error?: string }
|
||||
}
|
||||
|
||||
export interface ChatTextDeltaEvent {
|
||||
type: 'text_delta'
|
||||
data: string
|
||||
}
|
||||
|
||||
export interface ChatTextEvent {
|
||||
type: 'text'
|
||||
data: string
|
||||
}
|
||||
|
||||
export interface ChatDoneEvent {
|
||||
type: 'done'
|
||||
data?: { session_id?: string }
|
||||
session_id?: string
|
||||
}
|
||||
|
||||
export interface ChatErrorEvent {
|
||||
type: 'error'
|
||||
data: string
|
||||
}
|
||||
|
||||
export type ChatEvent =
|
||||
| ChatSessionEvent
|
||||
| ChatToolUseEvent
|
||||
| ChatToolResultEvent
|
||||
| ChatTextDeltaEvent
|
||||
| ChatTextEvent
|
||||
| ChatDoneEvent
|
||||
| ChatErrorEvent
|
||||
|
||||
// ---- Tool call result (merged from tool_use + tool_result SSE pairs) ----
|
||||
|
||||
export interface ToolCallResult {
|
||||
type: 'tool_use' | 'tool_result'
|
||||
name: string
|
||||
id?: string
|
||||
args?: Record<string, unknown>
|
||||
result?: unknown
|
||||
error?: string
|
||||
}
|
||||
|
||||
// ---- Message content (persisted messages from /agent/sessions/:id) ----
|
||||
|
||||
export interface MessageContent {
|
||||
text?: string
|
||||
tool_calls?: ToolCallResult[]
|
||||
}
|
||||
|
||||
// ---- Live event stream (SSE /api/v1/events/stream) ----
|
||||
|
||||
export interface PlanProposedData {
|
||||
steps: Array<{ id: string; seq: number; title: string; detail?: string; target_slug?: string }>
|
||||
appended?: boolean
|
||||
}
|
||||
|
||||
export interface PlanStepEventData {
|
||||
step_id?: string
|
||||
seq?: number
|
||||
status?: string
|
||||
execution_id?: string
|
||||
}
|
||||
|
||||
export interface QuestionRaisedData {
|
||||
question_id: string
|
||||
prompt?: string
|
||||
why?: string
|
||||
options?: string[]
|
||||
entities?: string[]
|
||||
}
|
||||
|
||||
export interface QuestionAnsweredData {
|
||||
question_id: string
|
||||
answer?: string
|
||||
}
|
||||
|
||||
export interface EntityTouchedData {
|
||||
slug?: string
|
||||
tool?: string
|
||||
}
|
||||
|
||||
export interface HealthChangedData {
|
||||
slug?: string
|
||||
from?: string
|
||||
to?: string
|
||||
}
|
||||
|
||||
// ---- Wails desktop bridge (injected into window) ----
|
||||
|
||||
export interface WailsCall {
|
||||
ByName: (method: string, ...args: unknown[]) => unknown
|
||||
}
|
||||
|
||||
export interface WailsGlobal {
|
||||
Call?: WailsCall
|
||||
}
|
||||
@@ -41,9 +41,7 @@ export function debounce<T extends (...args: never[]) => void>(fn: T, wait = 300
|
||||
}) as T;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, "children"> : T;
|
||||
export type WithoutChild<T> = T extends { child?: unknown } ? Omit<T, "child"> : T;
|
||||
export type WithoutChildren<T> = T extends { children?: unknown } ? Omit<T, "children"> : T;
|
||||
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
|
||||
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & { ref?: U | null };
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { fetchWithAuth, setConfig, initConfig, getConfig, clearConfig } from '$lib/config'
|
||||
import { startLogin, logout as oidcLogout, getUser, isOIDCConfigured } from '$lib/oidc'
|
||||
import ConfigBackground from '$lib/components/ConfigBackground.svelte'
|
||||
import type { WailsGlobal } from '$lib/types'
|
||||
|
||||
let { onConnected, onCancel }: { onConnected: () => void; onCancel?: () => void } = $props()
|
||||
|
||||
@@ -54,7 +55,7 @@
|
||||
}
|
||||
|
||||
function saveToDesktop() {
|
||||
const wails = (window as any).wails
|
||||
const wails = (window as unknown as { wails?: WailsGlobal }).wails
|
||||
if (!wails?.Call?.ByName) return
|
||||
try {
|
||||
wails.Call.ByName('SaveConfig', apiUrl.trim(), token.trim())
|
||||
@@ -71,8 +72,8 @@
|
||||
|
||||
try {
|
||||
await startLogin()
|
||||
} catch (e: any) {
|
||||
error = e.message || 'OIDC login failed'
|
||||
} catch (e: unknown) {
|
||||
error = e instanceof Error ? e.message : 'OIDC login failed'
|
||||
oidcLoggingIn = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/// <reference types="vitest/config" />
|
||||
import type { ProxyOptions } from 'vite'
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { readFileSync, existsSync } from 'fs'
|
||||
@@ -17,12 +18,12 @@ if (existsSync('../VERSION')) {
|
||||
// Injects OIKOS_API_TOKEN into proxied /api requests in dev — the API no
|
||||
// longer has a dev-open bypass (plans/2026-07-12-wails-desktop-app.md 0.4),
|
||||
// so `OIKOS_API_TOKEN=dev-token npm run dev` needs this to reach it.
|
||||
function authProxy(target: string, rewrite?: (path: string) => string) {
|
||||
function authProxy(target: string, rewrite?: (path: string) => string): ProxyOptions {
|
||||
return {
|
||||
target,
|
||||
...(rewrite ? { rewrite } : {}),
|
||||
configure: (proxy: any) => {
|
||||
proxy.on('proxyReq', (proxyReq: any) => {
|
||||
configure: (proxy) => {
|
||||
proxy.on('proxyReq', (proxyReq) => {
|
||||
const token = process.env.OIKOS_API_TOKEN
|
||||
if (token) proxyReq.setHeader('Authorization', `Bearer ${token}`)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user