ui: make resource links visible and interactive

This commit is contained in:
Lucio Lelii 2026-09-29 11:41:57 +02:00
parent 67d6bc6b71
commit 95ea013139
9 changed files with 171 additions and 20 deletions

View File

@ -33,7 +33,7 @@ export class CustomSocket implements AfterViewInit, OnDestroy {
@HostBinding("style.width")
get width() {
return this.kind === "resource" ? "9px" : "8px";
return this.kind === "resource" ? "7px" : "8px";
}
@HostBinding("style.height")
@ -42,7 +42,9 @@ export class CustomSocket implements AfterViewInit, OnDestroy {
}
@HostBinding("style.display") d = "block";
@HostBinding("style.borderRadius") br = "999px";
@HostBinding("style.borderRadius") get borderRadius() {
return this.kind === "resource" ? "4px" : "999px";
}
@HostBinding("style.border") border = "1px solid rgba(255,255,255,0.9)";
@HostBinding("style.cursor") get cursor() {
return this.data?.__readonly === true ? "default" : "crosshair";
@ -55,7 +57,7 @@ export class CustomSocket implements AfterViewInit, OnDestroy {
@HostBinding("style.background")
get bg() {
if (this.kind === "resource") {
return "linear-gradient(145deg, #2dd4bf 0%, #0f766e 100%)";
return "linear-gradient(180deg, #5eead4 0%, #0f766e 100%)";
}
if (this.kind === "dependency") {
return this.side === "input"
@ -70,7 +72,7 @@ export class CustomSocket implements AfterViewInit, OnDestroy {
@HostBinding("style.boxShadow")
get sh() {
if (this.kind === "resource") {
return "0 2px 6px rgba(15,118,110,0.45), 0 0 0 1px rgba(15,118,110,0.45)";
return "0 1px 4px rgba(15,118,110,0.32), 0 0 0 1px rgba(13,148,136,0.42)";
}
if (this.kind === "dependency") {
const c = this.side === "input" ? "rgba(217,119,6,0.45)" : "rgba(37,99,235,0.45)";
@ -86,7 +88,7 @@ export class CustomSocket implements AfterViewInit, OnDestroy {
}
private resolveSocketKind(): "data" | "dependency" | "resource" {
const kind = this.data?.__hfKind;
const kind = this.data?.__hfKind ?? this.data?.payload?.__hfKind;
return kind === "dependency" || kind === "resource" ? kind : "data";
}

View File

@ -595,6 +595,32 @@
gap: 0;
}
.llm-body-resource .llm-column {
grid-column: 1 / -1;
}
/* The Resource pill is the connection target itself, rather than having a separate tiny handle. */
.llm-row-resource {
position: relative;
}
.llm-row-resource .llm-pill-resource {
position: absolute;
inset: 0;
left: 0;
right: 0;
z-index: 21;
}
.llm-row-resource .llm-pill-resource ::ng-deep > * {
position: absolute !important;
inset: 0;
width: 100% !important;
height: 100% !important;
margin: 0 !important;
opacity: 0;
}
.llm-socket {
position: relative;
left: -16px;
@ -1024,7 +1050,26 @@
}
.llm-pill-resource {
width: 100%;
justify-content: center;
text-align: center;
border-color: #5eead4;
background: #f0fdfa;
background: linear-gradient(135deg, #f0fdfa 0%, #ccfbf1 100%);
color: #0f766e;
font-family: var(--font-sans);
font-size: 10px;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.llm-pill-resource--connected {
border-color: #14b8a6;
background: linear-gradient(135deg, #ccfbf1 0%, #99f6e4 100%);
box-shadow: inset 0 0 0 1px rgba(13, 148, 136, 0.14);
}
.llm-pill-resource--connected .bi {
font-size: 14px;
line-height: 1;
}

View File

@ -213,12 +213,13 @@
@if (resourceLinkInput) {
<div class="llm-body llm-body-dependency llm-body-resource">
<div class="llm-column">
<div class="llm-column-title">Resource</div>
@if (resourceLinkInput; as resourceLinkInput) {
<div class="llm-row llm-row-input">
<div
<div class="llm-row llm-row-input llm-row-resource">
<span
refComponent
class="llm-socket"
class="llm-pill llm-pill-input llm-pill-resource"
[class.llm-pill-resource--connected]="resourceLinkIsConnected"
[attr.title]="resourceLinkIsConnected ? 'Resource connected — drag another Resource here to replace it' : 'Drag a Resource here'"
[data]="{
type: 'socket',
side: 'input',
@ -228,8 +229,13 @@
payload: resourceLinkInput.socket
}"
[emit]="emit">
</div>
<span class="llm-pill llm-pill-input llm-pill-resource">Works on</span>
@if (resourceLinkIsConnected) {
<i class="bi bi-link-45deg" aria-hidden="true"></i>
Resource connected
} @else {
Resource
}
</span>
</div>
}
</div>

View File

@ -273,6 +273,14 @@ export class GenericNodeComponent implements OnDestroy {
return input ? { key: FLOW_RESOURCE_LINK_PORT_KEY, socket: (input as any).socket as ClassicPreset.Socket } : null;
}
/** A resource link is single-valued; the canvas stores the selected resource in this field. */
get resourceLinkIsConnected(): boolean {
const nodeData = this.data?.data as Record<string, any> | undefined;
const field = nodeData?.['__resourceRefField'];
const value = typeof field === 'string' ? nodeData?.['specificConfiguration']?.[field] : null;
return typeof value === 'string' && value.trim().length > 0;
}
get dependantOutput(): RenderedSocketPort | null {
const output = this.data?.outputs?.[FLOW_DEPENDANT_PORT_KEY];
return output

View File

@ -284,9 +284,9 @@
/* The socket sizes itself as a square; here it is stretched into the bar. */
.resource-card__socket ::ng-deep > * {
width: 9px !important;
width: 7px !important;
height: 100% !important;
border-radius: 999px !important;
border-radius: 4px !important;
}
.llm-spinner {

View File

@ -504,6 +504,28 @@ button.llm-node-bias-summary:focus-visible {
gap: 0;
}
/* A storage Resource is selected through the whole labelled pill, not a separate handle. */
.llm-row-resource {
position: relative;
}
.llm-row-resource .llm-pill-resource {
position: absolute;
inset: 0;
left: 0;
right: 0;
z-index: 21;
}
.llm-row-resource .llm-pill-resource ::ng-deep > * {
position: absolute !important;
inset: 0;
width: 100% !important;
height: 100% !important;
margin: 0 !important;
opacity: 0;
}
.llm-socket {
position: relative;
left: -16px;
@ -846,7 +868,26 @@ button.llm-node-bias-summary:focus-visible {
}
.llm-pill-resource {
width: 100%;
justify-content: center;
text-align: center;
border-color: #5eead4;
background: #f0fdfa;
background: linear-gradient(135deg, #f0fdfa 0%, #ccfbf1 100%);
color: #0f766e;
font-family: var(--font-sans);
font-size: 10px;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.llm-pill-resource--connected {
border-color: #14b8a6;
background: linear-gradient(135deg, #ccfbf1 0%, #99f6e4 100%);
box-shadow: inset 0 0 0 1px rgba(13, 148, 136, 0.14);
}
.llm-pill-resource--connected .bi {
font-size: 14px;
line-height: 1;
}

View File

@ -254,10 +254,12 @@
<div class="llm-column">
<div class="llm-column-title">Storage</div>
@if (storageLinkInput; as storageLinkInput) {
<div class="llm-row llm-row-input">
<div
<div class="llm-row llm-row-input llm-row-resource">
<span
refComponent
class="llm-socket"
class="llm-pill llm-pill-input llm-pill-resource"
[class.llm-pill-resource--connected]="storageLinkIsConnected"
[attr.title]="storageLinkIsConnected ? 'Resource connected' : 'Resource'"
[data]="{
type: 'socket',
side: 'input',
@ -267,8 +269,13 @@
payload: storageLinkInput.socket
}"
[emit]="emit">
</div>
<span class="llm-pill llm-pill-input llm-pill-resource">Works on</span>
@if (storageLinkIsConnected) {
<i class="bi bi-link-45deg" aria-hidden="true"></i>
Resource connected
} @else {
Resource
}
</span>
</div>
}
</div>

View File

@ -174,6 +174,13 @@ export class TaskStepNodeComponent {
return input ? { key: FLOW_RESOURCE_LINK_PORT_KEY, socket: (input as any).socket as ClassicPreset.Socket } : null;
}
get storageLinkIsConnected(): boolean {
const nodeData = this.data?.data as Record<string, any> | undefined;
const field = nodeData?.['__resourceRefField'];
const value = typeof field === 'string' ? nodeData?.['specificConfiguration']?.[field] : null;
return typeof value === 'string' && value.trim().length > 0;
}
get dependantOutput(): { key: string; socket: ClassicPreset.Socket } | null {
const output = this.data?.outputs?.[FLOW_DEPENDANT_PORT_KEY];
return output

View File

@ -215,6 +215,13 @@ export async function createEditor(
return context;
});
editor.addPipe(async (context) => {
if (context.type === "connectioncreated" || context.type === "connectionremoved") {
await syncResourceLinkPresentation(editor, area, runtime, context.data);
}
return context;
});
area.addPipe((context: any) => {
if (context?.type === 'nodetranslated') {
// Any node moved can be one the way back has to clear, not only one of its two ends.
@ -838,6 +845,34 @@ async function replaceResourceLink(editor: NodeEditor<HFSchemes>, stepNodeId: st
}
}
/** Keep a node's visible Resource state in step with the one allowed resource connection. */
async function syncResourceLinkPresentation(
editor: NodeEditor<HFSchemes>,
area: AreaPlugin<HFSchemes, AreaExtra>,
runtime: ReteRuntimeContext,
connection: Pick<ClassicPreset.Connection<ClassicPreset.Node, ClassicPreset.Node>, 'source' | 'sourceOutput' | 'target' | 'targetInput'>
) {
if (getGraphConnectionKind(connection.sourceOutput, connection.targetInput) !== 'resource') return;
const target = editor.getNode(connection.target) as HFNode | undefined;
const reference = resourceRefOf(runtime, target);
if (!target?.data || !reference) return;
const activeLink = editor.getConnections().find((candidate) =>
candidate.target === target.id && candidate.targetInput === FLOW_RESOURCE_LINK_PORT_KEY);
const source = activeLink ? editor.getNode(activeLink.source) as HFNode | undefined : undefined;
const resourceId = typeof source?.data?.id === 'string' ? source.data.id : null;
const targetData = target.data as Record<string, any>;
const configuration = targetData['specificConfiguration'] as Record<string, unknown> | undefined;
if (configuration?.[reference.field] === resourceId) return;
targetData['specificConfiguration'] = {
...(configuration ?? {}),
[reference.field]: resourceId
};
await area.update('node', target.id);
}
async function withRestoredConnections<T>(runtime: ReteRuntimeContext | undefined, restore: () => Promise<T>): Promise<T> {
if (!runtime) return restore();
runtime.restoringConnections = (runtime.restoringConnections ?? 0) + 1;