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
2 changes: 2 additions & 0 deletions src/agents/_data_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ class AgentChatWidgetConfig:
iteration_limit_handling: str
show_tool_calls_and_results: bool
reexecution_trigger: str
allow_links: bool
error_column_name: Optional[str]


Expand Down Expand Up @@ -159,6 +160,7 @@ def get_configuration(self):
return {
"show_tool_calls_and_results": self._widget_config.show_tool_calls_and_results,
"reexecution_trigger": self._widget_config.reexecution_trigger,
"allow_links": self._widget_config.allow_links,
}

def get_combined_tools_workflow_info(self):
Expand Down
12 changes: 12 additions & 0 deletions src/agents/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1088,6 +1088,15 @@ def execute(self, tool_calls):

# region Agent Chat Widget

@knext.parameter_group(
label="Content Settings", is_advanced=True, since_version="5.12.0"
)
class AgentChatWidgetContentSettings:
allow_links = knext.BoolParameter(
"Allow links",
"If checked, URLs in the chat view will be clickable and open in a new tab.",
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I used "view" here because the UI also says "Open view" and the description for the re-execution trigger also refers to it as chat view. But we could also change it to "chat widget".

default_value=False,
)

@knext.parameter_group(
label="Error Handling Settings", is_advanced=True, since_version="5.10.0"
Expand Down Expand Up @@ -1235,6 +1244,8 @@ class ReexecutionTrigger(knext.EnumParameterOptions):

data_message_prefix = _data_message_prefix_parameter()

content = AgentChatWidgetContentSettings()

errors = AgentChatWidgetErrorSettings()

def configure(
Expand Down Expand Up @@ -1382,6 +1393,7 @@ def get_data_service(
self.recursion_limit_handling,
self.show_tool_calls_and_results,
self.reexecution_trigger,
self.content.allow_links,
self.errors.error_column_name if self.errors.has_error_column else None,
)

Expand Down

Large diffs are not rendered by default.

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

This file was deleted.

48 changes: 48 additions & 0 deletions src/agents/chat_app/dist/assets/index-Cwcw1gMc.js

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-CjtBSGH3.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-DSZPqU-2.css">
<script type="module" crossorigin src="./assets/index-Cwcw1gMc.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BdMpKhWI.css">
</head>
<body>
<div id="app"></div>
Expand Down
16 changes: 15 additions & 1 deletion src/agents/chat_app/src/components/chat/MarkdownRenderer.vue
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
<script setup lang="ts">
import { computed } from "vue";

import { useChatStore } from "../../stores/chat";
import { renderMarkdown } from "../../utils/markdown";

const props = defineProps<{
markdown: string;
}>();

const htmlContent = computed(() => renderMarkdown(props.markdown));
const chatStore = useChatStore();

const htmlContent = computed(() =>
renderMarkdown(props.markdown, chatStore.allowLinks || false),
);
</script>

<template>
Expand All @@ -20,6 +25,15 @@ const htmlContent = computed(() => renderMarkdown(props.markdown));
overflow-wrap: break-word;
overflow-x: hidden;

& a {
color: var(--knime-cornflower);
text-decoration: none;

&:hover {
text-decoration: underline;
}
}

& > *:first-child {
margin-top: 0;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ describe("ChatInterface", () => {
chatStore.config = {
show_tool_calls_and_results: false,
reexecution_trigger: "NONE",
allow_links: false,
};
await wrapper.vm.$nextTick();

Expand All @@ -160,6 +161,7 @@ describe("ChatInterface", () => {
chatStore.config = {
show_tool_calls_and_results: false,
reexecution_trigger: "NONE",
allow_links: false,
};
chatStore.isInterrupted = true;
await wrapper.vm.$nextTick();
Expand Down Expand Up @@ -239,6 +241,7 @@ describe("ChatInterface", () => {
chatStore.config = {
show_tool_calls_and_results: false,
reexecution_trigger: "NONE",
allow_links: false,
};
await wrapper.vm.$nextTick();

Expand Down
9 changes: 9 additions & 0 deletions src/agents/chat_app/src/stores/__tests__/chat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,15 @@ describe("chat store", () => {
store.config = createConfig(false);
expect(store.shouldShowToolCalls).toBe(false);
});

it("allowLinks returns config value", () => {
const { store } = setupStore();
store.config = createConfig(true, "NONE", true);
expect(store.allowLinks).toBe(true);

store.config = createConfig(true, "NONE", false);
expect(store.allowLinks).toBe(false);
});
});

describe("addErrorMessage", () => {
Expand Down
4 changes: 4 additions & 0 deletions src/agents/chat_app/src/stores/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,8 @@ export const useChatStore = defineStore("chat", () => {
() => config.value?.show_tool_calls_and_results,
);

const allowLinks = computed(() => config.value?.allow_links);
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

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

allowLinks is computed as config.value?.allow_links, so it can be undefined until config is loaded. Since this getter is consumed as a boolean (and the config field itself is boolean), consider coercing it to a stable boolean (e.g. !!config.value?.allow_links) so callers don’t need to add fallbacks.

Suggested change
const allowLinks = computed(() => config.value?.allow_links);
const allowLinks = computed(() => !!config.value?.allow_links);

Copilot uses AI. Check for mistakes.

// actions
function addErrorMessage(errorType: keyof typeof ERROR_MESSAGES) {
const errorMessage: ErrorMessage = {
Expand Down Expand Up @@ -354,6 +356,7 @@ export const useChatStore = defineStore("chat", () => {
return {
show_tool_calls_and_results: false,
reexecution_trigger: "NONE",
allow_links: false,
};
}
}
Expand Down Expand Up @@ -606,6 +609,7 @@ export const useChatStore = defineStore("chat", () => {
shouldShowStatusIndicator,
shouldShowGenericLoadingIndicator,
shouldShowToolCalls,
allowLinks,
isUsingTools,

// actions
Expand Down
2 changes: 2 additions & 0 deletions src/agents/chat_app/src/test/factories/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,11 @@ export const createViewMessage = (
export const createConfig = (
showToolCalls = true,
reexecution_trigger: ReexecutionTrigger = "NONE",
allowLinks = false,
): Config => ({
show_tool_calls_and_results: showToolCalls,
reexecution_trigger,
allow_links: allowLinks,
});

export const createTimeline = (
Expand Down
1 change: 1 addition & 0 deletions src/agents/chat_app/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ export type ChatItem = Message | Timeline;
export interface Config {
show_tool_calls_and_results: boolean;
reexecution_trigger: ReexecutionTrigger;
allow_links: boolean;
}

export type ReexecutionTrigger = "NONE" | "INTERACTION";
Expand Down
104 changes: 99 additions & 5 deletions src/agents/chat_app/src/utils/__tests__/markdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ describe("renderMarkdown", () => {
const result = renderMarkdown(dirty);

expect(result).not.toContain("<script>");
expect(result).toContain("<p>Text</p>");
expect(result).toContain("Text");
});

it("retains allowed tags", () => {
Expand All @@ -40,16 +40,17 @@ describe("renderMarkdown", () => {

const result = renderMarkdown(input);

expect(result).not.toContain("onclick=");
expect(result).toContain("<p>Click me</p>");
expect(result).not.toMatch(/<[^>]+onclick=/i);
expect(result).toContain("Click me");
});

it("retains allowed attributes", () => {
it("removes disallowed style attributes", () => {
const input = '<p style="color:red;">Red text</p>';

const result = renderMarkdown(input);

expect(result).toContain('style="color:red;"');
expect(result).not.toMatch(/<[^>]+style=/i);
expect(result).toContain("Red text");
});

it("escapes inline code", () => {
Expand All @@ -60,4 +61,97 @@ describe("renderMarkdown", () => {
expect(result).toContain("&lt;script&gt;");
expect(includesTag(result, "code")).toBe(true);
});

describe("links", () => {
const linkInput = "[KNIME](https://www.knime.com)";

it("renders links when allowLinks is true", () => {
const result = renderMarkdown(linkInput, true);

expect(includesTag(result, "a")).toBe(true);
expect(result).toContain('href="https://www.knime.com"');
expect(result).toContain("KNIME");
});

it("does not render links when allowLinks is false", () => {
const result = renderMarkdown(linkInput, false);

expect(includesTag(result, "a")).toBe(false);
expect(result).not.toContain('href="https://www.knime.com"');
expect(result).toContain("KNIME");
});

it("injects security attributes into links", () => {
const result = renderMarkdown(linkInput, true);

expect(result).toContain('target="_blank"');
expect(result).toContain('rel="noopener noreferrer"');
});

it("automatically linkifies URLs", () => {
const input = "Check out https://www.knime.com";
const result = renderMarkdown(input, true);

expect(includesTag(result, "a")).toBe(true);
expect(result).toContain('href="https://www.knime.com"');
});

it("sanitizes same-origin links", () => {
const origin = window.location.origin;
const input = `[Internal](${origin}/some/internal/path)`;
const result = renderMarkdown(input, true);

expect(result).not.toContain(`href="${origin}`);
expect(result).toContain("Internal");
});

it("sanitizes dangerous protocols in markdown links", () => {
const input = '[Click](javascript:alert("XSS"))';
const result = renderMarkdown(input, true);

expect(result).not.toContain('href="javascript:');
});

it("sanitizes dangerous protocols in raw HTML links", () => {
const input = "<a href=\"javascript:alert('XSS')\">Click</a>";
const result = renderMarkdown(input, true);

expect(result).not.toContain('href="javascript:');
expect(result).not.toContain("<a");
expect(result).toContain("Click");
});

it("injects title attribute with URL and instruction", () => {
const result = renderMarkdown(linkInput, true);

expect(result).toContain("https://www.knime.com");
expect(result).toMatch(/Click to open link/);
});

it("truncates long URLs in the title", () => {
const longUrl = `https://www.knime.com/${"a".repeat(60)}`;
const input = `[link](${longUrl})`;
const result = renderMarkdown(input, true);

expect(result).toContain(`${longUrl.slice(0, 50)}...`);
});

it("blocks explicit mailto links", () => {
const input = "[Contact](mailto:support@example.com)";
const result = renderMarkdown(input, true);

expect(includesTag(result, "a")).toBe(false);
expect(result).not.toContain('href="');
expect(result).toContain("Contact");
});

it("blocks bare email addresses in links", () => {
const input = "[mailing](test.test@gmail.com)";
const result = renderMarkdown(input, true);

expect(includesTag(result, "a")).toBe(false);
expect(result).not.toContain('href="');
expect(result).toContain("mailing");
});
});
});
Loading