mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 22:17:36 +00:00
fix(web): preserve mcp args and instance labels (#1299)
Co-authored-by: ntduc <ntduc@cpp.ai.vn>
This commit is contained in:
1 parent
64596bf0c1
commit
79822410e8
6 files changed
+345
-25
No files matched your search
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ChannelContact } from "@/types/contact";
|
||||
import { buildAgentInstanceDisplay, getAgentInstanceResolveIds } from "./agent-instance-display-utils";
|
||||
|
||||
function resolver(contacts: Record<string, Partial<ChannelContact>>) {
|
||||
return (id: string): ChannelContact | null => (contacts[id] as ChannelContact | undefined) ?? null;
|
||||
}
|
||||
|
||||
describe("agent instance display utils", () => {
|
||||
it("formats group instances as channel, chat name, and raw instance id", () => {
|
||||
const display = buildAgentInstanceDisplay(
|
||||
{
|
||||
user_id: "group:lark-cppai-pm:oc_123",
|
||||
metadata: { chat_title: "LVC Online" },
|
||||
},
|
||||
resolver({}),
|
||||
);
|
||||
|
||||
expect(display.label).toBe("Lark - LVC Online - oc_123");
|
||||
expect(display.instanceId).toBe("oc_123");
|
||||
});
|
||||
|
||||
it("uses raw group contact names when profile metadata has no title", () => {
|
||||
const display = buildAgentInstanceDisplay(
|
||||
{ user_id: "group:lark-cppai-pm:oc_456" },
|
||||
resolver({
|
||||
oc_456: {
|
||||
sender_id: "oc_456",
|
||||
channel_type: "feishu",
|
||||
display_name: "Peacefullfie",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(display.label).toBe("Lark - Peacefullfie - oc_456");
|
||||
});
|
||||
|
||||
it("does not use sender display names as group names", () => {
|
||||
const display = buildAgentInstanceDisplay(
|
||||
{
|
||||
user_id: "group:lark-cppai-pm:oc_456",
|
||||
metadata: { display_name: "Last Sender" },
|
||||
},
|
||||
resolver({}),
|
||||
);
|
||||
|
||||
expect(display.label).toBe("Lark - oc_456");
|
||||
});
|
||||
|
||||
it("formats direct channel contacts when contact metadata is available", () => {
|
||||
const display = buildAgentInstanceDisplay(
|
||||
{ user_id: "ou_789" },
|
||||
resolver({
|
||||
ou_789: {
|
||||
sender_id: "ou_789",
|
||||
channel_type: "feishu",
|
||||
channel_instance: "lark-cppai-pm",
|
||||
display_name: "Duc Nguyen",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(display.label).toBe("Lark - Duc Nguyen - ou_789");
|
||||
});
|
||||
|
||||
it("falls back to a clean raw id when no channel or name is known", () => {
|
||||
const display = buildAgentInstanceDisplay({ user_id: "system" }, resolver({}));
|
||||
|
||||
expect(display.label).toBe("system");
|
||||
});
|
||||
|
||||
it("detects underscore channel prefixes", () => {
|
||||
const display = buildAgentInstanceDisplay({ user_id: "group:zalo_ol:chat4878" }, resolver({}));
|
||||
|
||||
expect(display.label).toBe("Zalo - chat4878");
|
||||
});
|
||||
|
||||
it("uses raw group ids for contact resolver inputs", () => {
|
||||
expect(
|
||||
getAgentInstanceResolveIds([
|
||||
{ user_id: "group:telegram:-100123" },
|
||||
{ user_id: "group:telegram:-100123" },
|
||||
{ user_id: "ou_abc" },
|
||||
]),
|
||||
).toEqual(["-100123", "ou_abc"]);
|
||||
});
|
||||
|
||||
it("maps tele group instance prefixes to Telegram", () => {
|
||||
const display = buildAgentInstanceDisplay({ user_id: "group:tele-itsdd-local-goclaw:-5104" }, resolver({}));
|
||||
|
||||
expect(display.label).toBe("Telegram - -5104");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import type { ChannelContact } from "@/types/contact";
|
||||
|
||||
interface AgentInstanceLike {
|
||||
user_id: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AgentInstanceDisplay {
|
||||
label: string;
|
||||
instanceId: string;
|
||||
channelLabel: string | null;
|
||||
chatName: string | null;
|
||||
rawUserId: string;
|
||||
}
|
||||
|
||||
type ContactResolver = (id: string) => ChannelContact | null;
|
||||
|
||||
const CHANNEL_PREFIX_LABELS: Array<[string, string]> = [
|
||||
["zalo_personal", "Zalo Personal"],
|
||||
["zalo-personal", "Zalo Personal"],
|
||||
["zalo_oa", "Zalo OA"],
|
||||
["zalo-oa", "Zalo OA"],
|
||||
["bitrix24", "Bitrix24"],
|
||||
["bitrix", "Bitrix24"],
|
||||
["telegram", "Telegram"],
|
||||
["tele", "Telegram"],
|
||||
["tg", "Telegram"],
|
||||
["discord", "Discord"],
|
||||
["whatsapp", "WhatsApp"],
|
||||
["facebook", "Facebook"],
|
||||
["pancake", "Pancake"],
|
||||
["feishu", "Feishu"],
|
||||
["lark", "Lark"],
|
||||
["slack", "Slack"],
|
||||
["zalo", "Zalo"],
|
||||
];
|
||||
|
||||
export function getAgentInstanceResolveIds(instances: AgentInstanceLike[]): string[] {
|
||||
const ids = new Set<string>();
|
||||
|
||||
for (const instance of instances) {
|
||||
const groupTarget = parseGroupTarget(instance.user_id);
|
||||
ids.add(groupTarget?.instanceId || instance.user_id);
|
||||
}
|
||||
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
export function buildAgentInstanceDisplay(
|
||||
instance: AgentInstanceLike,
|
||||
resolve: ContactResolver,
|
||||
): AgentInstanceDisplay {
|
||||
const groupTarget = parseGroupTarget(instance.user_id);
|
||||
const isGroupInstance = Boolean(groupTarget);
|
||||
const contact = resolve(instance.user_id);
|
||||
const rawContact = groupTarget ? resolve(groupTarget.instanceId) : null;
|
||||
const metadata = instance.metadata ?? {};
|
||||
|
||||
const instanceId = groupTarget?.instanceId || contact?.sender_id || instance.user_id;
|
||||
const channelLabel =
|
||||
channelLabelFromSource(groupTarget?.channelSource) ??
|
||||
channelLabelFromSource(metadata.channel_instance) ??
|
||||
channelLabelFromSource(rawContact?.channel_instance) ??
|
||||
channelLabelFromSource(contact?.channel_instance) ??
|
||||
channelLabelFromSource(metadata.channel_type) ??
|
||||
channelLabelFromSource(metadata.channel) ??
|
||||
channelLabelFromSource(metadata.platform) ??
|
||||
channelLabelFromSource(rawContact?.channel_type) ??
|
||||
channelLabelFromSource(contact?.channel_type);
|
||||
|
||||
const metadataTitle =
|
||||
clean(metadata.chat_title) ??
|
||||
clean(metadata.group_title) ??
|
||||
clean(metadata.chat_name);
|
||||
const chatName = isGroupInstance
|
||||
? metadataTitle ?? clean(rawContact?.display_name) ?? clean(rawContact?.username)
|
||||
: metadataTitle ??
|
||||
clean(metadata.display_name) ??
|
||||
clean(rawContact?.display_name) ??
|
||||
clean(rawContact?.username) ??
|
||||
clean(contact?.display_name) ??
|
||||
clean(contact?.username);
|
||||
|
||||
return {
|
||||
label: formatInstanceLabel(channelLabel, chatName, instanceId),
|
||||
instanceId,
|
||||
channelLabel,
|
||||
chatName,
|
||||
rawUserId: instance.user_id,
|
||||
};
|
||||
}
|
||||
|
||||
function parseGroupTarget(userID: string): { channelSource: string; instanceId: string } | null {
|
||||
if (!userID.startsWith("group:")) return null;
|
||||
|
||||
const rest = userID.slice("group:".length);
|
||||
const separatorIndex = rest.indexOf(":");
|
||||
if (separatorIndex < 0) return null;
|
||||
|
||||
const channelSource = rest.slice(0, separatorIndex);
|
||||
const instanceId = rest.slice(separatorIndex + 1);
|
||||
if (!channelSource || !instanceId) return null;
|
||||
|
||||
return { channelSource, instanceId };
|
||||
}
|
||||
|
||||
function channelLabelFromSource(source?: unknown): string | null {
|
||||
const value = clean(source)?.toLowerCase();
|
||||
if (!value) return null;
|
||||
|
||||
for (const [prefix, label] of CHANNEL_PREFIX_LABELS) {
|
||||
if (
|
||||
value === prefix ||
|
||||
value.startsWith(`${prefix}-`) ||
|
||||
value.startsWith(`${prefix}_`) ||
|
||||
value.startsWith(`${prefix}:`)
|
||||
) {
|
||||
return label;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatInstanceLabel(channelLabel: string | null, chatName: string | null, instanceId: string): string {
|
||||
if (channelLabel && chatName) return `${channelLabel} - ${chatName} - ${instanceId}`;
|
||||
if (channelLabel) return `${channelLabel} - ${instanceId}`;
|
||||
if (chatName) return chatName;
|
||||
return instanceId;
|
||||
}
|
||||
|
||||
function clean(value?: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { Textarea } from "@/components/ui/textarea";
|
||||
import { useContactResolver } from "@/hooks/use-contact-resolver";
|
||||
import { useAgentInstances, type UserInstance } from "../hooks/use-agent-instances";
|
||||
import { UserPickerCombobox } from "@/components/shared/user-picker-combobox";
|
||||
import { buildAgentInstanceDisplay, getAgentInstanceResolveIds } from "./agent-instance-display-utils";
|
||||
|
||||
interface AgentInstancesTabProps {
|
||||
agentId: string;
|
||||
@@ -53,13 +54,18 @@ export function AgentInstancesTab({ agentId }: AgentInstancesTabProps) {
|
||||
const isDirty = content !== originalContent;
|
||||
|
||||
// Resolve user_ids to contact names for instances without metadata
|
||||
const instanceUserIDs = useMemo(() => instances.map((i) => i.user_id), [instances]);
|
||||
const instanceUserIDs = useMemo(() => getAgentInstanceResolveIds(instances), [instances]);
|
||||
const { resolve } = useContactResolver(instanceUserIDs);
|
||||
|
||||
// Existing instance user_ids for deduplication
|
||||
const existingIDs = useMemo(() => new Set(instances.map((i) => i.user_id)), [instances]);
|
||||
|
||||
const [addUserId, setAddUserId] = useState("");
|
||||
const selectedInstanceDisplay = useMemo(() => {
|
||||
if (!selected) return null;
|
||||
const selectedInstance = instances.find((inst) => inst.user_id === selected) ?? { user_id: selected };
|
||||
return buildAgentInstanceDisplay(selectedInstance, resolve);
|
||||
}, [instances, resolve, selected]);
|
||||
|
||||
const handleAddUser = (val: string) => {
|
||||
setAddUserId(val);
|
||||
@@ -75,7 +81,7 @@ export function AgentInstancesTab({ agentId }: AgentInstancesTabProps) {
|
||||
return (
|
||||
<div className="flex gap-4" style={{ minHeight: 400 }}>
|
||||
{/* Instance list */}
|
||||
<div className="w-64 shrink-0 space-y-1 overflow-y-auto rounded-md border p-2">
|
||||
<div className="w-80 shrink-0 space-y-1 overflow-y-auto rounded-md border p-2">
|
||||
<div className="px-1 pb-2">
|
||||
<UserPickerCombobox
|
||||
value={addUserId}
|
||||
@@ -121,7 +127,9 @@ export function AgentInstancesTab({ agentId }: AgentInstancesTabProps) {
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">USER.md</span>
|
||||
<span className="text-xs text-muted-foreground">— {selected}</span>
|
||||
<span className="truncate text-xs text-muted-foreground" title={selectedInstanceDisplay?.rawUserId}>
|
||||
— {selectedInstanceDisplay?.label ?? selected}
|
||||
</span>
|
||||
</div>
|
||||
<Textarea
|
||||
className="flex-1 font-mono text-sm"
|
||||
@@ -146,8 +154,7 @@ export function AgentInstancesTab({ agentId }: AgentInstancesTabProps) {
|
||||
|
||||
function InstanceRow({ instance, isSelected, onClick, resolve }: { instance: UserInstance; isSelected: boolean; onClick: () => void; resolve: (id: string) => import("@/types/contact").ChannelContact | null }) {
|
||||
const lastSeen = instance.last_seen_at ? formatRelative(instance.last_seen_at) : null;
|
||||
const contact = resolve(instance.user_id);
|
||||
const displayName = instance.metadata?.display_name || instance.metadata?.chat_title || contact?.display_name || null;
|
||||
const display = buildAgentInstanceDisplay(instance, resolve);
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -157,12 +164,9 @@ function InstanceRow({ instance, isSelected, onClick, resolve }: { instance: Use
|
||||
isSelected ? "bg-accent text-accent-foreground" : "hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<span className="truncate text-xs font-medium">
|
||||
{displayName || instance.user_id}
|
||||
<span className="whitespace-normal break-words text-xs font-medium leading-snug" title={display.label}>
|
||||
{display.label}
|
||||
</span>
|
||||
{displayName && (
|
||||
<span className="truncate font-mono text-2xs text-muted-foreground">{instance.user_id}</span>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{instance.file_count > 0 && (
|
||||
<Badge variant="outline" className="text-2xs">
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatShellArgs, parseShellArgs } from "./mcp-args";
|
||||
|
||||
describe("MCP argv formatting", () => {
|
||||
it("preserves comma-separated values as one argument", () => {
|
||||
const input = "-t preset.default,preset.base.batch,task.v2.task.list";
|
||||
|
||||
expect(parseShellArgs(input)).toEqual([
|
||||
"-t",
|
||||
"preset.default,preset.base.batch,task.v2.task.list",
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders saved args with spaces between argv entries, not commas", () => {
|
||||
const args = [
|
||||
"/path/to/cli.js",
|
||||
"mcp",
|
||||
"-t",
|
||||
"preset.default,preset.base.batch,task.v2.task.list",
|
||||
];
|
||||
|
||||
expect(formatShellArgs(args)).toBe(
|
||||
"/path/to/cli.js mcp -t preset.default,preset.base.batch,task.v2.task.list",
|
||||
);
|
||||
});
|
||||
|
||||
it("round trips quoted arguments containing spaces", () => {
|
||||
const args = ["--flag", "value with spaces", "--json", "{\"ok\":true}", ""];
|
||||
|
||||
expect(parseShellArgs(formatShellArgs(args))).toEqual(args);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
/** Split argv text using shell-like whitespace rules. Commas are literal. */
|
||||
export function parseShellArgs(input: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
let token = "";
|
||||
let tokenStarted = false;
|
||||
let quote: "'" | "\"" | null = null;
|
||||
let escaping = false;
|
||||
|
||||
const pushToken = () => {
|
||||
if (tokenStarted) {
|
||||
tokens.push(token);
|
||||
token = "";
|
||||
tokenStarted = false;
|
||||
}
|
||||
};
|
||||
|
||||
for (const char of input.trim()) {
|
||||
if (escaping) {
|
||||
token += char;
|
||||
tokenStarted = true;
|
||||
escaping = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "\\" && quote !== "'") {
|
||||
tokenStarted = true;
|
||||
escaping = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((char === "'" || char === "\"") && !quote) {
|
||||
tokenStarted = true;
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (quote === char) {
|
||||
quote = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!quote && /\s/.test(char)) {
|
||||
pushToken();
|
||||
continue;
|
||||
}
|
||||
|
||||
token += char;
|
||||
tokenStarted = true;
|
||||
}
|
||||
|
||||
if (escaping) token += "\\";
|
||||
pushToken();
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/** Render argv text without inventing comma separators between arguments. */
|
||||
export function formatShellArgs(args: string[]): string {
|
||||
return args.map(formatShellArg).join(" ");
|
||||
}
|
||||
|
||||
function formatShellArg(arg: string): string {
|
||||
if (arg === "") return "\"\"";
|
||||
if (!/[\s"\\]/.test(arg)) return arg;
|
||||
return `"${arg.replace(/(["\\])/g, "\\$1")}"`;
|
||||
}
|
||||
@@ -16,17 +16,7 @@ import { isValidSlug } from "@/lib/slug";
|
||||
import { mcpFormSchema, type MCPFormData } from "@/schemas/mcp.schema";
|
||||
import { McpConnectionFields } from "./mcp-connection-fields";
|
||||
import { McpSettingsFields } from "./mcp-settings-fields";
|
||||
|
||||
/** Split a string into shell-like tokens, treating commas and spaces outside quotes as delimiters. */
|
||||
function splitShellTokens(input: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
const re = /"([^"]*)"|'([^']*)'|[^\s,]+/g;
|
||||
let m;
|
||||
while ((m = re.exec(input)) !== null) {
|
||||
tokens.push(m[1] ?? m[2] ?? m[0]);
|
||||
}
|
||||
return tokens.filter(Boolean);
|
||||
}
|
||||
import { formatShellArgs, parseShellArgs } from "./mcp-args";
|
||||
|
||||
interface MCPFormDialogProps {
|
||||
open: boolean;
|
||||
@@ -102,7 +92,7 @@ export function MCPFormDialog({ open, onOpenChange, server, onSubmit, onTest, on
|
||||
displayName: server?.display_name ?? "",
|
||||
transport: (server?.transport as MCPFormData["transport"]) ?? "stdio",
|
||||
command: server?.command ?? "",
|
||||
args: Array.isArray(server?.args) ? server.args.join(", ") : "",
|
||||
args: Array.isArray(server?.args) ? formatShellArgs(server.args) : "",
|
||||
url: server?.url ?? "",
|
||||
headers: server?.headers ?? {},
|
||||
env: server?.env ?? {},
|
||||
@@ -132,14 +122,14 @@ export function MCPFormDialog({ open, onOpenChange, server, onSubmit, onTest, on
|
||||
let resolvedCommand = command.trim();
|
||||
|
||||
if (isStdio) {
|
||||
const cmdTokens = splitShellTokens(resolvedCommand);
|
||||
const cmdTokens = parseShellArgs(resolvedCommand);
|
||||
if (cmdTokens.length > 1) {
|
||||
resolvedCommand = cmdTokens[0]!;
|
||||
const extraArgs = cmdTokens.slice(1);
|
||||
const userArgs = args.trim() ? splitShellTokens(args) : [];
|
||||
const userArgs = args.trim() ? parseShellArgs(args) : [];
|
||||
parsedArgs = [...extraArgs, ...userArgs];
|
||||
} else if (args.trim()) {
|
||||
parsedArgs = splitShellTokens(args);
|
||||
parsedArgs = parseShellArgs(args);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user