mirror of
https://github.com/agent0ai/agent-zero.git
synced 2026-08-20 13:55:18 +00:00
Add scoped composer references
Use the existing scoped profile, tool policy, skill, and workspace catalogs for inert @ references in the WebUI composer.
This commit is contained in:
parent
add781d3b3
commit
9c241bdd2c
4 changed files with 493 additions and 30 deletions
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
## Purpose
|
||||
|
||||
- Own the built-in slash command manager and chat composer slash picker.
|
||||
- Own the built-in slash command manager and chat composer slash/reference picker.
|
||||
- Keep file-backed `/command` discovery consistent across project, global, and plugin-provided scopes.
|
||||
|
||||
## Ownership
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
- `api/commands.py` owns the Commands API actions used by the WebUI.
|
||||
- `webui/` owns the manager/editor modal stores, HTML surfaces, and thumbnail asset.
|
||||
- `commands/` owns bundled read-only slash command definitions shipped by `_commands`, including `/stop` agent-run control.
|
||||
- `extensions/` owns the chat composer slash picker and incoming-message command resolution.
|
||||
- `extensions/` owns the chat composer slash and `@` reference picker plus incoming-message command resolution.
|
||||
- `extensions/python/startup_migration/` owns one-time migration from the legacy community `commands` plugin namespace.
|
||||
- `skills/commands-create-slash-command/` owns the agent-facing authoring workflow for reusable slash commands.
|
||||
- `tests/` owns regression coverage for parsing, CRUD, scope precedence, plugin-distributed commands, legacy migration, and skill discovery.
|
||||
|
|
@ -31,6 +31,9 @@
|
|||
- Script commands must expose `run(payload)` and return a string or a dict with `text` and optional `effects`; `show_markdown` effects render as auto-dismissing toast notifications.
|
||||
- Script commands may emit `send_message` with `text` to submit the rendered composer text immediately after command resolution.
|
||||
- Commands accept prefix syntax (`/goal objective`) and exact postfix syntax (`objective /goal`); ordinary mid-sentence mentions are not invocations. The composer picker opens only for prefix syntax, while postfix commands resolve when sent.
|
||||
- Composer `@` selections insert plain references only: `@[./path]`, `@[./folder/]`, `@[agent/profile]`, `@[skill/name]`, or `@[mcp/server]`. They never load content, activate skills, call MCP, or delegate by themselves.
|
||||
- Selected reference icons may use the composer highlight color while their labels keep the normal text color; serialized prompt text remains unchanged.
|
||||
- File and folder references stay inside the active chat workdir and list one directory at a time through the existing file-browser and chat-path APIs. Profile and effective MCP server references reuse their scoped catalogs; skill references use only entries visible in the active chat scope.
|
||||
- WebUI sends resolve through the picker effect path, while backend-originated messages resolve before reaching the agent.
|
||||
- `/stop` uses the same shared cancellation operation as the composer Stop button, including progress cleanup and terminal logging.
|
||||
- `/profile` opens Manage agents without arguments, keeps existing profile
|
||||
|
|
|
|||
|
|
@ -12,46 +12,54 @@
|
|||
<template x-if="$store.commandsSlash.loading">
|
||||
<div class="commands-slash-loading">
|
||||
<x-icon class="spinning" name="progress_activity"></x-icon>
|
||||
<span>Loading slash commands...</span>
|
||||
<span x-text="$store.commandsSlash.loadingLabel"></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="!$store.commandsSlash.loading && $store.commandsSlash.filteredCommands.length > 0">
|
||||
<template x-if="!$store.commandsSlash.loading && $store.commandsSlash.filteredItems.length > 0">
|
||||
<div class="commands-slash-results">
|
||||
<template x-for="(command, index) in $store.commandsSlash.filteredCommands" :key="command.path">
|
||||
<template x-for="(item, index) in $store.commandsSlash.filteredItems" :key="item.id || item.path">
|
||||
<button type="button"
|
||||
class="commands-slash-item"
|
||||
:class="{ active: index === $store.commandsSlash.selectedIndex }"
|
||||
@mouseenter="$store.commandsSlash.selectedIndex = index"
|
||||
@mousedown.prevent
|
||||
@click.prevent="$store.commandsSlash.applySelection(command)">
|
||||
@click.prevent="$store.commandsSlash.applySelectedItem(item)">
|
||||
<div class="commands-slash-item-header">
|
||||
<div class="commands-slash-item-name">
|
||||
<span class="commands-slash-prefix">/</span><span x-text="command.name"></span>
|
||||
<div class="commands-slash-item-name"
|
||||
:class="$store.commandsSlash.mode === 'reference' ? ['is-reference', `is-${item.tone}`] : ''">
|
||||
<template x-if="$store.commandsSlash.mode === 'reference'">
|
||||
<x-icon class="commands-reference-icon" :name="item.icon"></x-icon>
|
||||
</template>
|
||||
<span class="commands-slash-prefix" x-text="$store.commandsSlash.mode === 'reference' ? '@' : '/'"></span><span x-text="$store.commandsSlash.mode === 'reference' ? item.label : item.name"></span>
|
||||
</div>
|
||||
<span class="commands-slash-scope" x-text="command.source_scope_label"></span>
|
||||
<span class="commands-slash-scope"
|
||||
:class="$store.commandsSlash.mode === 'reference' ? ['is-reference', `is-${item.tone}`] : ''"
|
||||
x-text="$store.commandsSlash.mode === 'reference' ? item.kind : item.source_scope_label"></span>
|
||||
</div>
|
||||
<div class="commands-slash-item-description" x-text="command.description"></div>
|
||||
<template x-if="command.argument_hint">
|
||||
<div class="commands-slash-item-hint" x-text="command.argument_hint"></div>
|
||||
<div class="commands-slash-item-description" x-text="item.description"></div>
|
||||
<template x-if="$store.commandsSlash.mode !== 'reference' && item.argument_hint">
|
||||
<div class="commands-slash-item-hint" x-text="item.argument_hint"></div>
|
||||
</template>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="!$store.commandsSlash.loading && $store.commandsSlash.filteredCommands.length === 0">
|
||||
<template x-if="!$store.commandsSlash.loading && $store.commandsSlash.filteredItems.length === 0">
|
||||
<div class="commands-slash-empty">
|
||||
<div class="commands-slash-empty-copy">
|
||||
No matching slash commands.
|
||||
<span x-text="$store.commandsSlash.emptyLabel"></span>
|
||||
</div>
|
||||
<button type="button"
|
||||
class="commands-slash-create"
|
||||
@mousedown.prevent
|
||||
@click.prevent="$store.commandsSlash.openCreateCommand()">
|
||||
<x-icon name="add"></x-icon>
|
||||
<span x-text="$store.commandsSlash.emptyStateLabel"></span>
|
||||
</button>
|
||||
<template x-if="$store.commandsSlash.mode !== 'reference'">
|
||||
<button type="button"
|
||||
class="commands-slash-create"
|
||||
@mousedown.prevent
|
||||
@click.prevent="$store.commandsSlash.openCreateCommand()">
|
||||
<x-icon name="add"></x-icon>
|
||||
<span x-text="$store.commandsSlash.emptyStateLabel"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
|
@ -113,10 +121,26 @@
|
|||
font-weight: 600;
|
||||
}
|
||||
|
||||
.commands-slash-item-name.is-reference {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.commands-reference-icon {
|
||||
margin-right: 0.38rem;
|
||||
font-size: 1.08rem;
|
||||
color: var(--color-highlight);
|
||||
font-variation-settings: 'FILL' 0, 'wght' 450, 'GRAD' 0, 'opsz' 20;
|
||||
}
|
||||
|
||||
.commands-slash-prefix {
|
||||
color: var(--color-highlight);
|
||||
}
|
||||
|
||||
.commands-slash-item-name.is-reference .commands-slash-prefix {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.commands-slash-scope {
|
||||
padding: 0.18rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
|
|
@ -126,6 +150,55 @@
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.commands-slash-scope.is-reference {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.commands-slash-item-name.is-reference,
|
||||
#chat-input .composer-reference {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
#chat-input .composer-reference {
|
||||
display: inline;
|
||||
font-size: 0;
|
||||
font-weight: 600;
|
||||
line-height: inherit;
|
||||
white-space: nowrap;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
#chat-input .composer-reference::before {
|
||||
display: inline-block;
|
||||
color: var(--color-highlight);
|
||||
font-family: 'Material Symbols Outlined';
|
||||
margin-right: 0.22rem;
|
||||
font-size: 1rem;
|
||||
font-weight: normal;
|
||||
line-height: 1;
|
||||
vertical-align: -0.1em;
|
||||
font-variation-settings: 'FILL' 0, 'wght' 450, 'GRAD' 0, 'opsz' 20;
|
||||
}
|
||||
|
||||
#chat-input .composer-reference::after {
|
||||
content: attr(data-label);
|
||||
font-family: var(--font-family-main, "Rubik", Arial, Helvetica, sans-serif);
|
||||
font-size: 1rem;
|
||||
line-height: inherit;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
#chat-input .composer-reference.is-folder::before { content: 'folder'; }
|
||||
#chat-input .composer-reference.is-file::before { content: 'draft'; }
|
||||
#chat-input .composer-reference.is-agent::before { content: 'person'; }
|
||||
#chat-input .composer-reference.is-skill::before { content: 'auto_awesome'; }
|
||||
#chat-input .composer-reference.is-mcp::before { content: 'hub'; }
|
||||
|
||||
html:not(.material-icons-ready) #chat-input .composer-reference::before {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.commands-slash-item-description {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.86rem;
|
||||
|
|
|
|||
|
|
@ -90,10 +90,68 @@ if (path.active) throw new Error("path opened the picker");
|
|||
|
||||
const resolvable = parseSlashInput("objective /goal");
|
||||
if (!resolvable.active || resolvable.query !== "goal") throw new Error("postfix resolution broke");
|
||||
|
||||
const reference = parseReferenceInput("Compare @src/app", 16);
|
||||
if (!reference.active || reference.query !== "src/app" || reference.start !== 8 || reference.end !== 16) throw new Error("reference token not found");
|
||||
|
||||
const middle = parseReferenceInput("Use @src/app then", 12);
|
||||
if (!middle.active || middle.query !== "src/app") throw new Error("caret-local reference not found");
|
||||
|
||||
if (parseReferenceInput("mail@example.test").active) throw new Error("email opened reference picker");
|
||||
if (parseReferenceInput("Use @[./src/app.py]").active) throw new Error("completed reference reopened picker");
|
||||
if (fileQueryDirectory("../secret") !== null) throw new Error("parent traversal accepted");
|
||||
if (fileQueryDirectory("mcp/server") !== null) throw new Error("MCP reference opened file browser");
|
||||
|
||||
const mcp = getMcpReferences({{
|
||||
tools: {{
|
||||
effective_policy: {{ mode: "custom", mcp_default: "block", allowed: ["mcp:allowed:read"], blocked: ["mcp:blocked:read"] }},
|
||||
catalog: [
|
||||
{{ id: "mcp:allowed:read", available: true }},
|
||||
{{ id: "mcp:blocked:read", available: true }},
|
||||
{{ id: "mcp:default-blocked:read", available: true }},
|
||||
{{ id: "mcp:missing:read", available: false }},
|
||||
],
|
||||
}},
|
||||
}});
|
||||
if (JSON.stringify(mcp) !== JSON.stringify([{{ name: "allowed", toolCount: 1 }}])) throw new Error("MCP policy scope leaked");
|
||||
"""
|
||||
subprocess.run(["node", "-e", script], check=True, text=True)
|
||||
|
||||
|
||||
def test_composer_reference_picker_uses_plain_reference_tokens() -> None:
|
||||
plugin_root = Path(__file__).resolve().parents[1]
|
||||
store = (plugin_root / "webui" / "commands-slash-store.js").read_text(encoding="utf-8")
|
||||
menu = (
|
||||
plugin_root / "extensions" / "webui" / "chat-input-box-start" / "commands-menu.html"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
assert "@[agent/${key}]" in store
|
||||
assert "@[skill/${name}]" in store
|
||||
assert "value: `@[${displayPath}]`" in store
|
||||
assert 'icon: isDirectory ? "folder" : "draft"' in store
|
||||
assert 'icon: "person"' in store
|
||||
assert 'icon: "auto_awesome"' in store
|
||||
assert "skills.filter((skill) => !skill?.hidden)" in store
|
||||
assert 'icon: "hub"' in store
|
||||
assert "@[mcp/${name}]" in store
|
||||
assert 'const AGENT_EDITOR_API_PATH = "/plugins/_agent_editor/agent_editor"' in store
|
||||
assert 'action: "list", context_id: contextId' in store
|
||||
assert 'action: "load",' in store
|
||||
assert "getMcpReferences(mcpResult?.state)" in store
|
||||
assert 'mcp_servers_status' not in store
|
||||
assert "composer-reference" in store
|
||||
assert "node.dataset.label = reference.label" in store
|
||||
assert 'callJsonApi("/chat_files_path_get"' in store
|
||||
assert 'callJsonApi("/agents"' not in store
|
||||
assert "filteredItems" in menu
|
||||
assert "#chat-input .composer-reference" in menu
|
||||
assert "content: attr(data-label)" in menu
|
||||
assert "color: var(--color-highlight)" in menu
|
||||
assert "color: var(--color-text)" in menu
|
||||
assert "composer-reference.is-mcp" in menu
|
||||
assert "background: transparent" in menu
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scope_fixture() -> ScopeFixture:
|
||||
suffix = uuid.uuid4().hex[:8]
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { createStore } from "/js/AlpineStore.js";
|
||||
import { callJsonApi } from "/js/api.js";
|
||||
import { callJsonApi, fetchApi } from "/js/api.js";
|
||||
import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
|
||||
import { store as chatInputStore } from "/components/chat/input/input-store.js";
|
||||
import { store as attachmentsStore } from "/components/chat/attachments/attachmentsStore.js";
|
||||
|
|
@ -11,6 +11,8 @@ import {
|
|||
import { store as commandsManagerStore } from "/plugins/_commands/webui/commands-store.js";
|
||||
|
||||
const COMMANDS_API_PATH = "/plugins/_commands/commands";
|
||||
const SKILLS_API_PATH = "/plugins/_skills/skills_catalog";
|
||||
const AGENT_EDITOR_API_PATH = "/plugins/_agent_editor/agent_editor";
|
||||
|
||||
function sanitizeCommandName(rawName) {
|
||||
return (rawName || "")
|
||||
|
|
@ -45,6 +47,58 @@ function parseSlashInput(message, allowPostfix = true) {
|
|||
};
|
||||
}
|
||||
|
||||
function parseReferenceInput(message, caretOffset = undefined) {
|
||||
const text = String(message || "");
|
||||
if (caretOffset === null) return { active: false, query: "", start: 0, end: 0 };
|
||||
const caret = Math.max(0, Math.min(text.length, caretOffset ?? text.length));
|
||||
const match = text.slice(0, caret).match(/(?:^|\s)@([^\s@]*)$/);
|
||||
if (!match) return { active: false, query: "", start: caret, end: caret };
|
||||
if (match[1].startsWith("[") && match[1].endsWith("]")) {
|
||||
return { active: false, query: "", start: caret, end: caret };
|
||||
}
|
||||
|
||||
const token = `@${match[1]}`;
|
||||
return {
|
||||
active: true,
|
||||
query: match[1].toLowerCase(),
|
||||
start: caret - token.length,
|
||||
end: caret,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePath(value) {
|
||||
return String(value || "").replace(/\\/g, "/").replace(/\/{2,}/g, "/").replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function fileQueryDirectory(query) {
|
||||
const value = String(query || "").replace(/^\.\//, "");
|
||||
if (value.startsWith("agent/") || value.startsWith("skill/") || value.startsWith("mcp/") || value.split("/").includes("..")) {
|
||||
return null;
|
||||
}
|
||||
const slash = value.lastIndexOf("/");
|
||||
return slash < 0 ? "" : value.slice(0, slash);
|
||||
}
|
||||
|
||||
function mcpPolicyAllows(policy, id) {
|
||||
if (!policy || policy.mode !== "custom") return true;
|
||||
if (policy.blocked?.includes(id)) return false;
|
||||
if (policy.allowed?.includes(id)) return true;
|
||||
return policy.mcp_default === "allow";
|
||||
}
|
||||
|
||||
function getMcpReferences(state) {
|
||||
const servers = new Map();
|
||||
const policy = state?.tools?.effective_policy;
|
||||
for (const tool of state?.tools?.catalog || []) {
|
||||
const id = String(tool?.id || "");
|
||||
const match = id.match(/^mcp:([^:]+):/);
|
||||
if (!match || tool?.available === false || !mcpPolicyAllows(policy, id)) continue;
|
||||
const name = match[1];
|
||||
servers.set(name, (servers.get(name) || 0) + 1);
|
||||
}
|
||||
return [...servers].map(([name, toolCount]) => ({ name, toolCount }));
|
||||
}
|
||||
|
||||
function notifyError(message) {
|
||||
void toastFrontendError(message, "Commands");
|
||||
}
|
||||
|
|
@ -74,6 +128,13 @@ const model = {
|
|||
loading: false,
|
||||
applying: false,
|
||||
commands: [],
|
||||
references: [],
|
||||
referenceContextId: null,
|
||||
referenceDirectoryKey: "",
|
||||
referenceRoot: "",
|
||||
referenceCatalog: [],
|
||||
referenceFiles: [],
|
||||
referenceLoadGeneration: 0,
|
||||
contextScope: { project_name: "" },
|
||||
lastContextId: "",
|
||||
active: false,
|
||||
|
|
@ -81,6 +142,10 @@ const model = {
|
|||
query: "",
|
||||
rawArguments: "",
|
||||
rawMessage: "",
|
||||
mode: "",
|
||||
referenceStart: 0,
|
||||
referenceEnd: 0,
|
||||
referenceRange: null,
|
||||
selectedIndex: 0,
|
||||
boundInput: null,
|
||||
keydownHandler: null,
|
||||
|
|
@ -104,12 +169,37 @@ const model = {
|
|||
});
|
||||
},
|
||||
|
||||
get filteredReferences() {
|
||||
const needle = (this.query || "").trim().toLowerCase().replace(/^\.\//, "");
|
||||
const references = Array.isArray(this.references) ? this.references : [];
|
||||
if (!needle) return references;
|
||||
return references.filter((reference) => reference.search.includes(needle));
|
||||
},
|
||||
|
||||
get filteredItems() {
|
||||
return this.mode === "reference" ? this.filteredReferences : this.filteredCommands;
|
||||
},
|
||||
|
||||
get selectedCommand() {
|
||||
const commands = this.filteredCommands;
|
||||
if (!commands.length) return null;
|
||||
return commands[this.selectedIndex] || commands[0] || null;
|
||||
},
|
||||
|
||||
get selectedItem() {
|
||||
const items = this.filteredItems;
|
||||
if (!items.length) return null;
|
||||
return items[this.selectedIndex] || items[0] || null;
|
||||
},
|
||||
|
||||
get loadingLabel() {
|
||||
return this.mode === "reference" ? "Loading references..." : "Loading slash commands...";
|
||||
},
|
||||
|
||||
get emptyLabel() {
|
||||
return this.mode === "reference" ? "No matching references." : "No matching slash commands.";
|
||||
},
|
||||
|
||||
get emptyStateLabel() {
|
||||
const name = sanitizeCommandName(this.query || "");
|
||||
return name ? `Create /${name}` : "Create slash command";
|
||||
|
|
@ -146,6 +236,15 @@ const model = {
|
|||
this.query = "";
|
||||
this.rawArguments = "";
|
||||
this.rawMessage = "";
|
||||
this.mode = "";
|
||||
this.referenceRange = null;
|
||||
this.references = [];
|
||||
this.referenceContextId = null;
|
||||
this.referenceDirectoryKey = "";
|
||||
this.referenceRoot = "";
|
||||
this.referenceCatalog = [];
|
||||
this.referenceFiles = [];
|
||||
this.referenceLoadGeneration += 1;
|
||||
this.selectedIndex = 0;
|
||||
this.applying = false;
|
||||
},
|
||||
|
|
@ -233,13 +332,197 @@ const model = {
|
|||
}
|
||||
},
|
||||
|
||||
getCaretOffset() {
|
||||
const input = this.getInputElement();
|
||||
const selection = document.getSelection?.();
|
||||
const range = selection?.rangeCount ? selection.getRangeAt(0) : null;
|
||||
if (range && chatInputStore?._isInCodeBlock?.(range.startContainer?.parentElement)) return null;
|
||||
const offsets = chatInputStore?._selectionOffsets?.(input);
|
||||
return offsets && offsets.start === offsets.end ? offsets.end : null;
|
||||
},
|
||||
|
||||
captureReferenceRange(length) {
|
||||
const input = this.getInputElement();
|
||||
const selection = document.getSelection?.();
|
||||
if (!input || !selection || selection.rangeCount === 0) return null;
|
||||
const range = selection.getRangeAt(0);
|
||||
if (
|
||||
!range.collapsed ||
|
||||
range.startContainer?.nodeType !== Node.TEXT_NODE ||
|
||||
range.startOffset < length ||
|
||||
!input.contains(range.startContainer)
|
||||
) return null;
|
||||
const triggerRange = range.cloneRange();
|
||||
triggerRange.setStart(range.startContainer, range.startOffset - length);
|
||||
return triggerRange;
|
||||
},
|
||||
|
||||
async loadReferences(force = false) {
|
||||
const contextId = this.getContextId();
|
||||
const directory = fileQueryDirectory(this.query);
|
||||
const generation = ++this.referenceLoadGeneration;
|
||||
this.loading = true;
|
||||
|
||||
try {
|
||||
if (force || contextId !== this.referenceContextId) {
|
||||
const [rootResult, settingsResult, skillsResult, profilesResult] = await Promise.allSettled([
|
||||
contextId ? callJsonApi("/chat_files_path_get", { ctxid: contextId }) : Promise.resolve(null),
|
||||
callJsonApi("settings_get", null),
|
||||
callJsonApi(SKILLS_API_PATH, { action: "list", context_id: contextId }),
|
||||
callJsonApi(AGENT_EDITOR_API_PATH, { action: "list", context_id: contextId }),
|
||||
]);
|
||||
if (generation !== this.referenceLoadGeneration) return;
|
||||
|
||||
this.referenceRoot = normalizePath(
|
||||
rootResult.value?.path || settingsResult.value?.settings?.workdir_path || "",
|
||||
);
|
||||
const skills = skillsResult.value?.ok && Array.isArray(skillsResult.value.skills)
|
||||
? skillsResult.value.skills
|
||||
: [];
|
||||
const profiles = profilesResult.value?.ok && Array.isArray(profilesResult.value.profiles)
|
||||
? profilesResult.value.profiles
|
||||
: [];
|
||||
const activeProfile = String(
|
||||
chatsStore.selectedContext?.agent_profile
|
||||
|| settingsResult.value?.settings?.agent_profile
|
||||
|| "",
|
||||
).trim();
|
||||
const activeProfileAvailable = profiles.some((profile) => (
|
||||
profile?.id === activeProfile && profile?.enabled && profile?.available
|
||||
));
|
||||
const mcpResult = activeProfileAvailable
|
||||
? await callJsonApi(AGENT_EDITOR_API_PATH, {
|
||||
action: "load",
|
||||
profile_id: activeProfile,
|
||||
context_id: contextId,
|
||||
}).catch((error) => {
|
||||
console.error("Failed to load scoped MCP references:", error);
|
||||
return null;
|
||||
})
|
||||
: null;
|
||||
if (generation !== this.referenceLoadGeneration) return;
|
||||
const mcpServers = getMcpReferences(mcpResult?.state);
|
||||
this.referenceCatalog = [
|
||||
...profiles.filter((profile) => (
|
||||
profile?.id !== "default" && profile?.enabled && profile?.available
|
||||
)).map((profile) => {
|
||||
const key = String(profile?.id || "").trim();
|
||||
const label = String(profile?.title || key).trim();
|
||||
return {
|
||||
id: `agent:${key}`,
|
||||
kind: "Agent",
|
||||
icon: "person",
|
||||
tone: "agent",
|
||||
label,
|
||||
value: `@[agent/${key}]`,
|
||||
description: key === label ? "Agent profile" : `Agent profile · ${key}`,
|
||||
search: `agent/${key} ${label}`.toLowerCase(),
|
||||
};
|
||||
}).filter((item) => item.id !== "agent:"),
|
||||
...skills.filter((skill) => !skill?.hidden).map((skill) => {
|
||||
const name = String(skill?.name || "").trim();
|
||||
return {
|
||||
id: `skill:${String(skill?.path || name)}`,
|
||||
kind: "Skill",
|
||||
icon: "auto_awesome",
|
||||
tone: "skill",
|
||||
label: name,
|
||||
value: `@[skill/${name}]`,
|
||||
description: String(skill?.description || "Skill").trim(),
|
||||
search: `skill/${name} ${skill?.description || ""} ${skill?.path || ""}`.toLowerCase(),
|
||||
};
|
||||
}).filter((item) => item.label),
|
||||
...mcpServers.map((server) => {
|
||||
const name = String(server?.name || "").trim();
|
||||
const description = `${Number(server?.toolCount || 0)} available MCP tools`;
|
||||
return {
|
||||
id: `mcp:${name}`,
|
||||
kind: "MCP",
|
||||
icon: "hub",
|
||||
tone: "mcp",
|
||||
label: name,
|
||||
value: `@[mcp/${name}]`,
|
||||
description,
|
||||
search: `mcp/${name} ${name} ${description}`.toLowerCase(),
|
||||
};
|
||||
}).filter((item) => item.label),
|
||||
];
|
||||
this.referenceFiles = [];
|
||||
this.referenceContextId = contextId;
|
||||
this.referenceDirectoryKey = "";
|
||||
}
|
||||
|
||||
const directoryKey = directory === null || !this.referenceRoot
|
||||
? ""
|
||||
: `${this.referenceRoot}/${directory}`.replace(/\/$/, "");
|
||||
if (directory !== null && directoryKey && directoryKey !== this.referenceDirectoryKey) {
|
||||
const response = await fetchApi(`/get_work_dir_files?path=${encodeURIComponent(directoryKey)}`);
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (generation !== this.referenceLoadGeneration) return;
|
||||
const entries = response.ok && Array.isArray(payload?.data?.entries) ? payload.data.entries : [];
|
||||
const root = this.referenceRoot.replace(/^\//, "");
|
||||
this.referenceFiles = entries.flatMap((entry) => {
|
||||
const path = normalizePath(entry?.path).replace(/^\//, "");
|
||||
if (!path || (path !== root && !path.startsWith(`${root}/`))) return [];
|
||||
const relative = path === root ? "" : path.slice(root.length + 1);
|
||||
if (!relative) return [];
|
||||
const isDirectory = Boolean(entry?.is_dir);
|
||||
const displayPath = `./${relative}${isDirectory ? "/" : ""}`;
|
||||
return [{
|
||||
id: `${isDirectory ? "folder" : "file"}:${path}`,
|
||||
kind: isDirectory ? "Folder" : "File",
|
||||
icon: isDirectory ? "folder" : "draft",
|
||||
tone: isDirectory ? "folder" : "file",
|
||||
label: displayPath,
|
||||
value: `@[${displayPath}]`,
|
||||
description: isDirectory ? "Folder in active workspace" : "File in active workspace",
|
||||
search: displayPath.toLowerCase(),
|
||||
}];
|
||||
});
|
||||
this.referenceDirectoryKey = directoryKey;
|
||||
} else if (directory === null) {
|
||||
this.referenceFiles = [];
|
||||
this.referenceDirectoryKey = "";
|
||||
}
|
||||
|
||||
if (generation === this.referenceLoadGeneration) {
|
||||
this.references = [...this.referenceFiles, ...this.referenceCatalog];
|
||||
this.ensureSelection();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load composer references:", error);
|
||||
if (generation === this.referenceLoadGeneration) {
|
||||
this.references = [...this.referenceCatalog];
|
||||
}
|
||||
} finally {
|
||||
if (generation === this.referenceLoadGeneration) this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
handleInput(event = null) {
|
||||
this.ensureBindings();
|
||||
this.dismissed = false;
|
||||
|
||||
const message = this.getInputMessage(event);
|
||||
const reference = parseReferenceInput(message, this.getCaretOffset());
|
||||
if (reference.active) {
|
||||
const newReferenceSession = this.mode !== "reference";
|
||||
this.mode = "reference";
|
||||
this.active = true;
|
||||
this.query = reference.query;
|
||||
this.rawMessage = message;
|
||||
this.referenceStart = reference.start;
|
||||
this.referenceEnd = reference.end;
|
||||
this.referenceRange = this.captureReferenceRange(reference.end - reference.start);
|
||||
this.ensureSelection();
|
||||
void this.loadReferences(newReferenceSession);
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = parseSlashInput(message, false);
|
||||
|
||||
this.referenceRange = null;
|
||||
this.mode = parsed.active ? "slash" : "";
|
||||
this.active = parsed.active;
|
||||
this.query = parsed.query;
|
||||
this.rawArguments = parsed.rawArguments;
|
||||
|
|
@ -297,33 +580,79 @@ const model = {
|
|||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Enter" && this.selectedCommand) {
|
||||
if (event.key === "Enter" && this.selectedItem) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void this.applySelection(this.selectedCommand);
|
||||
void this.applySelectedItem(this.selectedItem);
|
||||
}
|
||||
},
|
||||
|
||||
ensureSelection() {
|
||||
const commands = this.filteredCommands;
|
||||
if (!commands.length) {
|
||||
const items = this.filteredItems;
|
||||
if (!items.length) {
|
||||
this.selectedIndex = 0;
|
||||
return;
|
||||
}
|
||||
if (this.selectedIndex >= commands.length) {
|
||||
if (this.selectedIndex >= items.length) {
|
||||
this.selectedIndex = 0;
|
||||
}
|
||||
},
|
||||
|
||||
moveSelection(delta) {
|
||||
const commands = this.filteredCommands;
|
||||
if (!commands.length) return;
|
||||
const items = this.filteredItems;
|
||||
if (!items.length) return;
|
||||
const nextIndex =
|
||||
(this.selectedIndex + delta + commands.length) % commands.length;
|
||||
(this.selectedIndex + delta + items.length) % items.length;
|
||||
this.selectedIndex = nextIndex;
|
||||
this.scrollSelectedIntoView();
|
||||
},
|
||||
|
||||
applySelectedItem(item) {
|
||||
return this.mode === "reference" ? this.applyReference(item) : this.applySelection(item);
|
||||
},
|
||||
|
||||
applyReference(reference) {
|
||||
const input = this.getInputElement();
|
||||
if (!reference?.value || !input) return;
|
||||
|
||||
const current = this.getInputMessage();
|
||||
const suffix = current.slice(this.referenceEnd);
|
||||
const separator = suffix && /^\s/.test(suffix) ? "" : " ";
|
||||
const nextText = `${current.slice(0, this.referenceStart)}${reference.value}${separator}${suffix}`;
|
||||
const caret = this.referenceStart + reference.value.length + separator.length;
|
||||
const range = this.referenceRange;
|
||||
this.referenceRange = null;
|
||||
if (range && input.contains(range.startContainer)) {
|
||||
range.deleteContents();
|
||||
const node = document.createElement("span");
|
||||
node.className = `composer-reference is-${reference.tone}`;
|
||||
node.dataset.reference = reference.value;
|
||||
node.dataset.label = reference.label;
|
||||
node.contentEditable = "false";
|
||||
node.textContent = reference.value;
|
||||
node.setAttribute("aria-label", `${reference.kind}: ${reference.label}`);
|
||||
range.insertNode(node);
|
||||
const space = separator ? document.createTextNode(separator) : null;
|
||||
if (space) node.after(space);
|
||||
range.setStartAfter(space || node);
|
||||
range.collapse(true);
|
||||
const selection = document.getSelection?.();
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
chatInputStore?._syncMessageFromEditor?.();
|
||||
} else {
|
||||
chatInputStore.message = nextText;
|
||||
chatInputStore?._setEditorCaret?.(caret);
|
||||
}
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
chatInputStore.adjustTextareaHeight();
|
||||
this.active = false;
|
||||
this.dismissed = false;
|
||||
this.mode = "";
|
||||
this.query = "";
|
||||
this.selectedIndex = 0;
|
||||
},
|
||||
|
||||
scrollSelectedIntoView() {
|
||||
requestAnimationFrame(() => {
|
||||
document
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue