feat(chat): support chat resources, implement chat-based remote discovery via resource messages

This commit is contained in:
Shivering Ocelot committed 2026-09-02 16:34:12 -05:00
1 parent da1420d04b
commit 30afcf2803
10 files changed
+142 -85

No files matched your search

+34
View File
@@ -32,6 +32,8 @@ function chatEntryClass(msg) {
return ['note'];
} else if (msg.type == 'message') {
return ['message', msg.sender == 'user' ? 'sent' : 'received'];
} else if (msg.type == 'resource') {
return ['resource', 'resource-' + (msg.subkind || 'generic')];
} else if (msg.type == 'log') {
return ['message', 'log'];
} else {
@@ -39,6 +41,11 @@ function chatEntryClass(msg) {
}
}
function resourceLabel(msg) {
if (msg.subkind == 'remote') return 'Remote';
return msg.subkind || 'Resource';
}
const format = ref(null);
const content = ref("");
@@ -79,6 +86,11 @@ watch(() => [props.message.format, props.message.content], ([newFormat, newConte
<div class="content">{{content}}</div>
</template>
<template v-if="message.type == 'resource'">
<div class="resource-label">{{ resourceLabel(message) }}</div>
<div class="content">{{content}}</div>
</template>
<template v-if="message.type == 'log'">
<div class="sender">Log ({{message.sender}})</div>
<div class="content">{{content}}</div>
@@ -143,4 +155,26 @@ watch(() => [props.message.format, props.message.content], ([newFormat, newConte
font-family: monospace;
font-size: 80%;
}
.resource {
align-self: center;
width: fit-content;
padding: 4px 12px;
border-radius: 12px;
border: 1px dashed var(--color-border);
background-color: var(--color-background-soft);
font-size: 85%;
display: flex;
gap: 0.5em;
align-items: baseline;
}
.resource .resource-label {
font-weight: bold;
opacity: 60%;
}
.resource .content {
font-family: monospace;
}
</style>
+42 -2
View File
@@ -84,6 +84,7 @@
for (let entry of messages.value) {
if (entry.id == it.id) {
entry.type = it.kind;
entry.subkind = it.subkind;
entry.format = it.format;
entry.content = it.content;
entry.threadId = it.threadId;
@@ -109,6 +110,7 @@
addMessage({
id: it.id,
type: it.kind,
subkind: it.subkind,
format: it.format,
sender: it.sender,
content: it.content,
@@ -124,6 +126,7 @@
//id: it.id,
id: _messageNonce++,
type: it.kind,
subkind: it.subkind,
sender: it.sender,
content: it.content,
threadId: it.threadId,
@@ -296,8 +299,8 @@
// New message object with updated name
const message = Object.assign({}, _msg, {sender: senderName(_msg.sender)});
// Non-threaded messages and notes go directly to the entry list
if (message.threadId == null || message.type == 'note') {
// Non-threaded messages, notes, and resources go directly to the entry list
if (message.threadId == null) {
entries.push(message);
} else {
@@ -343,6 +346,40 @@
}
}
async function addRemoteResource () {
const res = await backend.sendAction('remote-list');
const remotes = res.remotes || [];
if (remotes.length == 0) {
ui.alert("No remotes connected");
return;
}
const selected = await ui.contextMenu(remotes.map(r => ({
value: r.name,
title: r.name + (r.description ? ` — ${r.description}` : ''),
})));
if (!selected) return;
const threadId = currentThreadId.value;
backend.sendAction("send-chat-message", {
chatId, content: selected, threadId,
kind: 'resource', subkind: 'remote',
}).then((response) => {
if (response['error']) {
ui.alert("Error adding remote: " + response['error']);
}
});
// Optimistic entry, matched against the server broadcast by content
addMessage({
type: 'resource',
subkind: 'remote',
sender: 'user',
content: selected,
threadId,
}, {unsent: true});
}
function editMessage (message) {
let elem = inputFieldElem.value;
if (message) {
@@ -475,6 +512,9 @@
<button @click="startNewThread()">
New Thread
</button>
<button @click="addRemoteResource()">
Add Remote
</button>
<button @click="noteMode=!noteMode">
{{noteMode ? 'Cancel Note' : 'Make Note'}}
</button>
+3 -68
View File
@@ -5,72 +5,7 @@ import * as backend from '../backend';
import * as ui from '../ui';
/** Connected external tool remotes (initialized with mock data) */
const remotes = ref([/*
{
name: 'weather-service',
connected: true,
functions: [
{
name: 'get_forecast',
description: 'Retrieve weather forecast for a location.',
parameters: {
type: 'object',
properties: {
city: { type: 'string', description: 'City name or code.' },
days: { type: 'number', description: 'Number of days ahead.' }
},
required: ['city']
}
},
{
name: 'search_storms',
description: 'Find active storm systems near coordinates.',
parameters: {
type: 'object',
properties: {
lat: { type: 'number', description: 'Latitude.' },
lon: { type: 'number', description: 'Longitude.' },
radius_km: { type: 'number', description: 'Search radius.' }
},
required: ['lat', 'lon']
}
}
],
agentIds: []
},
{
name: 'code-repo-indexer',
connected: true,
functions: [
{
name: 'search_codebase',
description: 'Search repository code by query.',
parameters: {
type: 'object',
properties: {
query: { type: 'string', description: 'Search text.' },
lang: { type: 'string', description: 'Language filter.' },
path_regex: { type: 'string', description: 'Path pattern.' }
},
required: ['query']
}
},
{
name: 'find_symbol',
description: 'Locate a symbol definition in the codebase.',
parameters: {
type: 'object',
properties: {
symbol_name: { type: 'string', description: 'Name of the symbol.' },
file_path: { type: 'string', description: 'Restrict to a file.' }
},
required: ['symbol_name']
}
}
],
agentIds: []
}
*/]);
const remotes = ref([]);
/** Tracks expanded remote panels using a Set to support multiple open sections */
const expandedRemoteIds = ref(new Set());
@@ -113,7 +48,7 @@ onMounted(async () => {
remotes.value = remotes.value.filter((r) => r.name != ev.data.remotename);
});
if (!callListenerCancel) {
if (callListenerCancel == null) {
callListenerCancel = backend.onAction('remote-call', async ({ requestId, remotename, function: fnName, args }) => {
console.log(`Received remote call for: ${remotename}, ${fnName}`);
const local = localRemoteList.value.find(r => r.name == remotename && r.connected);
@@ -448,7 +383,7 @@ function toggleFnExpanded(index, fIdx) {
<div v-if="!remote.functions || remote.functions.length === 0" style="color:var(--color-text-mute);font-size:0.85em;">No functions yet.</div>
<div v-for="(fn, fIdx) in remote.functions" :key="fIdx" class="func-def-block">
<div class="func-def-header">
<strong>{{ fn.name }}</strong>
<input type="text" v-model="fn.name" class="input-field"/>
<button class="btn btn-danger btn-sm" @click="removeFunctionFromRemote(remote, fIdx)">Remove</button>
<span style="cursor:pointer;" @click.stop="toggleFnExpanded(index, fIdx)">
{{ fnExpandedSet.has(`${index}-${fIdx}`) ? 'collapse' : 'expand' }}