Skip to content
Draft
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
198 changes: 195 additions & 3 deletions client/src/components/GalaxyAI/ChatMessageCell.vue
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
<script setup lang="ts">
import { faDatabase, faHistory, faThumbsDown, faThumbsUp } from "@fortawesome/free-solid-svg-icons";
import {
faChevronDown,
faChevronUp,
faDatabase,
faExternalLinkAlt,
faHistory,
faLink,
faThumbsDown,
faThumbsUp,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { computed } from "vue";
import { computed, ref } from "vue";

import type { ActionSuggestion, AgentResponse } from "@/composables/agentActions";
import { type ActionSuggestion, ActionType, type AgentResponse } from "@/composables/agentActions";
import { type EntityType, MENTION_PATTERN_SOURCE } from "@/composables/useEntityMentions";

import { formatModelName, getAgentIcon, getAgentLabel, getAgentResponseOrEmpty } from "./agentTypes";
Expand All @@ -15,6 +24,11 @@ import ClarificationCard from "./ClarificationCard.vue";
const MENTION_RE = new RegExp(MENTION_PATTERN_SOURCE, "g");

type Segment = { kind: "text"; value: string } | { kind: "mention"; entityType: EntityType; identifier: string };
type ReferenceLink = { label: string; url: string };

const MARKDOWN_LINK_RE = /\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g;
const BARE_URL_RE = /https?:\/\/[^\s<>"')]+/g;
const LINK_METADATA_KEYS = new Set(["link", "links", "url", "urls", "source", "sources", "reference", "references"]);

function parseSegments(text: string): Segment[] {
const segments: Segment[] = [];
Expand Down Expand Up @@ -48,6 +62,87 @@ const emit = defineEmits<{

const isClarification = computed(() => props.message.agentType === "clarification");
const clarificationOptions = computed<string[]>(() => props.message.agentResponse?.metadata?.options ?? []);
const linksExpanded = ref(false);

function trimUrl(url: string) {
return url.replace(/[.,;:!?]+$/, "");
}

function isHttpUrl(value: string) {
return /^https?:\/\//i.test(value);
}

function addLink(links: ReferenceLink[], seen: Set<string>, label: string | undefined, url: string | undefined) {
if (!url || !isHttpUrl(url)) {
return;
}
const normalizedUrl = trimUrl(url);
if (seen.has(normalizedUrl)) {
return;
}
seen.add(normalizedUrl);
links.push({ label: label?.trim() || normalizedUrl, url: normalizedUrl });
}

function collectMetadataLinks(value: unknown, links: ReferenceLink[], seen: Set<string>, keyHint?: string) {
if (!value) {
return;
}

if (typeof value === "string") {
if (!keyHint || LINK_METADATA_KEYS.has(keyHint.toLowerCase()) || isHttpUrl(value)) {
addLink(links, seen, undefined, value);
}
return;
}

if (Array.isArray(value)) {
value.forEach((item) => collectMetadataLinks(item, links, seen, keyHint));
return;
}

if (typeof value === "object") {
const record = value as Record<string, unknown>;
const url =
typeof record.url === "string" ? record.url : typeof record.link === "string" ? record.link : undefined;
const label =
typeof record.title === "string"
? record.title
: typeof record.label === "string"
? record.label
: typeof record.name === "string"
? record.name
: undefined;
addLink(links, seen, label, url);

Object.entries(record).forEach(([key, child]) => collectMetadataLinks(child, links, seen, key));
}
}

const referenceLinks = computed<ReferenceLink[]>(() => {
const links: ReferenceLink[] = [];
const seen = new Set<string>();
const content = props.message.content;

for (const match of content.matchAll(MARKDOWN_LINK_RE)) {
addLink(links, seen, match[1], match[2]);
}

for (const match of content.matchAll(BARE_URL_RE)) {
addLink(links, seen, undefined, match[0]);
}

props.message.suggestions?.forEach((suggestion) => {
const url = typeof suggestion.parameters?.url === "string" ? suggestion.parameters.url : undefined;
if (suggestion.action_type === ActionType.VIEW_EXTERNAL || url) {
addLink(links, seen, suggestion.description, url);
}
});

collectMetadataLinks(props.message.agentResponse?.metadata, links, seen);

return links;
});
</script>

<template>
Expand Down Expand Up @@ -130,6 +225,29 @@ const clarificationOptions = computed<string[]>(() => props.message.agentRespons
<span v-if="props.message.feedback" class="feedback-ack">Thanks!</span>
</div>
<div class="meta-right">
<div v-if="referenceLinks.length" class="reference-links">
<button
class="links-toggle"
:aria-expanded="linksExpanded ? 'true' : 'false'"
title="References"
@click="linksExpanded = !linksExpanded">
<FontAwesomeIcon :icon="faLink" fixed-width />
<span class="links-count">{{ referenceLinks.length }}</span>
<FontAwesomeIcon :icon="linksExpanded ? faChevronDown : faChevronUp" fixed-width />
</button>
<div v-if="linksExpanded" class="links-popover">
<a
v-for="link in referenceLinks"
:key="link.url"
class="reference-link"
:href="link.url"
target="_blank"
rel="noopener noreferrer">
<span class="reference-link-label">{{ link.label }}</span>
<FontAwesomeIcon :icon="faExternalLinkAlt" fixed-width />
</a>
</div>
</div>
<span class="meta-tag">{{ getAgentLabel(props.message.agentType) }}</span>
<span v-if="props.message.agentResponse?.metadata?.model" class="meta-tag">
{{ formatModelName(props.message.agentResponse.metadata.model) }}
Expand Down Expand Up @@ -327,6 +445,7 @@ const clarificationOptions = computed<string[]>(() => props.message.agentRespons
}

.meta-right {
position: relative;
display: flex;
align-items: center;
gap: 0.5rem;
Expand Down Expand Up @@ -372,6 +491,79 @@ const clarificationOptions = computed<string[]>(() => props.message.agentRespons
margin-left: 0.25rem;
}

.reference-links {
position: relative;
}

.links-toggle {
display: inline-flex;
align-items: center;
gap: 0.2rem;
min-height: 1.4rem;
padding: 0.125rem 0.35rem;
border: 1px solid rgba($border-color, 0.7);
border-radius: $border-radius-base;
background: $white;
color: $text-light;
font-size: 0.675rem;
line-height: 1;
cursor: pointer;

&:hover {
color: $brand-primary;
border-color: rgba($brand-primary, 0.35);
background: rgba($brand-primary, 0.04);
}
}

.links-count {
font-weight: 600;
}

.links-popover {
position: absolute;
right: 0;
bottom: calc(100% + 0.375rem);
z-index: 2;
display: flex;
flex-direction: column;
min-width: 14rem;
max-width: min(22rem, 70vw);
max-height: 14rem;
overflow-y: auto;
padding: 0.35rem;
border: $border-default;
border-radius: $border-radius-base;
background: $white;
box-shadow: 0 0.25rem 0.75rem rgba($brand-dark, 0.12);
}

.reference-link {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.35rem 0.45rem;
border-radius: $border-radius-base;
color: $text-color;
font-size: 0.75rem;
line-height: 1.25;
text-decoration: none;

&:hover {
color: $brand-primary;
background: rgba($brand-primary, 0.06);
text-decoration: none;
}
}

.reference-link-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

// --- Animation ---
@keyframes entryReveal {
from {
Expand Down
11 changes: 11 additions & 0 deletions lib/galaxy/agents/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ def _capability_for_model(model_name: str, capability: str, table: dict[str, Any
"extract_structured_output",
"extract_usage_info",
"GalaxyAgentDependencies",
"is_url_reachable",
"MAX_HISTORY_MESSAGES",
"normalize_llm_text",
"SimpleGalaxyAgent",
Expand Down Expand Up @@ -293,6 +294,16 @@ def normalize_llm_text(text: str) -> str:
return normalized


def is_url_reachable(url: str) -> bool:
import requests

try:
response = requests.head(url, allow_redirects=True, timeout=5)
return 200 <= response.status_code < 400
except requests.RequestException:
return False


class AgentType:
"""Constants for registered agent types."""

Expand Down
41 changes: 23 additions & 18 deletions lib/galaxy/agents/gtn_training.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
extract_structured_output,
extract_usage_info,
GalaxyAgentDependencies,
is_url_reachable,
normalize_llm_text,
)
from .gtn import GTNSearchDB
Expand Down Expand Up @@ -358,7 +359,8 @@ def _format_gtn_response(self, response_data: GTNSearchResponse) -> str:
parts.append(f" - Difficulty: {difficulty}")
if time_estimation and time_estimation != "Unknown":
parts.append(f" - Time: {time_estimation}")
parts.append(f" - Link: {url}")
if is_url_reachable(url):
parts.append(f" - Link: {url}")

if response_data.faqs:
parts.append("\n**Relevant FAQs:**")
Expand All @@ -376,7 +378,8 @@ def _format_gtn_response(self, response_data: GTNSearchResponse) -> str:
parts.append(f" - Category: {category}")
if area:
parts.append(f" - Area: {area}")
parts.append(f" - Link: {url}")
if is_url_reachable(url):
parts.append(f" - Link: {url}")

if response_data.learning_path:
parts.append(f"\n**Suggested Learning Path:**\n{response_data.learning_path}")
Expand All @@ -397,28 +400,30 @@ def _create_suggestions(self, response_data: GTNSearchResponse) -> list[ActionSu
for tutorial in response_data.tutorials[:3]:
title = tutorial.get("title", "Untitled Tutorial")
url = tutorial.get("url", "#")
suggestions.append(
ActionSuggestion(
action_type=ActionType.VIEW_EXTERNAL,
description=f"Open tutorial: {title}",
parameters={"url": url},
confidence=ConfidenceLevel.HIGH,
priority=1,
if is_url_reachable(url):
suggestions.append(
ActionSuggestion(
action_type=ActionType.VIEW_EXTERNAL,
description=f"Open tutorial: {title}",
parameters={"url": url},
confidence=ConfidenceLevel.HIGH,
priority=1,
)
)
)

for faq in response_data.faqs[:3]:
title = faq.get("title", "Untitled FAQ")
url = faq.get("url", "#")
suggestions.append(
ActionSuggestion(
action_type=ActionType.VIEW_EXTERNAL,
description=f"Open FAQ: {title}",
parameters={"url": url},
confidence=ConfidenceLevel.HIGH,
priority=1 if not response_data.tutorials else 2,
if is_url_reachable(url):
suggestions.append(
ActionSuggestion(
action_type=ActionType.VIEW_EXTERNAL,
description=f"Open FAQ: {title}",
parameters={"url": url},
confidence=ConfidenceLevel.HIGH,
priority=1 if not response_data.tutorials else 2,
)
)
)

topics: set[str] = set()
for t in response_data.tutorials:
Expand Down
Loading