mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 04:14:56 +00:00
refactor(channels): unify tab layout widths and schema-driven group overrides
- Remove redundant max-w-* constraints from General, Credentials, Groups, and Managers tabs so all tabs fill the parent container equally - Refactor TelegramGroupFields to use ChannelFields with groupOverrideSchema instead of manually rendered form fields - Add tristate, textarea, tool-select, and skill-select field types to ChannelFields renderer - Create SkillNameSelect multi-select component (mirrors ToolNameSelect) - Wire listManagerGroups into Groups tab for quick-add known groups - Add i18n keys for knownGroups and selectOrTypeSkills (en/vi/zh)
This commit is contained in:
1 parent
b9b8c8f8aa
commit
bc9ebaa2d0
16 files changed
+374
-174
No files matched your search
@@ -0,0 +1,157 @@
|
||||
import { useMemo, useState, useRef, useEffect, useLayoutEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { X, ChevronDownIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSkills } from "@/pages/skills/hooks/use-skills";
|
||||
|
||||
interface SkillNameSelectProps {
|
||||
value: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SkillNameSelect({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
className,
|
||||
}: SkillNameSelectProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const { skills } = useSkills();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties>({});
|
||||
|
||||
const allSkills = useMemo(() => {
|
||||
return skills.map((s) => ({ name: s.name, description: s.description }));
|
||||
}, [skills]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.toLowerCase();
|
||||
return allSkills
|
||||
.filter((s) => !value.includes(s.name))
|
||||
.filter((s) => !q || s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q));
|
||||
}, [allSkills, value, search]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open || !containerRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
setDropdownStyle({
|
||||
position: "fixed",
|
||||
top: rect.bottom + 4,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
zIndex: 9999,
|
||||
});
|
||||
}, [open, search]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
const target = e.target as Node;
|
||||
if (
|
||||
containerRef.current && !containerRef.current.contains(target) &&
|
||||
(!dropdownRef.current || !dropdownRef.current.contains(target))
|
||||
) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [open]);
|
||||
|
||||
const addSkill = (name: string) => {
|
||||
if (!value.includes(name)) {
|
||||
onChange([...value, name]);
|
||||
}
|
||||
setSearch("");
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const removeSkill = (name: string) => {
|
||||
onChange(value.filter((v) => v !== name));
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter" || e.key === ",") {
|
||||
e.preventDefault();
|
||||
const trimmed = search.trim().replace(/,$/, "");
|
||||
if (trimmed) addSkill(trimmed);
|
||||
}
|
||||
if (e.key === "Backspace" && !search && value.length > 0) {
|
||||
removeSkill(value[value.length - 1]!);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={cn("relative", className)}>
|
||||
<div
|
||||
className={cn(
|
||||
"border-input dark:bg-input/30 flex min-h-9 flex-wrap items-center gap-1 rounded-md border bg-transparent px-2 py-1 text-sm shadow-xs transition-[color,box-shadow]",
|
||||
"focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-2",
|
||||
)}
|
||||
onClick={() => inputRef.current?.focus()}
|
||||
>
|
||||
{value.map((name) => (
|
||||
<span
|
||||
key={name}
|
||||
className="bg-secondary text-secondary-foreground inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-xs"
|
||||
>
|
||||
{name}
|
||||
<button
|
||||
type="button"
|
||||
className="hover:text-destructive ml-0.5"
|
||||
onClick={(e) => { e.stopPropagation(); removeSkill(name); }}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
if (!open) setOpen(true);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={value.length === 0 ? (placeholder ?? t("selectOrTypeSkills")) : ""}
|
||||
className="placeholder:text-muted-foreground min-w-[80px] flex-1 bg-transparent py-0.5 text-sm outline-none"
|
||||
/>
|
||||
<ChevronDownIcon
|
||||
className="text-muted-foreground size-4 shrink-0 cursor-pointer opacity-50"
|
||||
onClick={() => setOpen(!open)}
|
||||
/>
|
||||
</div>
|
||||
{open && filtered.length > 0 && createPortal(
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
style={dropdownStyle}
|
||||
className="bg-popover text-popover-foreground pointer-events-auto max-h-60 overflow-y-auto rounded-md border p-1 shadow-md"
|
||||
>
|
||||
{filtered.map((s) => (
|
||||
<button
|
||||
key={s.name}
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => addSkill(s.name)}
|
||||
className="hover:bg-accent hover:text-accent-foreground flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none"
|
||||
>
|
||||
<span className="shrink-0 font-medium">{s.name}</span>
|
||||
{s.description && (
|
||||
<span className="text-muted-foreground truncate text-xs">{s.description}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -240,6 +240,7 @@
|
||||
"groupWildcard": "* (wildcard)",
|
||||
"addGroupPlaceholder": "Chat ID (e.g. -100123456) or *",
|
||||
"addGroup": "Add Group",
|
||||
"knownGroups": "Known groups",
|
||||
"topicOverrides": "Topic Overrides",
|
||||
"topicLabel": "Topic: {{id}}",
|
||||
"addTopicPlaceholder": "Topic thread ID",
|
||||
|
||||
@@ -102,5 +102,6 @@
|
||||
"builtinTools": "Built-in",
|
||||
"customTools": "Custom",
|
||||
"selectOrTypeTools": "Select or type tool names...",
|
||||
"selectOrTypeSkills": "Select or type skill names...",
|
||||
"typeAndPressEnter": "Type and press Enter..."
|
||||
}
|
||||
@@ -240,6 +240,7 @@
|
||||
"groupWildcard": "* (ký tự đại diện)",
|
||||
"addGroupPlaceholder": "ID cuộc trò chuyện (vd: -100123456) hoặc *",
|
||||
"addGroup": "Thêm nhóm",
|
||||
"knownGroups": "Nhóm đã biết",
|
||||
"topicOverrides": "Ghi đè chủ đề",
|
||||
"topicLabel": "Chủ đề: {{id}}",
|
||||
"addTopicPlaceholder": "ID luồng chủ đề",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"selectAgent": "Chọn agent",
|
||||
"selectFileToView": "Chọn một tệp để xem nội dung",
|
||||
"selectOrTypeTools": "Chọn hoặc nhập tên công cụ...",
|
||||
"selectOrTypeSkills": "Chọn hoặc nhập tên kỹ năng...",
|
||||
"selectProvider": "Chọn provider",
|
||||
"sendMessage": "Gửi tin nhắn... (Shift+Enter để xuống dòng)",
|
||||
"sendMessageTitle": "Gửi tin nhắn",
|
||||
|
||||
@@ -240,6 +240,7 @@
|
||||
"groupWildcard": "* (通配符)",
|
||||
"addGroupPlaceholder": "聊天 ID(例如 -100123456)或 *",
|
||||
"addGroup": "添加群组",
|
||||
"knownGroups": "已知群组",
|
||||
"topicOverrides": "话题覆盖",
|
||||
"topicLabel": "话题: {{id}}",
|
||||
"addTopicPlaceholder": "话题线程 ID",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"selectAgent": "选择Agent",
|
||||
"selectFileToView": "选择文件以查看其内容",
|
||||
"selectOrTypeTools": "选择或输入工具名称...",
|
||||
"selectOrTypeSkills": "选择或输入技能名称...",
|
||||
"selectProvider": "选择Provider",
|
||||
"sendMessage": "发送消息... (Shift+Enter 换行)",
|
||||
"sendMessageTitle": "发送消息",
|
||||
|
||||
@@ -49,7 +49,7 @@ export function ChannelCredentialsTab({ instance, onUpdate }: ChannelCredentials
|
||||
|
||||
if (fields.length === 0) {
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<div className="">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("detail.credentials.noSchema")}
|
||||
</p>
|
||||
@@ -58,7 +58,7 @@ export function ChannelCredentialsTab({ instance, onUpdate }: ChannelCredentials
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
<div className="space-y-6">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("detail.credentials.hint")}
|
||||
</p>
|
||||
|
||||
@@ -126,6 +126,7 @@ export function ChannelDetailPage({ instanceId, onBack }: ChannelDetailPageProps
|
||||
<ChannelGroupsTab
|
||||
instance={instance}
|
||||
onUpdate={updateInstance}
|
||||
listManagerGroups={listManagerGroups}
|
||||
/>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
@@ -52,7 +52,7 @@ export function ChannelGeneralTab({ instance, agents, onUpdate }: ChannelGeneral
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
<div className="space-y-6">
|
||||
{/* Read-only fields */}
|
||||
<div className="grid gap-1.5">
|
||||
<Label>{t("detail.general.name")}</Label>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Save, Check, AlertCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -6,6 +6,7 @@ import type { ChannelInstanceData } from "@/types/channel";
|
||||
import { TelegramGroupOverrides } from "../telegram-group-overrides";
|
||||
import type { TelegramGroupConfigValues } from "../telegram-group-fields";
|
||||
import type { TelegramTopicConfigValues } from "../telegram-topic-overrides";
|
||||
import type { GroupManagerGroupInfo } from "../hooks/use-channel-detail";
|
||||
|
||||
interface GroupConfigWithTopics extends TelegramGroupConfigValues {
|
||||
topics?: Record<string, TelegramTopicConfigValues>;
|
||||
@@ -14,9 +15,10 @@ interface GroupConfigWithTopics extends TelegramGroupConfigValues {
|
||||
interface ChannelGroupsTabProps {
|
||||
instance: ChannelInstanceData;
|
||||
onUpdate: (updates: Record<string, unknown>) => Promise<void>;
|
||||
listManagerGroups: () => Promise<GroupManagerGroupInfo[]>;
|
||||
}
|
||||
|
||||
export function ChannelGroupsTab({ instance, onUpdate }: ChannelGroupsTabProps) {
|
||||
export function ChannelGroupsTab({ instance, onUpdate, listManagerGroups }: ChannelGroupsTabProps) {
|
||||
const { t } = useTranslation("channels");
|
||||
const config = (instance.config ?? {}) as Record<string, unknown>;
|
||||
const [groups, setGroups] = useState<Record<string, GroupConfigWithTopics>>(
|
||||
@@ -25,6 +27,16 @@ export function ChannelGroupsTab({ instance, onUpdate }: ChannelGroupsTabProps)
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [knownGroups, setKnownGroups] = useState<GroupManagerGroupInfo[]>([]);
|
||||
|
||||
const loadKnownGroups = useCallback(async () => {
|
||||
try {
|
||||
const g = await listManagerGroups();
|
||||
setKnownGroups(g);
|
||||
} catch { /* handled by http hook */ }
|
||||
}, [listManagerGroups]);
|
||||
|
||||
useEffect(() => { loadKnownGroups(); }, [loadKnownGroups]);
|
||||
|
||||
const handleSave = async () => {
|
||||
const hasGroups = Object.keys(groups).length > 0;
|
||||
@@ -49,8 +61,8 @@ export function ChannelGroupsTab({ instance, onUpdate }: ChannelGroupsTabProps)
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl space-y-6">
|
||||
<TelegramGroupOverrides groups={groups} onChange={(g) => setGroups(g)} />
|
||||
<div className="space-y-6">
|
||||
<TelegramGroupOverrides groups={groups} onChange={(g) => setGroups(g)} knownGroups={knownGroups} />
|
||||
|
||||
{saveError && (
|
||||
<div className="flex items-center gap-2 rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
|
||||
@@ -262,7 +262,7 @@ export function ChannelManagersTab({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl space-y-5">
|
||||
<div className="space-y-5">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("detail.managers.description")}
|
||||
</p>
|
||||
|
||||
@@ -10,8 +10,12 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ToolNameSelect } from "@/components/shared/tool-name-select";
|
||||
import { SkillNameSelect } from "@/components/shared/skill-name-select";
|
||||
import type { FieldDef } from "./channel-schemas";
|
||||
|
||||
const INHERIT = "__inherit__";
|
||||
|
||||
interface ChannelFieldsProps {
|
||||
fields: FieldDef[];
|
||||
values: Record<string, unknown>;
|
||||
@@ -127,6 +131,115 @@ function FieldRenderer({
|
||||
</div>
|
||||
);
|
||||
|
||||
case "tristate": {
|
||||
// Tri-state: undefined = inherit, value = override.
|
||||
// With options: select with Inherit + custom options (string value).
|
||||
// Without options: select with Inherit/Yes/No (boolean value).
|
||||
const inheritLabel = t("groupOverrides.fields.inherit", { defaultValue: "Inherit" });
|
||||
|
||||
if (field.options) {
|
||||
// String tri-state (e.g. group_policy)
|
||||
const allOptions = [{ value: INHERIT, label: inheritLabel }, ...field.options];
|
||||
const selectValue = (value as string) || INHERIT;
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<Label>{label}</Label>
|
||||
<Select
|
||||
value={selectValue}
|
||||
onValueChange={(v) => onChange(v === INHERIT ? undefined : v)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{allOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.value === INHERIT ? inheritLabel : t(`fieldOptions.${field.key}.${opt.value}`, { defaultValue: opt.label })}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{help && <p className="text-xs text-muted-foreground">{help}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Boolean tri-state (e.g. require_mention, enabled)
|
||||
const yesLabel = t("groupOverrides.fields.yes", { defaultValue: "Yes" });
|
||||
const noLabel = t("groupOverrides.fields.no", { defaultValue: "No" });
|
||||
const triOptions = [
|
||||
{ value: INHERIT, label: inheritLabel },
|
||||
{ value: "true", label: yesLabel },
|
||||
{ value: "false", label: noLabel },
|
||||
];
|
||||
const boolToStr = (v: unknown): string => {
|
||||
if (v === undefined || v === null) return INHERIT;
|
||||
return v ? "true" : "false";
|
||||
};
|
||||
const strToBool = (v: string): boolean | undefined => {
|
||||
if (v === INHERIT) return undefined;
|
||||
return v === "true";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<Label>{label}</Label>
|
||||
<Select value={boolToStr(value)} onValueChange={(v) => onChange(strToBool(v))}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{triOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{help && <p className="text-xs text-muted-foreground">{help}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case "textarea":
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
<Textarea
|
||||
id={id}
|
||||
value={(value as string) ?? ""}
|
||||
onChange={(e) => onChange(e.target.value || undefined)}
|
||||
placeholder={field.placeholder}
|
||||
rows={3}
|
||||
/>
|
||||
{help && <p className="text-xs text-muted-foreground">{help}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "tool-select":
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<Label>{label}</Label>
|
||||
<ToolNameSelect
|
||||
value={(value as string[]) ?? []}
|
||||
onChange={(v) => onChange(v.length > 0 ? v : undefined)}
|
||||
placeholder={field.placeholder}
|
||||
/>
|
||||
{help && <p className="text-xs text-muted-foreground">{help}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "skill-select":
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<Label>{label}</Label>
|
||||
<SkillNameSelect
|
||||
value={(value as string[]) ?? []}
|
||||
onChange={(v) => onChange(v.length > 0 ? v : undefined)}
|
||||
placeholder={field.placeholder}
|
||||
/>
|
||||
{help && <p className="text-xs text-muted-foreground">{help}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "tags":
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
@@ -138,7 +251,7 @@ function FieldRenderer({
|
||||
const lines = e.target.value.split("\n").map((l) => l.trim()).filter(Boolean);
|
||||
onChange(lines.length > 0 ? lines : undefined);
|
||||
}}
|
||||
placeholder={t("groupOverrides.fields.allowedUsersPlaceholder")}
|
||||
placeholder={field.placeholder ?? t("groupOverrides.fields.allowedUsersPlaceholder")}
|
||||
rows={3}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
export interface FieldDef {
|
||||
key: string;
|
||||
label: string;
|
||||
type: "text" | "password" | "number" | "boolean" | "select" | "tags";
|
||||
type: "text" | "password" | "number" | "boolean" | "select" | "tags" | "tristate" | "textarea" | "tool-select" | "skill-select";
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
defaultValue?: string | number | boolean | string[];
|
||||
@@ -146,6 +146,21 @@ export const configSchema: Record<string, FieldDef[]> = {
|
||||
],
|
||||
};
|
||||
|
||||
// --- Group override schema (Telegram per-group/topic overrides) ---
|
||||
// Uses tristate fields: undefined = inherit from parent, value = override.
|
||||
// tristate without options → Inherit/Yes/No (boolean).
|
||||
// tristate with options → Inherit + custom options (string).
|
||||
|
||||
export const groupOverrideSchema: FieldDef[] = [
|
||||
{ key: "group_policy", label: "Group Policy", type: "tristate", options: groupPolicyOptions },
|
||||
{ key: "require_mention", label: "Require @mention", type: "tristate" },
|
||||
{ key: "enabled", label: "Enabled", type: "tristate" },
|
||||
{ key: "allow_from", label: "Allowed Users", type: "tags", placeholder: "User IDs, one per line", help: "Restrict which users can interact in this group" },
|
||||
{ key: "skills", label: "Skills Filter", type: "skill-select", help: "Limit available skills for this group" },
|
||||
{ key: "tools", label: "Tool Allowlist", type: "tool-select", help: "Restrict which tools the agent can use in this group" },
|
||||
{ key: "system_prompt", label: "System Prompt", type: "textarea", placeholder: "Additional system prompt for this group..." },
|
||||
];
|
||||
|
||||
// --- Post-create wizard configuration ---
|
||||
// Channels with multi-step create flows (e.g. auth then config).
|
||||
// Channels not listed here use the default single-step create.
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { groupPolicyOptions } from "./channel-schemas";
|
||||
import { ToolNameSelect } from "@/components/shared/tool-name-select";
|
||||
import { ChannelFields } from "./channel-fields";
|
||||
import { groupOverrideSchema } from "./channel-schemas";
|
||||
|
||||
export interface TelegramGroupConfigValues {
|
||||
group_policy?: string;
|
||||
@@ -21,18 +11,6 @@ export interface TelegramGroupConfigValues {
|
||||
system_prompt?: string;
|
||||
}
|
||||
|
||||
const INHERIT = "__inherit__";
|
||||
|
||||
function triStateValue(val: boolean | undefined): string {
|
||||
if (val === undefined || val === null) return INHERIT;
|
||||
return val ? "true" : "false";
|
||||
}
|
||||
|
||||
function parseTriState(val: string): boolean | undefined {
|
||||
if (val === INHERIT || val === "") return undefined;
|
||||
return val === "true";
|
||||
}
|
||||
|
||||
interface Props {
|
||||
config: TelegramGroupConfigValues;
|
||||
onChange: (config: TelegramGroupConfigValues) => void;
|
||||
@@ -40,138 +18,12 @@ interface Props {
|
||||
}
|
||||
|
||||
export function TelegramGroupFields({ config, onChange, idPrefix }: Props) {
|
||||
const { t } = useTranslation("channels");
|
||||
|
||||
const inheritLabel = t("groupOverrides.fields.inherit");
|
||||
const yesLabel = t("groupOverrides.fields.yes");
|
||||
const noLabel = t("groupOverrides.fields.no");
|
||||
|
||||
const triStateOptions = [
|
||||
{ value: INHERIT, label: inheritLabel },
|
||||
{ value: "true", label: yesLabel },
|
||||
{ value: "false", label: noLabel },
|
||||
];
|
||||
|
||||
const groupPolicyWithInherit = [
|
||||
{ value: INHERIT, label: inheritLabel },
|
||||
...groupPolicyOptions,
|
||||
];
|
||||
|
||||
const update = (patch: Partial<TelegramGroupConfigValues>) => {
|
||||
onChange({ ...config, ...patch });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<div className="grid gap-1.5">
|
||||
<Label>{t("groupOverrides.fields.groupPolicy")}</Label>
|
||||
<Select
|
||||
value={config.group_policy || INHERIT}
|
||||
onValueChange={(v) => update({ group_policy: v === INHERIT ? undefined : v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{groupPolicyWithInherit.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label>{t("groupOverrides.fields.requireMention")}</Label>
|
||||
<Select
|
||||
value={triStateValue(config.require_mention)}
|
||||
onValueChange={(v) => update({ require_mention: parseTriState(v) })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{triStateOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label>{t("groupOverrides.fields.enabled")}</Label>
|
||||
<Select
|
||||
value={triStateValue(config.enabled)}
|
||||
onValueChange={(v) => update({ enabled: parseTriState(v) })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{triStateOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor={`${idPrefix}-allow`}>{t("groupOverrides.fields.allowedUsers")}</Label>
|
||||
<Textarea
|
||||
id={`${idPrefix}-allow`}
|
||||
value={config.allow_from?.join("\n") ?? ""}
|
||||
onChange={(e) => {
|
||||
const lines = e.target.value.split("\n").map((l) => l.trim()).filter(Boolean);
|
||||
update({ allow_from: lines.length > 0 ? lines : undefined });
|
||||
}}
|
||||
placeholder={t("groupOverrides.fields.allowedUsersPlaceholder")}
|
||||
rows={2}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor={`${idPrefix}-skills`}>{t("groupOverrides.fields.skillsFilter")}</Label>
|
||||
<Textarea
|
||||
id={`${idPrefix}-skills`}
|
||||
value={config.skills?.join("\n") ?? ""}
|
||||
onChange={(e) => {
|
||||
const lines = e.target.value.split("\n").map((l) => l.trim()).filter(Boolean);
|
||||
update({ skills: lines.length > 0 ? lines : undefined });
|
||||
}}
|
||||
placeholder={t("groupOverrides.fields.skillsPlaceholder")}
|
||||
rows={2}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label>{t("groupOverrides.fields.toolAllow")}</Label>
|
||||
<ToolNameSelect
|
||||
value={config.tools ?? []}
|
||||
onChange={(v) => update({ tools: v.length > 0 ? v : undefined })}
|
||||
placeholder={t("groupOverrides.fields.toolAllowPlaceholder")}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("groupOverrides.fields.toolAllowHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor={`${idPrefix}-prompt`}>{t("groupOverrides.fields.systemPrompt")}</Label>
|
||||
<Textarea
|
||||
id={`${idPrefix}-prompt`}
|
||||
value={config.system_prompt ?? ""}
|
||||
onChange={(e) => update({ system_prompt: e.target.value || undefined })}
|
||||
placeholder={t("groupOverrides.fields.systemPromptPlaceholder")}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ChannelFields
|
||||
fields={groupOverrideSchema}
|
||||
values={config as Record<string, unknown>}
|
||||
onChange={(key, value) => onChange({ ...config, [key]: value })}
|
||||
idPrefix={idPrefix}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -2,9 +2,11 @@ import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Plus, Trash2, ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { TelegramGroupFields, type TelegramGroupConfigValues } from "./telegram-group-fields";
|
||||
import { TelegramTopicOverrides, type TelegramTopicConfigValues } from "./telegram-topic-overrides";
|
||||
import type { GroupManagerGroupInfo } from "./hooks/use-channel-detail";
|
||||
|
||||
interface GroupConfigWithTopics extends TelegramGroupConfigValues {
|
||||
topics?: Record<string, TelegramTopicConfigValues>;
|
||||
@@ -13,21 +15,23 @@ interface GroupConfigWithTopics extends TelegramGroupConfigValues {
|
||||
interface Props {
|
||||
groups: Record<string, GroupConfigWithTopics>;
|
||||
onChange: (groups: Record<string, GroupConfigWithTopics>) => void;
|
||||
/** Known groups from the system (e.g. groups with managers assigned) */
|
||||
knownGroups?: GroupManagerGroupInfo[];
|
||||
}
|
||||
|
||||
export function TelegramGroupOverrides({ groups, onChange }: Props) {
|
||||
export function TelegramGroupOverrides({ groups, onChange, knownGroups }: Props) {
|
||||
const { t } = useTranslation("channels");
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||
const [newGroupId, setNewGroupId] = useState("");
|
||||
|
||||
const groupIds = Object.keys(groups);
|
||||
|
||||
const addGroup = () => {
|
||||
const id = newGroupId.trim();
|
||||
if (!id || groups[id]) return;
|
||||
onChange({ ...groups, [id]: {} });
|
||||
setExpanded((prev) => ({ ...prev, [id]: true }));
|
||||
setNewGroupId("");
|
||||
const addGroup = (id?: string) => {
|
||||
const gid = (id ?? newGroupId).trim();
|
||||
if (!gid || groups[gid]) return;
|
||||
onChange({ ...groups, [gid]: {} });
|
||||
setExpanded((prev) => ({ ...prev, [gid]: true }));
|
||||
if (!id) setNewGroupId("");
|
||||
};
|
||||
|
||||
const removeGroup = (id: string) => {
|
||||
@@ -53,6 +57,13 @@ export function TelegramGroupOverrides({ groups, onChange }: Props) {
|
||||
setExpanded((prev) => ({ ...prev, [id]: !prev[id] }));
|
||||
};
|
||||
|
||||
// Known groups not yet added as overrides
|
||||
const availableGroups = knownGroups?.filter((g) => {
|
||||
// Extract the raw group ID from "group:<channel>:<id>" format
|
||||
const rawId = extractGroupId(g.group_id);
|
||||
return !groups[rawId] && !groups[g.group_id];
|
||||
});
|
||||
|
||||
return (
|
||||
<fieldset className="rounded-md border p-3 space-y-3">
|
||||
<legend className="px-1 text-sm font-medium">{t("groupOverrides.title")}</legend>
|
||||
@@ -102,6 +113,33 @@ export function TelegramGroupOverrides({ groups, onChange }: Props) {
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Known groups quick-add */}
|
||||
{availableGroups && availableGroups.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">{t("groupOverrides.knownGroups")}</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{availableGroups.map((g) => {
|
||||
const rawId = extractGroupId(g.group_id);
|
||||
return (
|
||||
<button
|
||||
key={g.group_id}
|
||||
type="button"
|
||||
onClick={() => addGroup(rawId)}
|
||||
className="inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
<span className="font-mono">{rawId}</span>
|
||||
<Badge variant="secondary" className="text-[10px] px-1 py-0">
|
||||
{g.writer_count}
|
||||
</Badge>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Manual group ID input */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={newGroupId}
|
||||
@@ -110,7 +148,7 @@ export function TelegramGroupOverrides({ groups, onChange }: Props) {
|
||||
className="h-8 flex-1 text-sm"
|
||||
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), addGroup())}
|
||||
/>
|
||||
<Button type="button" variant="outline" size="sm" className="h-8" onClick={addGroup} disabled={!newGroupId.trim()}>
|
||||
<Button type="button" variant="outline" size="sm" className="h-8" onClick={() => addGroup()} disabled={!newGroupId.trim()}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||
{t("groupOverrides.addGroup")}
|
||||
</Button>
|
||||
@@ -118,3 +156,9 @@ export function TelegramGroupOverrides({ groups, onChange }: Props) {
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
/** Strips "group:<channel>:" prefix, e.g. "group:telegram:-100123" → "-100123" */
|
||||
function extractGroupId(id: string): string {
|
||||
const m = id.match(/^group:[^:]+:(.+)$/);
|
||||
return m?.[1] ?? id;
|
||||
}
|
||||
Reference in new issue
Block a user