Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 37 additions & 9 deletions src/agents/_data_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
import knime.extension as knext
from langchain_core.messages.human import HumanMessage

from typing import TYPE_CHECKING, Optional
from typing import TYPE_CHECKING, Optional, Union

if TYPE_CHECKING:
from .base import AgentPrompterConversation
Expand Down Expand Up @@ -87,17 +87,21 @@ def __init__(
widget_config: AgentChatWidgetConfig,
tool_converter: LangchainToolConverter,
combined_tools_workflow_info: dict,
startup_error: Optional[str] = None,
):
self._ctx = ctx

self._chat_model = chat_model
self._startup_error = startup_error
self._conversation = FrontendConversation(
conversation, tool_converter, self._check_canceled
)
self._agent_config = agent_config
self._agent = Agent(
self._conversation, self._chat_model, toolset, self._agent_config
)
self._agent = None
if self._chat_model is not None:
self._agent = Agent(
self._conversation, self._chat_model, toolset, self._agent_config
)

self._data_registry = data_registry
self._widget_config = widget_config
Expand All @@ -108,6 +112,9 @@ def __init__(
self._thread = None
self._is_canceled = False

if self._startup_error:
self._conversation.append_warning_to_frontend(self._startup_error)

def get_initial_message(self):
if self._widget_config.initial_message:
return {
Expand Down Expand Up @@ -179,10 +186,20 @@ def get_view_data(self):
def _post_user_message(self, user_message: str):
from langchain_core.messages import AIMessage

# append user message before checking for start-up error to mirror UI
self._conversation.append_messages_to_backend(
HumanMessage(content=user_message)
)

if self._agent is None:
self._conversation.append_error(
Exception(
self._startup_error
or "The selected model is not available."
)
)
return

try:
self._agent.run()

Expand Down Expand Up @@ -263,10 +280,9 @@ def _append_messages(self, messages):
"""Appends messages to both backend and frontend without checking for cancellation."""
from langchain_core.messages import HumanMessage

# will not raise since backend has no context
self._backend_messages.append_messages(messages)

for new_message in messages:
self._backend_messages._append(new_message) # _backend_messages.append_messages does validation

Comment on lines 283 to +285
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FrontendConversation._append_messages calls the private AgentPrompterConversation._append(...) to bypass validation. This works today, but it tightly couples the data service to a private method and makes future refactors of AgentPrompterConversation riskier. Consider adding an explicit public API on AgentPrompterConversation for appending without validation (or a flag on append_messages) and use that instead of reaching into _append.

Copilot uses AI. Check for mistakes.
if isinstance(new_message, HumanMessage):
continue

Expand All @@ -276,8 +292,13 @@ def _append_messages(self, messages):

def append_messages_to_backend(self, messages):
"""Appends messages only to the backend conversation."""
# will not raise since backend has no context
self._backend_messages.append_messages(messages)
from langchain_core.messages import BaseMessage

if isinstance(messages, BaseMessage):
messages = [messages]

for msg in messages:
self._backend_messages._append(msg) # _backend_messages.append_messages does validation
Comment on lines +300 to +301
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same coupling issue in append_messages_to_backend: it iterates messages and calls the private _backend_messages._append(...). Prefer using a dedicated public method on AgentPrompterConversation for unvalidated appends so this behavior is intentional and less brittle.

Suggested change
for msg in messages:
self._backend_messages._append(msg) # _backend_messages.append_messages does validation
# Use the backend's public API for appending messages instead of its private `_append` method.
self._backend_messages.append_messages(messages)

Copilot uses AI. Check for mistakes.

def append_error(self, error: Exception):
"""Appends an error to both backend and frontend."""
Expand All @@ -294,6 +315,13 @@ def append_error_to_frontend(self, error: Exception):
error_message = {"type": "error", "content": content}
self._frontend.put(error_message)

def append_warning_to_frontend(self, warning: Union[Exception, str]):
"""Appends a warning only to the frontend."""
content = str(warning)

warning_message = {"type": "warning", "content": content}
self._frontend.put(warning_message)
Comment on lines +318 to +323
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

append_warning_to_frontend enqueues a warning dict without an id. In the frontend, WarningMessage extends BaseMessage and requires id: string (and several tests/matchers assume an id is present). Consider generating a stable unique id for warnings (e.g., uuid/monotonic counter) and including it in the payload to keep the message shape consistent with other message types.

Copilot uses AI. Check for mistakes.

def get_messages(self):
return self._backend_messages.get_messages()

Expand Down
28 changes: 25 additions & 3 deletions src/agents/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1104,6 +1104,15 @@ class AgentChatWidgetErrorSettings:
).rule(knext.OneOf(has_error_column, [True]), knext.Effect.SHOW)


def _validate_chat_model_credentials(ctx, chat_model_port: ChatModelPortObject):
try:
chat_model_port.spec.validate_context(ctx)
except Exception as exc:
return str(exc)

return None


@knext.node(
"Agent Chat Widget (experimental)",
node_type=knext.NodeType.VISUALIZER,
Expand Down Expand Up @@ -1327,9 +1336,7 @@ def get_data_service(

view_data = ctx._get_view_data()

chat_model = chat_model.create_model(
ctx, output_format=OutputFormatOptions.Text
)
chat_model, startup_error = self._try_create_model(ctx, chat_model)

if view_data is None:
project_id, workflow_id, input_ids = ctx._init_combined_tools_workflow(
Expand Down Expand Up @@ -1388,8 +1395,23 @@ def get_data_service(
"project_id": project_id,
"workflow_id": workflow_id,
},
startup_error=startup_error,
)

def _try_create_model(self, ctx, chat_model: ChatModelPortObject):
credential_error = _validate_chat_model_credentials(ctx, chat_model)
if credential_error:
return None, credential_error
else:
try:
model = chat_model.create_model(ctx, output_format=OutputFormatOptions.Text)
return model, None
except Exception as exc:
startup_error = (
f"Failed to initialize the selected model. {exc}"
)
return None, startup_error

def _create_conversation_history(self, view_data, data_registry, tool_converter):
history_table = view_data["ports"][0] if view_data is not None else None
error_column = (
Expand Down
47 changes: 0 additions & 47 deletions src/agents/chat_app/dist/assets/index-CcVbEd9v.js

This file was deleted.

47 changes: 47 additions & 0 deletions src/agents/chat_app/dist/assets/index-DM-MYyfr.js

Large diffs are not rendered by default.

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions src/agents/chat_app/dist/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
<link rel="icon" href="./favicon.png" type="image/png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI Chat Assistant</title>
<script type="module" crossorigin src="./assets/index-CcVbEd9v.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-B9GfoYlZ.css">
<script type="module" crossorigin src="./assets/index-DM-MYyfr.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-exEeaaxF.css">
</head>
<body>
<div id="app"></div>
Expand Down
7 changes: 7 additions & 0 deletions src/agents/chat_app/src/components/chat/ChatInterface.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { useChatStore } from "@/stores/chat";
import MessageInput from "./MessageInput.vue";
import StatusIndicator from "./StatusIndicator.vue";
import WarningBanner from "./WarningBanner.vue";
import AiMessage from "./message/AiMessage.vue";
import ErrorMessage from "./message/ErrorMessage.vue";
import HumanMessage from "./message/HumanMessage.vue";
Expand Down Expand Up @@ -54,6 +55,12 @@ useScrollToBottom(scrollableContainer, messagesList);
</div>
</div>

<WarningBanner
v-if="chatStore.warningMessage"
:warning="chatStore.warningMessage"
@dismiss="chatStore.dismissWarning"
/>

<MessageInput />
</main>
</template>
Expand Down
67 changes: 67 additions & 0 deletions src/agents/chat_app/src/components/chat/WarningBanner.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<script setup lang="ts">
import AbortIcon from "@knime/styles/img/icons/close.svg";

import type { WarningMessage } from "@/types";

defineProps<{ warning: WarningMessage }>();

defineEmits<{ (event: "dismiss"): void }>();
</script>

<template>
<div class="warning-banner">
<span class="message">{{ warning.content }}</span>
<button
class="dismiss-button"
type="button"
aria-label="Dismiss warning"
@click="$emit('dismiss')"
>
<AbortIcon aria-hidden="true" focusable="false" />
</button>
</div>
</template>

<style lang="postcss" scoped>
@import url("@knime/styles/css/mixins");

.warning-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-12);
margin: var(--space-8) 0 var(--space-16);
padding: var(--space-12) var(--space-16);
border-radius: var(--space-8);
border: 1px solid var(--knime-yellow);
background-color: var(--knime-yellow-ultra-light);
color: var(--knime-masala);
font-size: 13px;
}

.message {
flex: 1;
}

.dismiss-button {
border: none;
background: transparent;
color: inherit;
cursor: pointer;
padding: var(--space-4) var(--space-8);
border-radius: 9999px;
display: inline-flex;
align-items: center;
justify-content: center;
}

.dismiss-button:hover {
background-color: var(--knime-yellow);
}

.dismiss-button :deep(svg) {
stroke: var(--knime-masala);

@mixin svg-icon-size 16;
}
</style>
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ describe("ChatInterface", () => {
props: ["label"],
template: "<div class='status-indicator'>{{ label }}</div>",
},
WarningBanner: {
props: ["warning"],
template:
"<div class='warning-banner'><button class='dismiss' @click=\"$emit('dismiss')\">Dismiss</button></div>",
},
MessageInput: {
template: "<div class='message-input'>Message Input</div>",
},
Expand Down Expand Up @@ -249,6 +254,37 @@ describe("ChatInterface", () => {
expect(wrapper.find(".message-input").exists()).toBe(true);
});

it("shows warning banner when warning message is set", async () => {
const wrapper = createWrapper();
const chatStore = useChatStore();

chatStore.warningMessage = {
id: "warning-1",
type: "warning",
content: "Heads up",
} as any;
await wrapper.vm.$nextTick();

expect(wrapper.find(".warning-banner").exists()).toBe(true);
});

it("dismisses warning banner when dismiss is triggered", async () => {
const wrapper = createWrapper();
const chatStore = useChatStore();

chatStore.warningMessage = {
id: "warning-2",
type: "warning",
content: "Be careful",
} as any;
const dismissSpy = vi.spyOn(chatStore, "dismissWarning");
await wrapper.vm.$nextTick();

await wrapper.find(".warning-banner .dismiss").trigger("click");

expect(dismissSpy).toHaveBeenCalledTimes(1);
});

it("finishes loading when the last message is an AI message without tool calls", async () => {
const wrapper = createWrapper();
const chatStore = useChatStore();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { mount } from "@vue/test-utils";

import WarningBanner from "../WarningBanner.vue";

const createWrapper = (content = "Warning text") =>
mount(WarningBanner, {
props: {
warning: {
id: "warning-1",
type: "warning",
content,
},
},
global: {
stubs: {
AbortIcon: { template: "<svg />" },
},
},
});

describe("WarningBanner", () => {
it("renders warning content", () => {
const wrapper = createWrapper("Heads up");

expect(wrapper.text()).toContain("Heads up");
});

it("emits dismiss when dismiss button is clicked", async () => {
const wrapper = createWrapper();

await wrapper.find(".dismiss-button").trigger("click");

expect(wrapper.emitted("dismiss")).toHaveLength(1);
});
});
Loading