mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-29 16:15:51 +00:00
* feat(chat-sdk): chat through a raw app's backend runnable Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(chat-sdk): finish a lost stream and wait out a stopped turn in streamChatTurn Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(chat-sdk): show the answer when a stream is lost before a tool call Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(chat-sdk): send attachments through the raw app runnable Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(chat-sdk): point sandboxed attachments at the runnable section Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: agent runnables for raw apps Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: build agent runnables without the parquet feature, cache the agent check query Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: address review nits on the agent picker and the CLI schema doc Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: run an agent runnable once when it is given no memory_id Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: accept previous_messages on agent runs, read-checking the files it quotes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: refuse an anonymous agent turn while one with the same memory still runs Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: show the agent's settings in the raw-app runnable panel, size its button like the row Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: offer an agent runnable memory_id or previous_messages after the agent's memory Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: take previous_messages in a memory-off agent's run form, type agent attachments as s3object Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: file an anonymous agent turn as a conversation, so its memory is collected and its turns serialized Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: own anonymous agent conversations under a fixed name that fits created_by Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: own anonymous agent conversations as anonymous and keep them out of conversation lists Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: point file_chat_turn at the anonymous conversation owner Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: title the runnable picker after what it lists, in sentence case Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: start a chat with local history listing its stored conversations Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: chat through a raw app's agent runnable with { wmill, runnable } Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: key an agent runnable chat's local history by its app as well Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: address review findings on agent runnables Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: offer memory_id to an agent runnable over an older auto memory Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: drop inherited static inputs from an agent runnable, show the memory-off note only when it is off Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
127 lines
4.9 KiB
TypeScript
127 lines
4.9 KiB
TypeScript
import {
|
|
useExternalStoreRuntime,
|
|
type AppendMessage,
|
|
type AssistantRuntime,
|
|
type ExternalStoreAdapter,
|
|
type ThreadMessageLike
|
|
} from '@assistant-ui/react'
|
|
import { useEffect, useMemo } from 'react'
|
|
import { useWindmillChat } from './react'
|
|
import type { ChatMessage, RawAppWmill, TypedChatOptions } from './types'
|
|
import { parseJsonOr } from './utils'
|
|
|
|
/** One assistant turn: the assistant and tool rows between two user messages. */
|
|
export interface WindmillTurn {
|
|
id: string
|
|
role: 'user' | 'assistant' | 'system'
|
|
messages: ChatMessage[]
|
|
}
|
|
|
|
export type WindmillRuntimeOptions<W extends RawAppWmill = RawAppWmill> = TypedChatOptions<W> & {
|
|
/** Load the conversation list on mount so `ThreadListPrimitive` has something to show. Default true. */
|
|
threadList?: boolean
|
|
}
|
|
|
|
/**
|
|
* An assistant-ui runtime over a chat-mode flow, for `AssistantRuntimeProvider`.
|
|
* Conversations become threads, so the thread list primitives switch, create and
|
|
* delete Windmill conversations.
|
|
*/
|
|
export function useWindmillRuntime<W extends RawAppWmill>(options: WindmillRuntimeOptions<W>): AssistantRuntime {
|
|
const chat = useWindmillChat(options)
|
|
const turns = useMemo(() => groupTurns(chat.messages), [chat.messages])
|
|
const withThreadList = options.threadList !== false
|
|
useEffect(() => {
|
|
if (withThreadList) chat.loadConversations().catch(() => {})
|
|
}, [chat.chat, withThreadList])
|
|
|
|
const adapter: ExternalStoreAdapter<WindmillTurn> = {
|
|
messages: turns,
|
|
isRunning: chat.status === 'submitted' || chat.status === 'streaming',
|
|
convertMessage: toThreadMessage,
|
|
onNew: async (message: AppendMessage) => {
|
|
if (message.role !== 'user') return
|
|
await chat.sendMessage(appendedText(message))
|
|
},
|
|
onCancel: async () => {
|
|
await chat.stop()
|
|
},
|
|
adapters: withThreadList
|
|
? {
|
|
threadList: {
|
|
threadId: chat.conversationId,
|
|
threads: chat.conversations.map((c) => ({ status: 'regular' as const, id: c.id, title: c.title })),
|
|
onSwitchToNewThread: () => chat.newConversation(),
|
|
onSwitchToThread: (id) => chat.selectConversation(id),
|
|
onDelete: (id) => chat.deleteConversation(id),
|
|
onRename: (id, title) => chat.renameConversation(id, title)
|
|
}
|
|
}
|
|
: undefined
|
|
}
|
|
return useExternalStoreRuntime<WindmillTurn>(adapter)
|
|
}
|
|
|
|
function appendedText(message: AppendMessage): string {
|
|
return message.content
|
|
.filter((p): p is { type: 'text'; text: string } => p.type === 'text')
|
|
.map((p) => p.text)
|
|
.join('\n')
|
|
}
|
|
|
|
/** Folds the role-per-row message list into turns: one entry per user message, one per answer. */
|
|
export function groupTurns(messages: ChatMessage[]): WindmillTurn[] {
|
|
const turns: WindmillTurn[] = []
|
|
for (const m of messages) {
|
|
const last = turns[turns.length - 1]
|
|
if (m.role === 'user' || m.role === 'system' || !last || last.role !== 'assistant') {
|
|
turns.push({ id: m.id, role: m.role === 'tool' ? 'assistant' : m.role, messages: [m] })
|
|
} else {
|
|
last.messages.push(m)
|
|
}
|
|
}
|
|
return turns
|
|
}
|
|
|
|
/** A turn as assistant-ui content parts; the status reflects streaming and a failed flow. */
|
|
export function toThreadMessage(turn: WindmillTurn): ThreadMessageLike {
|
|
const first = turn.messages[0]
|
|
const createdAt = new Date(first.createdAt)
|
|
if (turn.role !== 'assistant') {
|
|
return { id: turn.id, role: turn.role, createdAt, content: [{ type: 'text', text: first.content }] }
|
|
}
|
|
const content: ThreadContentPart[] = []
|
|
for (const m of turn.messages) {
|
|
if (m.reasoning) content.push({ type: 'reasoning', text: m.reasoning })
|
|
if (m.role === 'tool') {
|
|
const args = parseJsonOr(m.tool?.arguments)
|
|
content.push({
|
|
type: 'tool-call',
|
|
toolCallId: m.tool?.callId ?? m.id,
|
|
toolName: m.tool?.name ?? 'tool',
|
|
args: (isJsonObject(args) ? args : args === undefined ? {} : { input: args }) as ToolCallArgs,
|
|
argsText: m.tool?.arguments ?? '',
|
|
result:
|
|
m.tool?.status === 'running' ? undefined : m.tool?.result !== undefined ? parseJsonOr(m.tool.result) : m.content,
|
|
isError: m.tool?.status === 'error'
|
|
})
|
|
continue
|
|
}
|
|
if (m.content) content.push({ type: 'text', text: m.content })
|
|
}
|
|
const last = turn.messages[turn.messages.length - 1]
|
|
const status: ThreadMessageLike['status'] = turn.messages.some((m) => m.pending)
|
|
? { type: 'running' }
|
|
: last.role === 'assistant' && !last.success
|
|
? { type: 'incomplete', reason: 'error', error: last.content }
|
|
: { type: 'complete', reason: 'stop' }
|
|
return { id: turn.id, role: 'assistant', createdAt, content, status }
|
|
}
|
|
|
|
type ThreadContentPart = Exclude<ThreadMessageLike['content'], string>[number]
|
|
type ToolCallArgs = Extract<ThreadContentPart, { type: 'tool-call' }>['args']
|
|
|
|
function isJsonObject(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
}
|