mirror of
https://github.com/langchain-ai/openwork.git
synced 2026-07-01 20:37:03 -04:00
chore: refactor to use useStream
This commit is contained in:
+3
-1
@@ -28,6 +28,7 @@
|
||||
"@langchain/core": "^1.1.12",
|
||||
"@langchain/langgraph": "^1.0.15",
|
||||
"@langchain/langgraph-checkpoint": "^1.0.0",
|
||||
"@langchain/langgraph-sdk": "^1.5.3",
|
||||
"@langchain/openai": "^1.2.1",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
@@ -77,5 +78,6 @@
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.6"
|
||||
}
|
||||
},
|
||||
"packageManager": "pnpm@10.28.0+sha512.05df71d1421f21399e053fde567cea34d446fa02c76571441bfc1c7956e98e363088982d940465fd34480d4d90a0668bc12362f8aa88000a64e83d0b0e47be48"
|
||||
}
|
||||
|
||||
Generated
+865
File diff suppressed because it is too large
Load Diff
+16
-11
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import { createDeepAgent } from 'deepagents'
|
||||
import { app } from 'electron'
|
||||
import { join } from 'path'
|
||||
@@ -6,6 +7,11 @@ import { ChatAnthropic } from '@langchain/anthropic'
|
||||
import { ChatOpenAI } from '@langchain/openai'
|
||||
import { SqlJsSaver } from '../checkpointer/sqljs-saver'
|
||||
|
||||
import type * as _lcTypes from 'langchain'
|
||||
import type * as _lcMessages from '@langchain/core/messages'
|
||||
import type * as _lcLanggraph from '@langchain/langgraph'
|
||||
import type * as _lcZodTypes from '@langchain/core/utils/types'
|
||||
|
||||
// Singleton checkpointer instance
|
||||
let checkpointer: SqlJsSaver | null = null
|
||||
|
||||
@@ -19,7 +25,7 @@ export async function getCheckpointer(): Promise<SqlJsSaver> {
|
||||
}
|
||||
|
||||
// Get the appropriate model instance based on configuration
|
||||
function getModelInstance(modelId?: string) {
|
||||
function getModelInstance(modelId?: string): ChatAnthropic | ChatOpenAI | string {
|
||||
const model = modelId || getDefaultModel()
|
||||
console.log('[Runtime] Using model:', model)
|
||||
|
||||
@@ -54,30 +60,29 @@ function getModelInstance(modelId?: string) {
|
||||
}
|
||||
|
||||
// Create agent runtime with configured model and checkpointer
|
||||
export type AgentRuntime = ReturnType<typeof createDeepAgent>
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
||||
export async function createAgentRuntime(modelId?: string) {
|
||||
console.log('[Runtime] Creating agent runtime...')
|
||||
|
||||
|
||||
const model = getModelInstance(modelId)
|
||||
console.log('[Runtime] Model instance created:', typeof model)
|
||||
|
||||
|
||||
const saver = await getCheckpointer()
|
||||
console.log('[Runtime] Checkpointer ready')
|
||||
|
||||
// Using type assertion to work around version compatibility issues
|
||||
// between @langchain packages and deepagentsjs types
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const agent = createDeepAgent({
|
||||
model: model as any,
|
||||
checkpointer: saver as any
|
||||
model: model,
|
||||
checkpointer: saver
|
||||
})
|
||||
|
||||
console.log('[Runtime] Deep agent created')
|
||||
|
||||
console.log('[Runtime] Deep agent created')
|
||||
return agent
|
||||
}
|
||||
|
||||
// Clean up resources
|
||||
export async function closeRuntime() {
|
||||
export async function closeRuntime(): Promise<void> {
|
||||
if (checkpointer) {
|
||||
await checkpointer.close()
|
||||
checkpointer = null
|
||||
|
||||
+6
-10
@@ -4,7 +4,7 @@ import { dirname, join } from 'path'
|
||||
import { app } from 'electron'
|
||||
|
||||
// Database path in user data directory
|
||||
const getDbPath = () => join(app.getPath('userData'), 'openwork.sqlite')
|
||||
const getDbPath = (): string => join(app.getPath('userData'), 'openwork.sqlite')
|
||||
|
||||
let db: SqlJsDatabase | null = null
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
@@ -176,10 +176,7 @@ export function getThread(threadId: string): Thread | null {
|
||||
return thread
|
||||
}
|
||||
|
||||
export function createThread(
|
||||
threadId: string,
|
||||
metadata?: Record<string, unknown>
|
||||
): Thread {
|
||||
export function createThread(threadId: string, metadata?: Record<string, unknown>): Thread {
|
||||
const database = getDb()
|
||||
const now = Date.now()
|
||||
|
||||
@@ -217,7 +214,9 @@ export function updateThread(
|
||||
|
||||
if (updates.metadata !== undefined) {
|
||||
setClauses.push('metadata = ?')
|
||||
values.push(typeof updates.metadata === 'string' ? updates.metadata : JSON.stringify(updates.metadata))
|
||||
values.push(
|
||||
typeof updates.metadata === 'string' ? updates.metadata : JSON.stringify(updates.metadata)
|
||||
)
|
||||
}
|
||||
if (updates.status !== undefined) {
|
||||
setClauses.push('status = ?')
|
||||
@@ -234,10 +233,7 @@ export function updateThread(
|
||||
|
||||
values.push(threadId)
|
||||
|
||||
database.run(
|
||||
`UPDATE threads SET ${setClauses.join(', ')} WHERE thread_id = ?`,
|
||||
values
|
||||
)
|
||||
database.run(`UPDATE threads SET ${setClauses.join(', ')} WHERE thread_id = ?`, values)
|
||||
|
||||
saveToDisk()
|
||||
|
||||
|
||||
+189
-213
@@ -1,239 +1,215 @@
|
||||
import { IpcMain, BrowserWindow } from 'electron'
|
||||
import { HumanMessage } from '@langchain/core/messages'
|
||||
import { HumanMessage, BaseMessage, AIMessage, AIMessageChunk } from '@langchain/core/messages'
|
||||
import { createAgentRuntime } from '../agent/runtime'
|
||||
import type { HITLDecision, StreamEvent } from '../types'
|
||||
import type { HITLDecision } from '../types'
|
||||
|
||||
// Track active runs for cancellation
|
||||
const activeRuns = new Map<string, AbortController>()
|
||||
|
||||
export function registerAgentHandlers(ipcMain: IpcMain) {
|
||||
/**
|
||||
* Serialize a LangChain message to a plain object for IPC
|
||||
*/
|
||||
function serializeMessage(msg: BaseMessage): {
|
||||
id: string
|
||||
type: 'human' | 'ai' | 'tool' | 'system'
|
||||
content: string
|
||||
tool_calls?: Array<{ id: string; name: string; args: Record<string, unknown> }>
|
||||
} {
|
||||
// Extract content as string
|
||||
let content = ''
|
||||
if (typeof msg.content === 'string') {
|
||||
content = msg.content
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
content = msg.content
|
||||
.filter((block): block is { type: 'text'; text: string } => block.type === 'text')
|
||||
.map((block) => block.text)
|
||||
.join('')
|
||||
}
|
||||
|
||||
// Map LangChain message types
|
||||
const type =
|
||||
msg.type === 'human'
|
||||
? 'human'
|
||||
: msg.type === 'ai'
|
||||
? 'ai'
|
||||
: msg.type === 'tool'
|
||||
? 'tool'
|
||||
: 'system'
|
||||
|
||||
// Extract tool calls from AI messages
|
||||
const toolCalls =
|
||||
msg instanceof AIMessage && msg.tool_calls?.length
|
||||
? msg.tool_calls.map((tc) => ({
|
||||
id: tc.id || crypto.randomUUID(),
|
||||
name: tc.name,
|
||||
args: tc.args as Record<string, unknown>
|
||||
}))
|
||||
: undefined
|
||||
|
||||
return {
|
||||
id: msg.id || crypto.randomUUID(),
|
||||
type,
|
||||
content,
|
||||
tool_calls: toolCalls
|
||||
}
|
||||
}
|
||||
|
||||
export function registerAgentHandlers(ipcMain: IpcMain): void {
|
||||
console.log('[Agent] Registering agent handlers...')
|
||||
|
||||
|
||||
// Handle agent invocation with streaming
|
||||
ipcMain.on('agent:invoke', async (event, { threadId, message }: { threadId: string; message: string }) => {
|
||||
const channel = `agent:stream:${threadId}`
|
||||
const window = BrowserWindow.fromWebContents(event.sender)
|
||||
|
||||
console.log('[Agent] Received invoke request:', { threadId, message: message.substring(0, 50) })
|
||||
|
||||
if (!window) {
|
||||
console.error('[Agent] No window found')
|
||||
return
|
||||
}
|
||||
ipcMain.on(
|
||||
'agent:invoke',
|
||||
async (event, { threadId, message }: { threadId: string; message: string }) => {
|
||||
const channel = `agent:stream:${threadId}`
|
||||
const window = BrowserWindow.fromWebContents(event.sender)
|
||||
|
||||
const abortController = new AbortController()
|
||||
activeRuns.set(threadId, abortController)
|
||||
console.log('[Agent] Received invoke request:', {
|
||||
threadId,
|
||||
message: message.substring(0, 50)
|
||||
})
|
||||
|
||||
try {
|
||||
console.log('[Agent] Creating runtime...')
|
||||
const agent = await createAgentRuntime()
|
||||
console.log('[Agent] Runtime created, starting stream...')
|
||||
|
||||
// Create proper HumanMessage
|
||||
const humanMessage = new HumanMessage(message)
|
||||
|
||||
// Track seen message IDs to avoid duplicates
|
||||
const seenMessageIds = new Set<string>()
|
||||
|
||||
// Stream with values mode to get full state after each step
|
||||
// Note: 'messages' mode was causing tool call corruption, so we stick with 'values'
|
||||
const stream = await agent.stream(
|
||||
{ messages: [humanMessage] },
|
||||
{
|
||||
configurable: { thread_id: threadId },
|
||||
signal: abortController.signal,
|
||||
streamMode: 'values',
|
||||
recursionLimit: 1000 // Match Python deepagents behavior
|
||||
}
|
||||
)
|
||||
console.log('[Agent] Stream started with streamMode: values')
|
||||
if (!window) {
|
||||
console.error('[Agent] No window found')
|
||||
return
|
||||
}
|
||||
|
||||
for await (const chunk of stream) {
|
||||
if (abortController.signal.aborted) break
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const state = chunk as any
|
||||
console.log('[Agent] Chunk keys:', Object.keys(state || {}))
|
||||
|
||||
// Process messages from state
|
||||
if (state.messages && Array.isArray(state.messages)) {
|
||||
for (const msg of state.messages) {
|
||||
const msgId = msg.id || crypto.randomUUID()
|
||||
|
||||
// Skip if we've already sent this message
|
||||
if (seenMessageIds.has(msgId)) continue
|
||||
|
||||
// Determine the role from the message type
|
||||
let role: 'user' | 'assistant' | 'system' | 'tool' = 'assistant'
|
||||
if (typeof msg._getType === 'function') {
|
||||
const msgType = msg._getType()
|
||||
if (msgType === 'human') role = 'user'
|
||||
else if (msgType === 'ai') role = 'assistant'
|
||||
else if (msgType === 'system') role = 'system'
|
||||
else if (msgType === 'tool') role = 'tool'
|
||||
}
|
||||
|
||||
// Extract content
|
||||
let content: string = ''
|
||||
if (typeof msg.content === 'string') {
|
||||
content = msg.content
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
content = msg.content
|
||||
.filter((block: { type?: string }) => block.type === 'text')
|
||||
.map((block: { text?: string }) => block.text || '')
|
||||
.join('')
|
||||
}
|
||||
|
||||
// Only send assistant messages with content
|
||||
if (role === 'assistant' && content) {
|
||||
seenMessageIds.add(msgId)
|
||||
|
||||
const streamEvent: StreamEvent = {
|
||||
type: 'message',
|
||||
message: {
|
||||
id: msgId,
|
||||
role,
|
||||
content,
|
||||
tool_calls: msg.tool_calls,
|
||||
created_at: new Date()
|
||||
}
|
||||
const abortController = new AbortController()
|
||||
activeRuns.set(threadId, abortController)
|
||||
|
||||
try {
|
||||
const agent = await createAgentRuntime()
|
||||
const humanMessage = new HumanMessage(message)
|
||||
|
||||
// Track state for deduplication
|
||||
const seenMessageIds = new Set<string>()
|
||||
let currentMessageId: string | null = null
|
||||
|
||||
// Stream with both modes:
|
||||
// - 'messages' for real-time token streaming
|
||||
// - 'values' for full state (todos, files, etc.)
|
||||
const stream = await agent.stream(
|
||||
{ messages: [humanMessage] },
|
||||
{
|
||||
configurable: { thread_id: threadId },
|
||||
signal: abortController.signal,
|
||||
streamMode: ['messages', 'values'],
|
||||
recursionLimit: 1000
|
||||
}
|
||||
)
|
||||
|
||||
for await (const chunk of stream) {
|
||||
if (abortController.signal.aborted) break
|
||||
|
||||
// With multiple stream modes, chunks are tuples: [mode, data]
|
||||
const [mode, data] = chunk as [string, unknown]
|
||||
|
||||
if (mode === 'messages') {
|
||||
// Messages mode returns [message, metadata] tuples
|
||||
const [msgChunk, metadata] = data as [AIMessageChunk, { langgraph_node?: string }]
|
||||
console.log('[Agent] Message chunk:', {
|
||||
type: msgChunk?.constructor?.name,
|
||||
node: metadata?.langgraph_node
|
||||
})
|
||||
|
||||
// Process AI message chunks (from any node that produces AI messages)
|
||||
if (msgChunk instanceof AIMessageChunk) {
|
||||
const content =
|
||||
typeof msgChunk.content === 'string'
|
||||
? msgChunk.content
|
||||
: Array.isArray(msgChunk.content)
|
||||
? msgChunk.content
|
||||
.filter((b): b is { type: 'text'; text: string } => b.type === 'text')
|
||||
.map((b) => b.text)
|
||||
.join('')
|
||||
: ''
|
||||
|
||||
if (content) {
|
||||
// Track message ID for grouping tokens
|
||||
const msgId = msgChunk.id || currentMessageId || crypto.randomUUID()
|
||||
currentMessageId = msgId
|
||||
|
||||
console.log('[Agent] Sending token:', content.substring(0, 50))
|
||||
window.webContents.send(channel, {
|
||||
type: 'token',
|
||||
messageId: msgId,
|
||||
token: content
|
||||
})
|
||||
}
|
||||
window.webContents.send(channel, streamEvent)
|
||||
console.log('[Agent] Sent message:', msgId.substring(0, 20))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for todos in agent state
|
||||
if (state.todos && Array.isArray(state.todos)) {
|
||||
const todosEvent: StreamEvent = {
|
||||
type: 'todos',
|
||||
todos: (state.todos as Array<{ id?: string; content?: string; status?: string }>).map((t) => ({
|
||||
id: t.id || crypto.randomUUID(),
|
||||
content: t.content || '',
|
||||
status: (t.status || 'pending') as 'pending' | 'in_progress' | 'completed' | 'cancelled'
|
||||
}))
|
||||
}
|
||||
window.webContents.send(channel, todosEvent)
|
||||
}
|
||||
|
||||
// Check for workspace/file state
|
||||
// deepagents stores files as Record<string, FileData> (object keyed by path)
|
||||
const filesObj = state.files as Record<string, { content?: string; lastModified?: number }> | undefined
|
||||
const workspacePath = (state.workspacePath as string) || process.cwd()
|
||||
|
||||
if (filesObj && typeof filesObj === 'object' && !Array.isArray(filesObj)) {
|
||||
// Convert object format to array format
|
||||
const files = Object.entries(filesObj).map(([filePath, data]) => ({
|
||||
path: filePath,
|
||||
is_dir: false,
|
||||
size: typeof data?.content === 'string' ? data.content.length : undefined
|
||||
}))
|
||||
|
||||
if (files.length > 0) {
|
||||
console.log('[Agent] Sending workspace event with', files.length, 'files')
|
||||
const workspaceEvent: StreamEvent = {
|
||||
type: 'workspace',
|
||||
files,
|
||||
path: workspacePath
|
||||
// Handle tool calls in the chunk
|
||||
if (msgChunk.tool_call_chunks?.length) {
|
||||
window.webContents.send(channel, {
|
||||
type: 'tool_call',
|
||||
messageId: currentMessageId,
|
||||
tool_calls: msgChunk.tool_call_chunks
|
||||
})
|
||||
}
|
||||
}
|
||||
window.webContents.send(channel, workspaceEvent)
|
||||
}
|
||||
} else if (Array.isArray(filesObj)) {
|
||||
// Handle legacy array format if present
|
||||
const files = (filesObj as Array<{ path: string; is_dir?: boolean; size?: number }>)
|
||||
if (files.length > 0) {
|
||||
const workspaceEvent: StreamEvent = {
|
||||
type: 'workspace',
|
||||
files: files.map((f) => ({
|
||||
path: f.path,
|
||||
is_dir: f.is_dir,
|
||||
size: f.size
|
||||
})),
|
||||
path: workspacePath
|
||||
} else if (mode === 'values') {
|
||||
// Values mode returns the full state
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const state = data as any
|
||||
|
||||
// Send complete messages (for final state)
|
||||
const messages = (state.messages as BaseMessage[] | undefined)
|
||||
?.filter((msg) => {
|
||||
const id = msg.id || ''
|
||||
if (seenMessageIds.has(id)) return false
|
||||
if (msg.type === 'ai' && msg.content) {
|
||||
seenMessageIds.add(id)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
.map(serializeMessage)
|
||||
|
||||
// Reset current message ID when we get a complete message
|
||||
if (messages?.length) {
|
||||
currentMessageId = null
|
||||
}
|
||||
window.webContents.send(channel, workspaceEvent)
|
||||
|
||||
window.webContents.send(channel, {
|
||||
type: 'values',
|
||||
data: {
|
||||
messages,
|
||||
todos: state.todos,
|
||||
files: state.files,
|
||||
workspacePath: state.workspacePath,
|
||||
subagents: state.subagents,
|
||||
interrupt: state.__interrupt__
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Check for subagents in agent state
|
||||
const subagentsRaw = state.subagents as Array<{
|
||||
id?: string
|
||||
name?: string
|
||||
type?: string
|
||||
description?: string
|
||||
status?: string
|
||||
startedAt?: Date | string
|
||||
completedAt?: Date | string
|
||||
}> | undefined
|
||||
|
||||
if (subagentsRaw && Array.isArray(subagentsRaw) && subagentsRaw.length > 0) {
|
||||
console.log('[Agent] Sending subagents event with', subagentsRaw.length, 'subagents')
|
||||
const subagentsEvent: StreamEvent = {
|
||||
type: 'subagents',
|
||||
subagents: subagentsRaw.map((s) => ({
|
||||
id: s.id || crypto.randomUUID(),
|
||||
name: s.name || s.type || 'Subagent',
|
||||
description: s.description || '',
|
||||
status: (s.status || 'pending') as 'pending' | 'running' | 'completed' | 'failed',
|
||||
startedAt: s.startedAt ? new Date(s.startedAt) : undefined,
|
||||
completedAt: s.completedAt ? new Date(s.completedAt) : undefined
|
||||
}))
|
||||
}
|
||||
window.webContents.send(channel, subagentsEvent)
|
||||
}
|
||||
|
||||
// Check for interrupts (HITL)
|
||||
const interrupt = state.__interrupt__ as { id?: string; tool_call?: unknown } | undefined
|
||||
if (interrupt) {
|
||||
const streamEvent: StreamEvent = {
|
||||
type: 'interrupt',
|
||||
request: {
|
||||
id: interrupt.id || crypto.randomUUID(),
|
||||
tool_call: interrupt.tool_call as { id: string; name: string; args: Record<string, unknown> },
|
||||
allowed_decisions: ['approve', 'reject', 'edit']
|
||||
}
|
||||
}
|
||||
window.webContents.send(channel, streamEvent)
|
||||
}
|
||||
// Send done event
|
||||
window.webContents.send(channel, { type: 'done' })
|
||||
} catch (error) {
|
||||
console.error('[Agent] Error:', error)
|
||||
window.webContents.send(channel, {
|
||||
type: 'error',
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
})
|
||||
} finally {
|
||||
activeRuns.delete(threadId)
|
||||
}
|
||||
|
||||
// Send done event
|
||||
console.log('[Agent] Stream complete, sending done event')
|
||||
const doneEvent: StreamEvent = { type: 'done', result: null }
|
||||
window.webContents.send(channel, doneEvent)
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Agent] Error:', error)
|
||||
const errorEvent: StreamEvent = {
|
||||
type: 'error',
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
}
|
||||
window.webContents.send(channel, errorEvent)
|
||||
} finally {
|
||||
activeRuns.delete(threadId)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// Handle HITL interrupt response
|
||||
ipcMain.handle('agent:interrupt', async (_event, { threadId, decision }: { threadId: string; decision: HITLDecision }) => {
|
||||
const agent = await createAgentRuntime()
|
||||
|
||||
// Get the current state
|
||||
const config = { configurable: { thread_id: threadId } }
|
||||
|
||||
// Resume with the decision
|
||||
if (decision.type === 'approve') {
|
||||
// Continue execution
|
||||
await agent.invoke(null, config)
|
||||
} else if (decision.type === 'reject') {
|
||||
// Cancel the tool call
|
||||
// The agent will handle this via Command
|
||||
} else if (decision.type === 'edit') {
|
||||
// Update the tool call args and continue
|
||||
// This requires updating state before resuming
|
||||
ipcMain.handle(
|
||||
'agent:interrupt',
|
||||
async (_event, { threadId, decision }: { threadId: string; decision: HITLDecision }) => {
|
||||
const agent = await createAgentRuntime()
|
||||
const config = { configurable: { thread_id: threadId } }
|
||||
|
||||
if (decision.type === 'approve') {
|
||||
await agent.invoke(null, config)
|
||||
}
|
||||
// reject and edit handled by Command in future
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// Handle cancellation
|
||||
ipcMain.handle('agent:cancel', async (_event, { threadId }: { threadId: string }) => {
|
||||
|
||||
Vendored
+6
@@ -16,6 +16,12 @@ interface ElectronAPI {
|
||||
interface CustomAPI {
|
||||
agent: {
|
||||
invoke: (threadId: string, message: string, onEvent: (event: StreamEvent) => void) => () => void
|
||||
streamAgent: (
|
||||
threadId: string,
|
||||
message: string,
|
||||
command: unknown,
|
||||
onEvent: (event: StreamEvent) => void
|
||||
) => () => void
|
||||
interrupt: (threadId: string, decision: HITLDecision) => Promise<void>
|
||||
cancel: (threadId: string) => Promise<void>
|
||||
}
|
||||
|
||||
+45
-8
@@ -25,28 +25,65 @@ const api = {
|
||||
agent: {
|
||||
// Send message and receive events via callback
|
||||
invoke: (
|
||||
threadId: string,
|
||||
message: string,
|
||||
threadId: string,
|
||||
message: string,
|
||||
onEvent: (event: StreamEvent) => void
|
||||
): (() => void) => {
|
||||
console.log('[Preload] invoke() called', { threadId, message: message.substring(0, 50) })
|
||||
|
||||
|
||||
const channel = `agent:stream:${threadId}`
|
||||
|
||||
const handler = (_: unknown, data: StreamEvent) => {
|
||||
|
||||
const handler = (_: unknown, data: StreamEvent): void => {
|
||||
console.log('[Preload] Received event:', data.type)
|
||||
onEvent(data)
|
||||
|
||||
|
||||
// Clean up listener on terminal events
|
||||
if (data.type === 'done' || data.type === 'error') {
|
||||
ipcRenderer.removeListener(channel, handler)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
ipcRenderer.on(channel, handler)
|
||||
console.log('[Preload] Sending agent:invoke IPC')
|
||||
ipcRenderer.send('agent:invoke', { threadId, message })
|
||||
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
ipcRenderer.removeListener(channel, handler)
|
||||
}
|
||||
},
|
||||
// Stream agent events for useStream transport
|
||||
streamAgent: (
|
||||
threadId: string,
|
||||
message: string,
|
||||
command: unknown,
|
||||
onEvent: (event: StreamEvent) => void
|
||||
): (() => void) => {
|
||||
console.log('[Preload] streamAgent() called', { threadId, message: message.substring(0, 50) })
|
||||
|
||||
const channel = `agent:stream:${threadId}`
|
||||
|
||||
const handler = (_: unknown, data: StreamEvent): void => {
|
||||
console.log('[Preload] Received stream event:', data.type)
|
||||
onEvent(data)
|
||||
|
||||
// Clean up listener on terminal events
|
||||
if (data.type === 'done' || data.type === 'error') {
|
||||
ipcRenderer.removeListener(channel, handler)
|
||||
}
|
||||
}
|
||||
|
||||
ipcRenderer.on(channel, handler)
|
||||
|
||||
// If we have a command, it might be a resume/retry
|
||||
if (command) {
|
||||
console.log('[Preload] Sending agent:resume IPC')
|
||||
ipcRenderer.send('agent:resume', { threadId, command })
|
||||
} else {
|
||||
console.log('[Preload] Sending agent:invoke IPC')
|
||||
ipcRenderer.send('agent:invoke', { threadId, message })
|
||||
}
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
ipcRenderer.removeListener(channel, handler)
|
||||
|
||||
+13
-10
@@ -4,18 +4,21 @@ import { ChatContainer } from '@/components/chat/ChatContainer'
|
||||
import { RightPanel } from '@/components/panels/RightPanel'
|
||||
import { useAppStore } from '@/lib/store'
|
||||
|
||||
function App() {
|
||||
function App(): React.JSX.Element {
|
||||
const { currentThreadId, loadThreads, createThread, setSettingsOpen } = useAppStore()
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
// Keyboard shortcuts
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
// Cmd+, for settings
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === ',') {
|
||||
e.preventDefault()
|
||||
setSettingsOpen(true)
|
||||
}
|
||||
}, [setSettingsOpen])
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
// Cmd+, for settings
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === ',') {
|
||||
e.preventDefault()
|
||||
setSettingsOpen(true)
|
||||
}
|
||||
},
|
||||
[setSettingsOpen]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
@@ -23,7 +26,7 @@ function App() {
|
||||
}, [handleKeyDown])
|
||||
|
||||
useEffect(() => {
|
||||
async function init() {
|
||||
async function init(): Promise<void> {
|
||||
try {
|
||||
await loadThreads()
|
||||
// Create a default thread if none exist
|
||||
@@ -52,7 +55,7 @@ function App() {
|
||||
<div className="flex flex-col h-screen overflow-hidden bg-background">
|
||||
{/* Draggable titlebar region */}
|
||||
<div className="h-8 w-full shrink-0 app-drag-region bg-sidebar" />
|
||||
|
||||
|
||||
{/* Main content area */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Left Sidebar - Thread List */}
|
||||
|
||||
@@ -1,54 +1,251 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useState, useRef, useEffect, useMemo, useCallback } from 'react'
|
||||
import { Send, Square, Loader2 } from 'lucide-react'
|
||||
import { useStream } from '@langchain/langgraph-sdk/react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { useAppStore } from '@/lib/store'
|
||||
import { MessageBubble } from './MessageBubble'
|
||||
import { ApprovalDialog } from '@/components/hitl/ApprovalDialog'
|
||||
import { ElectronIPCTransport } from '@/lib/electron-transport'
|
||||
import type { Message } from '@/types'
|
||||
|
||||
interface ChatContainerProps {
|
||||
threadId: string
|
||||
}
|
||||
|
||||
export function ChatContainer({ threadId }: ChatContainerProps) {
|
||||
// Define custom event data types
|
||||
interface TodoEventData {
|
||||
id?: string
|
||||
content?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
interface FileEventData {
|
||||
path: string
|
||||
is_dir?: boolean
|
||||
size?: number
|
||||
}
|
||||
|
||||
interface SubagentEventData {
|
||||
id?: string
|
||||
name?: string
|
||||
description?: string
|
||||
status?: string
|
||||
startedAt?: Date
|
||||
completedAt?: Date
|
||||
}
|
||||
|
||||
interface MessageEventData {
|
||||
id?: string
|
||||
type?: string
|
||||
role?: string
|
||||
content?: string
|
||||
tool_calls?: unknown[]
|
||||
created_at?: Date
|
||||
}
|
||||
|
||||
interface CustomEventData {
|
||||
type?: string
|
||||
message?: MessageEventData
|
||||
todos?: TodoEventData[]
|
||||
files?: FileEventData[]
|
||||
path?: string
|
||||
subagents?: SubagentEventData[]
|
||||
request?: unknown
|
||||
}
|
||||
|
||||
export function ChatContainer({ threadId }: ChatContainerProps): React.JSX.Element {
|
||||
const [input, setInput] = useState('')
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const {
|
||||
messages,
|
||||
isThreadStreaming,
|
||||
getStreamingContent,
|
||||
|
||||
const {
|
||||
messages: storeMessages,
|
||||
pendingApproval,
|
||||
sendMessage
|
||||
setTodos,
|
||||
setWorkspaceFiles,
|
||||
setWorkspacePath,
|
||||
setSubagents,
|
||||
setPendingApproval,
|
||||
appendMessage,
|
||||
loadThreads,
|
||||
generateTitleForFirstMessage
|
||||
} = useAppStore()
|
||||
|
||||
// Get streaming state for this specific thread
|
||||
const isStreaming = isThreadStreaming(threadId)
|
||||
const streamingContent = getStreamingContent(threadId)
|
||||
|
||||
// Create transport instance (memoized to avoid recreating)
|
||||
const transport = useMemo(() => new ElectronIPCTransport(), [])
|
||||
|
||||
// Handle custom events from the stream
|
||||
const handleCustomEvent = useCallback(
|
||||
(data: CustomEventData): void => {
|
||||
console.log('[ChatContainer] Custom event:', data)
|
||||
switch (data.type) {
|
||||
case 'message':
|
||||
if (data.message) {
|
||||
const msg = data.message
|
||||
const storeMsg: Message = {
|
||||
id: msg.id || crypto.randomUUID(),
|
||||
role:
|
||||
msg.role === 'user' || msg.type === 'human'
|
||||
? 'user'
|
||||
: msg.role === 'assistant' || msg.type === 'ai'
|
||||
? 'assistant'
|
||||
: msg.role === 'tool' || msg.type === 'tool'
|
||||
? 'tool'
|
||||
: 'system',
|
||||
content: msg.content || '',
|
||||
tool_calls: msg.tool_calls as Message['tool_calls'],
|
||||
created_at: msg.created_at ? new Date(msg.created_at) : new Date()
|
||||
}
|
||||
console.log('[ChatContainer] Adding message:', storeMsg)
|
||||
appendMessage(storeMsg)
|
||||
}
|
||||
break
|
||||
case 'todos':
|
||||
if (Array.isArray(data.todos)) {
|
||||
setTodos(
|
||||
data.todos.map((t) => ({
|
||||
id: t.id || crypto.randomUUID(),
|
||||
content: t.content || '',
|
||||
status: (t.status || 'pending') as
|
||||
| 'pending'
|
||||
| 'in_progress'
|
||||
| 'completed'
|
||||
| 'cancelled'
|
||||
}))
|
||||
)
|
||||
}
|
||||
break
|
||||
case 'workspace':
|
||||
if (Array.isArray(data.files)) {
|
||||
setWorkspaceFiles(
|
||||
data.files.map((f) => ({
|
||||
path: f.path,
|
||||
is_dir: f.is_dir,
|
||||
size: f.size
|
||||
}))
|
||||
)
|
||||
}
|
||||
if (data.path) {
|
||||
setWorkspacePath(data.path)
|
||||
}
|
||||
break
|
||||
case 'subagents':
|
||||
if (Array.isArray(data.subagents)) {
|
||||
setSubagents(
|
||||
data.subagents.map((s) => ({
|
||||
id: s.id || crypto.randomUUID(),
|
||||
name: s.name || 'Subagent',
|
||||
description: s.description || '',
|
||||
status: (s.status || 'pending') as 'pending' | 'running' | 'completed' | 'failed',
|
||||
startedAt: s.startedAt,
|
||||
completedAt: s.completedAt
|
||||
}))
|
||||
)
|
||||
}
|
||||
break
|
||||
case 'interrupt':
|
||||
if (data.request) {
|
||||
setPendingApproval(data.request as Parameters<typeof setPendingApproval>[0])
|
||||
}
|
||||
break
|
||||
}
|
||||
},
|
||||
[setTodos, setWorkspaceFiles, setWorkspacePath, setSubagents, setPendingApproval, appendMessage]
|
||||
)
|
||||
|
||||
// Use the useStream hook with our custom transport
|
||||
const stream = useStream({
|
||||
transport,
|
||||
threadId,
|
||||
messagesKey: 'messages',
|
||||
onCustomEvent: (data): void => {
|
||||
handleCustomEvent(data as CustomEventData)
|
||||
},
|
||||
onError: (error): void => {
|
||||
console.error('[ChatContainer] Stream error:', error)
|
||||
}
|
||||
})
|
||||
console.log('[ChatContainer] Stream:', stream.messages)
|
||||
|
||||
// Refresh threads when loading state changes from true to false (stream completed)
|
||||
const prevLoadingRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (prevLoadingRef.current && !stream.isLoading) {
|
||||
// Stream just completed
|
||||
loadThreads()
|
||||
}
|
||||
prevLoadingRef.current = stream.isLoading
|
||||
}, [stream.isLoading, loadThreads])
|
||||
|
||||
// Combine store messages with streaming messages
|
||||
const displayMessages = useMemo(() => {
|
||||
// Get IDs of messages already in the store
|
||||
const storeMessageIds = new Set(storeMessages.map((m) => m.id))
|
||||
|
||||
// Get streaming messages that aren't in the store yet
|
||||
const streamingMsgs: Message[] = (stream.messages || [])
|
||||
.filter((m): m is typeof m & { id: string } => !!m.id && !storeMessageIds.has(m.id))
|
||||
.map((m) => ({
|
||||
id: m.id,
|
||||
role: (m.type === 'human' ? 'user' : 'assistant') as Message['role'],
|
||||
content: typeof m.content === 'string' ? m.content : '',
|
||||
created_at: new Date()
|
||||
}))
|
||||
|
||||
return [...storeMessages, ...streamingMsgs]
|
||||
}, [storeMessages, stream.messages])
|
||||
|
||||
// Auto-scroll on new messages
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
||||
}
|
||||
}, [messages, streamingContent, threadId])
|
||||
}, [displayMessages, stream.isLoading, threadId])
|
||||
|
||||
// Focus input on mount
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus()
|
||||
}, [threadId])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
const handleSubmit = async (e: React.FormEvent): Promise<void> => {
|
||||
e.preventDefault()
|
||||
if (!input.trim() || isStreaming) return
|
||||
if (!input.trim() || stream.isLoading) return
|
||||
|
||||
const message = input.trim()
|
||||
setInput('')
|
||||
await sendMessage(message)
|
||||
|
||||
// Check if this is the first message (for title generation)
|
||||
const isFirstMessage = storeMessages.length === 0
|
||||
|
||||
// Add user message to store immediately
|
||||
const userMessage: Message = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
content: message,
|
||||
created_at: new Date()
|
||||
}
|
||||
appendMessage(userMessage)
|
||||
|
||||
// Generate title for first message
|
||||
if (isFirstMessage) {
|
||||
generateTitleForFirstMessage(threadId, message)
|
||||
}
|
||||
|
||||
// Submit via useStream
|
||||
await stream.submit(
|
||||
{
|
||||
messages: [{ type: 'human', content: message }]
|
||||
},
|
||||
{
|
||||
config: {
|
||||
configurable: { thread_id: threadId }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
const handleKeyDown = (e: React.KeyboardEvent): void => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSubmit(e)
|
||||
@@ -56,7 +253,7 @@ export function ChatContainer({ threadId }: ChatContainerProps) {
|
||||
}
|
||||
|
||||
// Auto-resize textarea based on content
|
||||
const adjustTextareaHeight = () => {
|
||||
const adjustTextareaHeight = (): void => {
|
||||
const textarea = inputRef.current
|
||||
if (textarea) {
|
||||
textarea.style.height = 'auto'
|
||||
@@ -68,8 +265,8 @@ export function ChatContainer({ threadId }: ChatContainerProps) {
|
||||
adjustTextareaHeight()
|
||||
}, [input])
|
||||
|
||||
const handleCancel = async () => {
|
||||
await window.api.agent.cancel(threadId)
|
||||
const handleCancel = async (): Promise<void> => {
|
||||
await stream.stop()
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -78,36 +275,24 @@ export function ChatContainer({ threadId }: ChatContainerProps) {
|
||||
<ScrollArea className="flex-1 min-h-0" ref={scrollRef}>
|
||||
<div className="p-4">
|
||||
<div className="max-w-3xl mx-auto space-y-4">
|
||||
{messages.length === 0 && !isStreaming && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<div className="text-section-header mb-2">NEW THREAD</div>
|
||||
<div className="text-sm">Start a conversation with the agent</div>
|
||||
</div>
|
||||
)}
|
||||
{displayMessages.length === 0 && !stream.isLoading && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<div className="text-section-header mb-2">NEW THREAD</div>
|
||||
<div className="text-sm">Start a conversation with the agent</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((message) => (
|
||||
<MessageBubble key={message.id} message={message} />
|
||||
))}
|
||||
{displayMessages.map((message) => (
|
||||
<MessageBubble key={message.id} message={message} />
|
||||
))}
|
||||
|
||||
{/* Streaming indicator */}
|
||||
{isStreaming && streamingContent && (
|
||||
<MessageBubble
|
||||
message={{
|
||||
id: 'streaming',
|
||||
role: 'assistant',
|
||||
content: streamingContent,
|
||||
created_at: new Date()
|
||||
}}
|
||||
isStreaming
|
||||
/>
|
||||
)}
|
||||
|
||||
{isStreaming && !streamingContent && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground text-sm">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Agent is thinking...
|
||||
</div>
|
||||
)}
|
||||
{/* Streaming indicator - only show if no streaming messages yet */}
|
||||
{stream.isLoading && stream.messages.length === 0 && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground text-sm">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Agent is thinking...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
@@ -122,28 +307,18 @@ export function ChatContainer({ threadId }: ChatContainerProps) {
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Message..."
|
||||
disabled={isStreaming}
|
||||
disabled={stream.isLoading}
|
||||
className="flex-1 min-w-0 resize-none rounded-sm border border-border bg-background px-4 py-3 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:opacity-50"
|
||||
rows={1}
|
||||
style={{ minHeight: '48px', maxHeight: '200px' }}
|
||||
/>
|
||||
<div className="flex items-center justify-center shrink-0 h-12">
|
||||
{isStreaming ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
{stream.isLoading ? (
|
||||
<Button type="button" variant="ghost" size="icon" onClick={handleCancel}>
|
||||
<Square className="size-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="submit"
|
||||
variant="default"
|
||||
size="icon"
|
||||
disabled={!input.trim()}
|
||||
>
|
||||
<Button type="submit" variant="default" size="icon" disabled={!input.trim()}>
|
||||
<Send className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { Plus, MessageSquare, Trash2, Settings, Pencil, Loader2 } from 'lucide-react'
|
||||
import { Plus, MessageSquare, Trash2, Settings, Pencil } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
@@ -12,47 +12,46 @@ import {
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuTrigger
|
||||
} from '@/components/ui/context-menu'
|
||||
|
||||
// Get version from package.json (injected at build time or via preload)
|
||||
const APP_VERSION = '0.1.0'
|
||||
|
||||
export function ThreadSidebar() {
|
||||
const {
|
||||
threads,
|
||||
currentThreadId,
|
||||
createThread,
|
||||
selectThread,
|
||||
export function ThreadSidebar(): React.JSX.Element {
|
||||
const {
|
||||
threads,
|
||||
currentThreadId,
|
||||
createThread,
|
||||
selectThread,
|
||||
deleteThread,
|
||||
updateThread,
|
||||
settingsOpen,
|
||||
setSettingsOpen,
|
||||
streamingThreads
|
||||
settingsOpen,
|
||||
setSettingsOpen
|
||||
} = useAppStore()
|
||||
|
||||
|
||||
const [editingThreadId, setEditingThreadId] = useState<string | null>(null)
|
||||
const [editingTitle, setEditingTitle] = useState('')
|
||||
|
||||
const startEditing = (threadId: string, currentTitle: string) => {
|
||||
|
||||
const startEditing = (threadId: string, currentTitle: string): void => {
|
||||
setEditingThreadId(threadId)
|
||||
setEditingTitle(currentTitle || '')
|
||||
}
|
||||
|
||||
const saveTitle = async () => {
|
||||
|
||||
const saveTitle = async (): Promise<void> => {
|
||||
if (editingThreadId && editingTitle.trim()) {
|
||||
await updateThread(editingThreadId, { title: editingTitle.trim() })
|
||||
}
|
||||
setEditingThreadId(null)
|
||||
setEditingTitle('')
|
||||
}
|
||||
|
||||
const cancelEditing = () => {
|
||||
|
||||
const cancelEditing = (): void => {
|
||||
setEditingThreadId(null)
|
||||
setEditingTitle('')
|
||||
}
|
||||
|
||||
const handleNewThread = async () => {
|
||||
const handleNewThread = async (): Promise<void> => {
|
||||
await createThread({ title: `Thread ${new Date().toLocaleDateString()}` })
|
||||
}
|
||||
|
||||
@@ -62,9 +61,7 @@ export function ThreadSidebar() {
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-section-header tracking-wider">OPENWORK</span>
|
||||
<span className="text-[10px] text-muted-foreground font-mono">
|
||||
{APP_VERSION}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground font-mono">{APP_VERSION}</span>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon-sm" onClick={handleNewThread} title="New thread">
|
||||
<Plus className="size-4" />
|
||||
@@ -81,10 +78,10 @@ export function ThreadSidebar() {
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"group flex items-center gap-2 rounded-sm px-3 py-2 cursor-pointer transition-colors overflow-hidden",
|
||||
'group flex items-center gap-2 rounded-sm px-3 py-2 cursor-pointer transition-colors overflow-hidden',
|
||||
currentThreadId === thread.thread_id
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
||||
: "hover:bg-sidebar-accent/50"
|
||||
? 'bg-sidebar-accent text-sidebar-accent-fore ground'
|
||||
: 'hover:bg-sidebar-accent/50'
|
||||
)}
|
||||
onClick={() => {
|
||||
if (editingThreadId !== thread.thread_id) {
|
||||
@@ -92,11 +89,7 @@ export function ThreadSidebar() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
{streamingThreads.has(thread.thread_id) ? (
|
||||
<Loader2 className="size-4 shrink-0 text-status-info animate-spin" />
|
||||
) : (
|
||||
<MessageSquare className="size-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<MessageSquare className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
{editingThreadId === thread.thread_id ? (
|
||||
<input
|
||||
@@ -137,14 +130,12 @@ export function ThreadSidebar() {
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
onClick={() => startEditing(thread.thread_id, thread.title || '')}
|
||||
>
|
||||
<ContextMenuItem onClick={() => startEditing(thread.thread_id, thread.title || '')}>
|
||||
<Pencil className="size-4 mr-2" />
|
||||
Rename
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
<ContextMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => deleteThread(thread.thread_id)}
|
||||
>
|
||||
@@ -168,10 +159,10 @@ export function ThreadSidebar() {
|
||||
{/* Model Selector */}
|
||||
<div className="p-4 space-y-4">
|
||||
<ModelSelector />
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start gap-2"
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import type { UseStreamTransport } from '@langchain/langgraph-sdk/react'
|
||||
import type { StreamPayload, StreamEvent, IPCEvent } from '../../../types'
|
||||
|
||||
/**
|
||||
* Custom transport for useStream that uses Electron IPC instead of HTTP.
|
||||
* This allows useStream to work seamlessly in an Electron app where the
|
||||
* LangGraph agent runs in the main process.
|
||||
*/
|
||||
export class ElectronIPCTransport implements UseStreamTransport {
|
||||
async stream(payload: StreamPayload): Promise<AsyncGenerator<StreamEvent>> {
|
||||
// Extract thread ID from config
|
||||
const threadId = payload.config?.configurable?.thread_id
|
||||
if (!threadId) {
|
||||
return this.createErrorGenerator('MISSING_THREAD_ID', 'Thread ID is required')
|
||||
}
|
||||
|
||||
// Extract the message content from input
|
||||
const input = payload.input as
|
||||
| { messages?: Array<{ content: string; type: string }> }
|
||||
| null
|
||||
| undefined
|
||||
const messages = input?.messages ?? []
|
||||
const lastHumanMessage = messages.find((m) => m.type === 'human')
|
||||
const messageContent = lastHumanMessage?.content ?? ''
|
||||
|
||||
if (!messageContent) {
|
||||
return this.createErrorGenerator('MISSING_MESSAGE', 'Message content is required')
|
||||
}
|
||||
|
||||
// Create an async generator that bridges IPC events
|
||||
return this.createStreamGenerator(threadId, messageContent, payload.command, payload.signal)
|
||||
}
|
||||
|
||||
private async *createErrorGenerator(code: string, message: string): AsyncGenerator<StreamEvent> {
|
||||
yield {
|
||||
event: 'error',
|
||||
data: { error: code, message }
|
||||
}
|
||||
}
|
||||
|
||||
private async *createStreamGenerator(
|
||||
threadId: string,
|
||||
message: string,
|
||||
command: unknown,
|
||||
signal: AbortSignal
|
||||
): AsyncGenerator<StreamEvent> {
|
||||
// Create a queue to buffer events from IPC
|
||||
const eventQueue: StreamEvent[] = []
|
||||
let resolveNext: ((value: StreamEvent | null) => void) | null = null
|
||||
let isDone = false
|
||||
let hasError = false
|
||||
|
||||
// Generate a run ID for this stream
|
||||
const runId = crypto.randomUUID()
|
||||
|
||||
// Emit metadata event first to establish run context
|
||||
yield {
|
||||
event: 'metadata',
|
||||
data: {
|
||||
run_id: runId,
|
||||
thread_id: threadId
|
||||
}
|
||||
}
|
||||
|
||||
// Start the stream via IPC
|
||||
const cleanup = window.api.agent.streamAgent(threadId, message, command, (ipcEvent) => {
|
||||
// Convert IPC events to SDK format
|
||||
const sdkEvents = this.convertToSDKEvents(ipcEvent as IPCEvent, threadId)
|
||||
|
||||
for (const sdkEvent of sdkEvents) {
|
||||
console.log('[Transport] Converted event:', sdkEvent)
|
||||
|
||||
if (sdkEvent.event === 'done' || sdkEvent.event === 'error') {
|
||||
isDone = true
|
||||
hasError = sdkEvent.event === 'error'
|
||||
}
|
||||
|
||||
// If someone is waiting for the next event, resolve immediately
|
||||
if (resolveNext) {
|
||||
const resolve = resolveNext
|
||||
resolveNext = null
|
||||
resolve(sdkEvent)
|
||||
} else {
|
||||
// Otherwise queue the event
|
||||
eventQueue.push(sdkEvent)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Handle abort signal
|
||||
if (signal) {
|
||||
signal.addEventListener('abort', () => {
|
||||
cleanup()
|
||||
isDone = true
|
||||
if (resolveNext) {
|
||||
const resolve = resolveNext
|
||||
resolveNext = null
|
||||
resolve(null)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Yield events as they come in
|
||||
while (!isDone || eventQueue.length > 0) {
|
||||
// Check for queued events first
|
||||
if (eventQueue.length > 0) {
|
||||
const event = eventQueue.shift()!
|
||||
if (event.event === 'done') {
|
||||
break
|
||||
}
|
||||
if (event.event !== 'error' || hasError) {
|
||||
yield event
|
||||
}
|
||||
if (hasError) {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Wait for the next event
|
||||
const event = await new Promise<StreamEvent | null>((resolve) => {
|
||||
resolveNext = resolve
|
||||
})
|
||||
|
||||
if (event === null) {
|
||||
break
|
||||
}
|
||||
|
||||
if (event.event === 'done') {
|
||||
break
|
||||
}
|
||||
|
||||
yield event
|
||||
|
||||
if (event.event === 'error') {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert IPC events to LangGraph SDK format
|
||||
* Returns an array since a single IPC event may produce multiple SDK events
|
||||
*/
|
||||
private convertToSDKEvents(event: IPCEvent, threadId: string): StreamEvent[] {
|
||||
const events: StreamEvent[] = []
|
||||
|
||||
switch (event.type) {
|
||||
// Token streaming for real-time typing effect
|
||||
case 'token':
|
||||
events.push({
|
||||
event: 'messages',
|
||||
data: [
|
||||
{ id: event.messageId, type: 'ai', content: event.token },
|
||||
{ langgraph_node: 'agent' }
|
||||
]
|
||||
})
|
||||
break
|
||||
|
||||
// Tool call chunks
|
||||
case 'tool_call':
|
||||
events.push({
|
||||
event: 'custom',
|
||||
data: {
|
||||
type: 'tool_call',
|
||||
messageId: event.messageId,
|
||||
tool_calls: event.tool_calls
|
||||
}
|
||||
})
|
||||
break
|
||||
|
||||
// Full state values
|
||||
case 'values': {
|
||||
const { messages, todos, files, workspacePath, subagents, interrupt } = event.data
|
||||
|
||||
// Emit complete messages
|
||||
if (messages?.length) {
|
||||
for (const msg of messages) {
|
||||
if (msg.type === 'ai' && msg.content) {
|
||||
events.push({
|
||||
event: 'custom',
|
||||
data: {
|
||||
type: 'message',
|
||||
message: {
|
||||
id: msg.id,
|
||||
type: msg.type,
|
||||
content: msg.content,
|
||||
tool_calls: msg.tool_calls
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Emit todos
|
||||
if (todos?.length) {
|
||||
events.push({
|
||||
event: 'custom',
|
||||
data: { type: 'todos', todos }
|
||||
})
|
||||
}
|
||||
|
||||
// Emit files/workspace
|
||||
if (files) {
|
||||
const filesList = Array.isArray(files)
|
||||
? files
|
||||
: Object.entries(files).map(([path, data]) => ({
|
||||
path,
|
||||
is_dir: false,
|
||||
size:
|
||||
typeof (data as { content?: string })?.content === 'string'
|
||||
? (data as { content: string }).content.length
|
||||
: undefined
|
||||
}))
|
||||
|
||||
if (filesList.length) {
|
||||
events.push({
|
||||
event: 'custom',
|
||||
data: { type: 'workspace', files: filesList, path: workspacePath || '/' }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Emit subagents
|
||||
if (subagents?.length) {
|
||||
events.push({
|
||||
event: 'custom',
|
||||
data: { type: 'subagents', subagents }
|
||||
})
|
||||
}
|
||||
|
||||
// Emit interrupt
|
||||
if (interrupt) {
|
||||
events.push({
|
||||
event: 'custom',
|
||||
data: {
|
||||
type: 'interrupt',
|
||||
request: {
|
||||
id: interrupt.id || crypto.randomUUID(),
|
||||
tool_call: interrupt.tool_call,
|
||||
allowed_decisions: ['approve', 'reject', 'edit']
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'error':
|
||||
events.push({
|
||||
event: 'error',
|
||||
data: { error: 'STREAM_ERROR', message: event.error }
|
||||
})
|
||||
break
|
||||
|
||||
case 'done':
|
||||
events.push({
|
||||
event: 'done',
|
||||
data: { thread_id: threadId }
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
return events
|
||||
}
|
||||
}
|
||||
+72
-209
@@ -5,83 +5,75 @@ interface AppState {
|
||||
// Threads
|
||||
threads: Thread[]
|
||||
currentThreadId: string | null
|
||||
|
||||
|
||||
// Messages for current thread
|
||||
messages: Message[]
|
||||
|
||||
// Streaming state - per-thread to allow concurrent runs
|
||||
streamingThreads: Set<string>
|
||||
streamingContent: Record<string, string> // threadId -> content
|
||||
|
||||
|
||||
// HITL state
|
||||
pendingApproval: HITLRequest | null
|
||||
|
||||
|
||||
// Todos (from agent)
|
||||
todos: Todo[]
|
||||
|
||||
|
||||
// Workspace files (from agent)
|
||||
workspaceFiles: FileInfo[]
|
||||
workspacePath: string | null
|
||||
|
||||
|
||||
// Subagents (from agent)
|
||||
subagents: Subagent[]
|
||||
|
||||
|
||||
// Models
|
||||
models: ModelConfig[]
|
||||
currentModel: string
|
||||
|
||||
|
||||
// Right panel state
|
||||
rightPanelTab: 'todos' | 'files' | 'subagents'
|
||||
|
||||
|
||||
// Settings dialog state
|
||||
settingsOpen: boolean
|
||||
|
||||
|
||||
// Sidebar state
|
||||
sidebarCollapsed: boolean
|
||||
|
||||
|
||||
// Actions
|
||||
loadThreads: () => Promise<void>
|
||||
createThread: (metadata?: Record<string, unknown>) => Promise<Thread>
|
||||
selectThread: (threadId: string) => Promise<void>
|
||||
deleteThread: (threadId: string) => Promise<void>
|
||||
updateThread: (threadId: string, updates: Partial<Thread>) => Promise<void>
|
||||
|
||||
|
||||
// Message actions
|
||||
sendMessage: (content: string) => Promise<void>
|
||||
appendMessage: (message: Message) => void
|
||||
setMessages: (messages: Message[]) => void
|
||||
|
||||
// Streaming actions
|
||||
isThreadStreaming: (threadId: string) => boolean
|
||||
getStreamingContent: (threadId: string) => string
|
||||
setThreadStreaming: (threadId: string, streaming: boolean) => void
|
||||
appendStreamingContent: (threadId: string, content: string) => void
|
||||
clearStreamingContent: (threadId: string) => void
|
||||
|
||||
generateTitleForFirstMessage: (threadId: string, content: string) => Promise<void>
|
||||
|
||||
// HITL actions
|
||||
setPendingApproval: (request: HITLRequest | null) => void
|
||||
respondToApproval: (decision: 'approve' | 'reject' | 'edit', editedArgs?: Record<string, unknown>) => Promise<void>
|
||||
|
||||
respondToApproval: (
|
||||
decision: 'approve' | 'reject' | 'edit',
|
||||
editedArgs?: Record<string, unknown>
|
||||
) => Promise<void>
|
||||
|
||||
// Todo actions
|
||||
setTodos: (todos: Todo[]) => void
|
||||
|
||||
|
||||
// Workspace actions
|
||||
setWorkspaceFiles: (files: FileInfo[]) => void
|
||||
setWorkspacePath: (path: string | null) => void
|
||||
|
||||
|
||||
// Subagent actions
|
||||
setSubagents: (subagents: Subagent[]) => void
|
||||
|
||||
|
||||
// Model actions
|
||||
loadModels: () => Promise<void>
|
||||
setCurrentModel: (modelId: string) => Promise<void>
|
||||
|
||||
|
||||
// Panel actions
|
||||
setRightPanelTab: (tab: 'todos' | 'files' | 'subagents') => void
|
||||
|
||||
|
||||
// Settings actions
|
||||
setSettingsOpen: (open: boolean) => void
|
||||
|
||||
|
||||
// Sidebar actions
|
||||
toggleSidebar: () => void
|
||||
setSidebarCollapsed: (collapsed: boolean) => void
|
||||
@@ -92,8 +84,6 @@ export const useAppStore = create<AppState>((set, get) => ({
|
||||
threads: [],
|
||||
currentThreadId: null,
|
||||
messages: [],
|
||||
streamingThreads: new Set<string>(),
|
||||
streamingContent: {},
|
||||
pendingApproval: null,
|
||||
todos: [],
|
||||
workspaceFiles: [],
|
||||
@@ -109,7 +99,7 @@ export const useAppStore = create<AppState>((set, get) => ({
|
||||
loadThreads: async () => {
|
||||
const threads = await window.api.threads.list()
|
||||
set({ threads })
|
||||
|
||||
|
||||
// Select first thread if none selected
|
||||
if (!get().currentThreadId && threads.length > 0) {
|
||||
await get().selectThread(threads[0].thread_id)
|
||||
@@ -118,7 +108,7 @@ export const useAppStore = create<AppState>((set, get) => ({
|
||||
|
||||
createThread: async (metadata?: Record<string, unknown>) => {
|
||||
const thread = await window.api.threads.create(metadata)
|
||||
set(state => ({
|
||||
set((state) => ({
|
||||
threads: [thread, ...state.threads],
|
||||
currentThreadId: thread.thread_id,
|
||||
messages: []
|
||||
@@ -127,12 +117,19 @@ export const useAppStore = create<AppState>((set, get) => ({
|
||||
},
|
||||
|
||||
selectThread: async (threadId: string) => {
|
||||
set({ currentThreadId: threadId, messages: [], todos: [], workspaceFiles: [], workspacePath: null, subagents: [] })
|
||||
|
||||
set({
|
||||
currentThreadId: threadId,
|
||||
messages: [],
|
||||
todos: [],
|
||||
workspaceFiles: [],
|
||||
workspacePath: null,
|
||||
subagents: []
|
||||
})
|
||||
|
||||
// Load thread history from checkpoints
|
||||
try {
|
||||
const history = await window.api.threads.getHistory(threadId)
|
||||
|
||||
|
||||
// Get the most recent checkpoint (first in the list since it's ordered DESC)
|
||||
if (history.length > 0) {
|
||||
const latestCheckpoint = history[0] as {
|
||||
@@ -153,9 +150,9 @@ export const useAppStore = create<AppState>((set, get) => ({
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const channelValues = latestCheckpoint.checkpoint?.channel_values
|
||||
|
||||
|
||||
// Extract messages
|
||||
if (channelValues?.messages && Array.isArray(channelValues.messages)) {
|
||||
const messages: Message[] = channelValues.messages.map((msg, index) => {
|
||||
@@ -173,7 +170,7 @@ export const useAppStore = create<AppState>((set, get) => ({
|
||||
else if (msg.type === 'system') role = 'system'
|
||||
else if (msg.type === 'tool') role = 'tool'
|
||||
}
|
||||
|
||||
|
||||
// Handle content - could be string or array of content blocks
|
||||
let content: Message['content'] = ''
|
||||
if (typeof msg.content === 'string') {
|
||||
@@ -181,7 +178,7 @@ export const useAppStore = create<AppState>((set, get) => ({
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
content = msg.content as Message['content']
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
id: msg.id || `msg-${index}`,
|
||||
role,
|
||||
@@ -190,10 +187,10 @@ export const useAppStore = create<AppState>((set, get) => ({
|
||||
created_at: new Date()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
set({ messages })
|
||||
}
|
||||
|
||||
|
||||
// Extract todos if present
|
||||
if (channelValues?.todos && Array.isArray(channelValues.todos)) {
|
||||
const todos: Todo[] = channelValues.todos.map((todo, index) => ({
|
||||
@@ -201,7 +198,7 @@ export const useAppStore = create<AppState>((set, get) => ({
|
||||
content: todo.content || '',
|
||||
status: (todo.status as Todo['status']) || 'pending'
|
||||
}))
|
||||
|
||||
|
||||
set({ todos })
|
||||
}
|
||||
}
|
||||
@@ -215,21 +212,21 @@ export const useAppStore = create<AppState>((set, get) => ({
|
||||
try {
|
||||
await window.api.threads.delete(threadId)
|
||||
console.log('[Store] Thread deleted from backend')
|
||||
|
||||
set(state => {
|
||||
const threads = state.threads.filter(t => t.thread_id !== threadId)
|
||||
|
||||
set((state) => {
|
||||
const threads = state.threads.filter((t) => t.thread_id !== threadId)
|
||||
const wasCurrentThread = state.currentThreadId === threadId
|
||||
const newCurrentId = wasCurrentThread
|
||||
? threads[0]?.thread_id || null
|
||||
const newCurrentId = wasCurrentThread
|
||||
? threads[0]?.thread_id || null
|
||||
: state.currentThreadId
|
||||
|
||||
console.log('[Store] Updating state:', {
|
||||
remainingThreads: threads.length,
|
||||
wasCurrentThread,
|
||||
newCurrentId
|
||||
|
||||
console.log('[Store] Updating state:', {
|
||||
remainingThreads: threads.length,
|
||||
wasCurrentThread,
|
||||
newCurrentId
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
return {
|
||||
threads,
|
||||
currentThreadId: newCurrentId,
|
||||
// Clear messages if we deleted the current thread
|
||||
@@ -248,127 +245,18 @@ export const useAppStore = create<AppState>((set, get) => ({
|
||||
|
||||
updateThread: async (threadId: string, updates: Partial<Thread>) => {
|
||||
const updated = await window.api.threads.update(threadId, updates)
|
||||
set(state => ({
|
||||
threads: state.threads.map(t => t.thread_id === threadId ? updated : t)
|
||||
set((state) => ({
|
||||
threads: state.threads.map((t) => (t.thread_id === threadId ? updated : t))
|
||||
}))
|
||||
},
|
||||
|
||||
// Message actions
|
||||
sendMessage: async (content: string) => {
|
||||
const { currentThreadId } = get()
|
||||
console.log('[Store] sendMessage called', { currentThreadId, content: content.substring(0, 50) })
|
||||
|
||||
if (!currentThreadId) {
|
||||
console.error('[Store] No currentThreadId!')
|
||||
return
|
||||
}
|
||||
|
||||
const threadId = currentThreadId
|
||||
|
||||
// Add user message immediately
|
||||
const userMessage: Message = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
content,
|
||||
created_at: new Date()
|
||||
}
|
||||
|
||||
const isFirstMessage = get().messages.length === 0
|
||||
|
||||
set(state => ({
|
||||
messages: [...state.messages, userMessage]
|
||||
}))
|
||||
|
||||
// Auto-generate title on first message
|
||||
if (isFirstMessage) {
|
||||
try {
|
||||
const generatedTitle = await window.api.threads.generateTitle(content)
|
||||
await get().updateThread(threadId, { title: generatedTitle })
|
||||
} catch (error) {
|
||||
console.error('[Store] Failed to generate title:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Set this thread as streaming
|
||||
get().setThreadStreaming(threadId, true)
|
||||
get().clearStreamingContent(threadId)
|
||||
|
||||
// Stream agent response using callback pattern
|
||||
try {
|
||||
console.log('[Store] Checking window.api:', !!window.api, !!window.api?.agent)
|
||||
console.log('[Store] About to call window.api.agent.invoke')
|
||||
|
||||
// The cleanup function is returned but auto-removes on done/error events
|
||||
window.api.agent.invoke(threadId, content, (event) => {
|
||||
console.log('[Store] Received event:', event.type)
|
||||
switch (event.type) {
|
||||
case 'message':
|
||||
// Only update if this is still the current thread
|
||||
if (get().currentThreadId === threadId) {
|
||||
get().appendMessage(event.message)
|
||||
}
|
||||
break
|
||||
case 'token':
|
||||
get().appendStreamingContent(threadId, event.token)
|
||||
break
|
||||
case 'interrupt':
|
||||
set({ pendingApproval: event.request })
|
||||
break
|
||||
case 'tool_call':
|
||||
// Could show tool call in progress
|
||||
break
|
||||
case 'todos':
|
||||
// Only update if this is still the current thread
|
||||
if (get().currentThreadId === threadId) {
|
||||
get().setTodos(event.todos)
|
||||
}
|
||||
break
|
||||
case 'workspace':
|
||||
console.log('[Store] Received workspace event:', {
|
||||
files: event.files.length,
|
||||
path: event.path,
|
||||
isCurrentThread: get().currentThreadId === threadId
|
||||
})
|
||||
// Only update if this is still the current thread
|
||||
if (get().currentThreadId === threadId) {
|
||||
get().setWorkspaceFiles(event.files)
|
||||
get().setWorkspacePath(event.path)
|
||||
}
|
||||
break
|
||||
case 'subagents':
|
||||
console.log('[Store] Received subagents event:', {
|
||||
count: event.subagents.length,
|
||||
isCurrentThread: get().currentThreadId === threadId
|
||||
})
|
||||
// Only update if this is still the current thread
|
||||
if (get().currentThreadId === threadId) {
|
||||
get().setSubagents(event.subagents)
|
||||
}
|
||||
break
|
||||
case 'done':
|
||||
get().setThreadStreaming(threadId, false)
|
||||
get().clearStreamingContent(threadId)
|
||||
break
|
||||
case 'error':
|
||||
console.error('[Store] Stream error:', event.error)
|
||||
get().setThreadStreaming(threadId, false)
|
||||
get().clearStreamingContent(threadId)
|
||||
break
|
||||
}
|
||||
})
|
||||
console.log('[Store] invoke() called')
|
||||
} catch (error) {
|
||||
console.error('[Store] Failed to send message:', error)
|
||||
get().setThreadStreaming(threadId, false)
|
||||
}
|
||||
},
|
||||
|
||||
appendMessage: (message: Message) => {
|
||||
set(state => {
|
||||
set((state) => {
|
||||
// Check if message already exists (by id)
|
||||
const exists = state.messages.some(m => m.id === message.id)
|
||||
const exists = state.messages.some((m) => m.id === message.id)
|
||||
if (exists) {
|
||||
return { messages: state.messages.map(m => m.id === message.id ? message : m) }
|
||||
return { messages: state.messages.map((m) => (m.id === message.id ? message : m)) }
|
||||
}
|
||||
return { messages: [...state.messages, message] }
|
||||
})
|
||||
@@ -378,42 +266,14 @@ export const useAppStore = create<AppState>((set, get) => ({
|
||||
set({ messages })
|
||||
},
|
||||
|
||||
// Streaming actions
|
||||
isThreadStreaming: (threadId: string) => {
|
||||
return get().streamingThreads.has(threadId)
|
||||
},
|
||||
|
||||
getStreamingContent: (threadId: string) => {
|
||||
return get().streamingContent[threadId] || ''
|
||||
},
|
||||
|
||||
setThreadStreaming: (threadId: string, streaming: boolean) => {
|
||||
set(state => {
|
||||
const newSet = new Set(state.streamingThreads)
|
||||
if (streaming) {
|
||||
newSet.add(threadId)
|
||||
} else {
|
||||
newSet.delete(threadId)
|
||||
}
|
||||
return { streamingThreads: newSet }
|
||||
})
|
||||
},
|
||||
|
||||
appendStreamingContent: (threadId: string, content: string) => {
|
||||
set(state => ({
|
||||
streamingContent: {
|
||||
...state.streamingContent,
|
||||
[threadId]: (state.streamingContent[threadId] || '') + content
|
||||
}
|
||||
}))
|
||||
},
|
||||
|
||||
clearStreamingContent: (threadId: string) => {
|
||||
set(state => {
|
||||
const newContent = { ...state.streamingContent }
|
||||
delete newContent[threadId]
|
||||
return { streamingContent: newContent }
|
||||
})
|
||||
// Auto-generate title for first message in a thread
|
||||
generateTitleForFirstMessage: async (threadId: string, content: string) => {
|
||||
try {
|
||||
const generatedTitle = await window.api.threads.generateTitle(content)
|
||||
await get().updateThread(threadId, { title: generatedTitle })
|
||||
} catch (error) {
|
||||
console.error('[Store] Failed to generate title:', error)
|
||||
}
|
||||
},
|
||||
|
||||
// HITL actions
|
||||
@@ -421,7 +281,10 @@ export const useAppStore = create<AppState>((set, get) => ({
|
||||
set({ pendingApproval: request })
|
||||
},
|
||||
|
||||
respondToApproval: async (decision: 'approve' | 'reject' | 'edit', editedArgs?: Record<string, unknown>) => {
|
||||
respondToApproval: async (
|
||||
decision: 'approve' | 'reject' | 'edit',
|
||||
editedArgs?: Record<string, unknown>
|
||||
) => {
|
||||
const { currentThreadId, pendingApproval } = get()
|
||||
if (!currentThreadId || !pendingApproval) return
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { UseStreamTransport } from '@langchain/langgraph-sdk/react'
|
||||
|
||||
export type StreamPayload = Parameters<UseStreamTransport['stream']>[0]
|
||||
|
||||
export type StreamEvent = {
|
||||
id?: string
|
||||
event: string
|
||||
data: unknown
|
||||
}
|
||||
|
||||
// Types for the IPC events from main process
|
||||
export interface IPCMessage {
|
||||
id: string
|
||||
type: 'human' | 'ai' | 'tool' | 'system'
|
||||
content: string
|
||||
tool_calls?: { id: string; name: string; args: Record<string, unknown> }[]
|
||||
}
|
||||
|
||||
export interface IPCValuesEvent {
|
||||
type: 'values'
|
||||
data: {
|
||||
messages?: IPCMessage[]
|
||||
todos?: { id?: string; content?: string; status?: string }[]
|
||||
files?: Record<string, unknown> | Array<{ path: string; is_dir?: boolean; size?: number }>
|
||||
workspacePath?: string
|
||||
subagents?: Array<{
|
||||
id?: string
|
||||
name?: string
|
||||
description?: string
|
||||
status?: string
|
||||
startedAt?: Date | string
|
||||
completedAt?: Date | string
|
||||
}>
|
||||
interrupt?: { id?: string; tool_call?: unknown }
|
||||
}
|
||||
}
|
||||
|
||||
export interface IPCTokenEvent {
|
||||
type: 'token'
|
||||
messageId: string
|
||||
token: string
|
||||
}
|
||||
|
||||
export interface IPCToolCallEvent {
|
||||
type: 'tool_call'
|
||||
messageId: string | null
|
||||
tool_calls: Array<{ id?: string; name?: string; args?: string }>
|
||||
}
|
||||
|
||||
export interface IPCDoneEvent {
|
||||
type: 'done'
|
||||
}
|
||||
|
||||
export interface IPCErrorEvent {
|
||||
type: 'error'
|
||||
error: string
|
||||
}
|
||||
|
||||
export type IPCEvent =
|
||||
| IPCValuesEvent
|
||||
| IPCTokenEvent
|
||||
| IPCToolCallEvent
|
||||
| IPCDoneEvent
|
||||
| IPCErrorEvent
|
||||
+3
-2
@@ -3,7 +3,8 @@
|
||||
"include": [
|
||||
"electron.vite.config.*",
|
||||
"src/main/**/*",
|
||||
"src/preload/**/*"
|
||||
"src/preload/**/*",
|
||||
"src/types.ts"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
@@ -11,4 +12,4 @@
|
||||
"electron-vite/node"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -4,7 +4,8 @@
|
||||
"src/renderer/src/env.d.ts",
|
||||
"src/renderer/src/**/*",
|
||||
"src/renderer/src/**/*.tsx",
|
||||
"src/preload/*.d.ts"
|
||||
"src/preload/*.d.ts",
|
||||
"src/types.ts"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
@@ -19,4 +20,4 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user