552 lines
20 KiB
TypeScript
552 lines
20 KiB
TypeScript
import { Plus, Trash2 } from 'lucide-react'
|
||
|
||
import { Badge } from '@/components/ui/badge'
|
||
import { Button } from '@/components/ui/button'
|
||
import { Input } from '@/components/ui/input'
|
||
import { Label } from '@/components/ui/label'
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from '@/components/ui/select'
|
||
import type { FieldHookComponent } from '@/lib/field-hooks'
|
||
|
||
import { createJsonFieldHook } from './JsonFieldHookFactory'
|
||
import { createListItemEditorHook } from './ListItemEditorHookFactory'
|
||
|
||
type ExpressionRuleType = 'group' | 'private'
|
||
|
||
interface ExpressionGroupTarget {
|
||
platform: string
|
||
item_id: string
|
||
rule_type: ExpressionRuleType
|
||
}
|
||
|
||
interface ExpressionGroupValue {
|
||
expression_groups: ExpressionGroupTarget[]
|
||
}
|
||
|
||
interface PlatformAccountRow {
|
||
platform: string
|
||
account: string
|
||
}
|
||
|
||
const ruleTypeLabel = (rule: unknown) => {
|
||
if (rule === 'private') return '私聊'
|
||
if (rule === 'group') return '群聊'
|
||
return rule ? String(rule) : '未指定'
|
||
}
|
||
|
||
const platformLabel = (item: Record<string, unknown>) => {
|
||
const platform = typeof item.platform === 'string' ? item.platform.trim() : ''
|
||
const itemId = typeof item.item_id === 'string' ? item.item_id.trim() : ''
|
||
if (!platform && !itemId) return '全局'
|
||
if (!platform) return itemId
|
||
if (!itemId) return platform
|
||
return `${platform}:${itemId}`
|
||
}
|
||
|
||
const truncate = (text: string, max = 32) => {
|
||
if (text.length <= max) return text
|
||
return `${text.slice(0, max)}…`
|
||
}
|
||
|
||
const collectStringList = (value: unknown): string[] => {
|
||
if (!Array.isArray(value)) return []
|
||
return value
|
||
.map((item) => (typeof item === 'string' ? item.trim() : ''))
|
||
.filter((item) => item.length > 0)
|
||
}
|
||
|
||
const normalizeExpressionRuleType = (value: unknown): ExpressionRuleType => {
|
||
return value === 'private' ? 'private' : 'group'
|
||
}
|
||
|
||
const normalizeExpressionTarget = (value: unknown): ExpressionGroupTarget => {
|
||
const source =
|
||
value && typeof value === 'object'
|
||
? (value as Record<string, unknown>)
|
||
: {}
|
||
return {
|
||
platform:
|
||
typeof source.platform === 'string' ? source.platform.trim() : 'qq',
|
||
item_id:
|
||
typeof source.item_id === 'string' ? source.item_id.trim() : '',
|
||
rule_type: normalizeExpressionRuleType(source.rule_type),
|
||
}
|
||
}
|
||
|
||
const normalizeExpressionGroups = (value: unknown): ExpressionGroupValue[] => {
|
||
if (!Array.isArray(value)) return []
|
||
return value.map((item) => {
|
||
const source =
|
||
item && typeof item === 'object'
|
||
? (item as Record<string, unknown>)
|
||
: {}
|
||
const members = Array.isArray(source.expression_groups)
|
||
? source.expression_groups.map(normalizeExpressionTarget)
|
||
: []
|
||
return { expression_groups: members }
|
||
})
|
||
}
|
||
|
||
const createExpressionTarget = (): ExpressionGroupTarget => ({
|
||
platform: 'qq',
|
||
item_id: '',
|
||
rule_type: 'group',
|
||
})
|
||
|
||
const formatExpressionTarget = (target: ExpressionGroupTarget): string => {
|
||
const platform = target.platform.trim()
|
||
const itemId = target.item_id.trim()
|
||
const rule = ruleTypeLabel(target.rule_type)
|
||
if (!platform && !itemId) return `全局 · ${rule}`
|
||
if (!itemId) return `${platform} · ${rule}`
|
||
return `${platform}:${itemId} · ${rule}`
|
||
}
|
||
|
||
const normalizePlatformAccounts = (value: unknown): string[] => {
|
||
if (!Array.isArray(value)) return []
|
||
return value.map((item) => String(item ?? ''))
|
||
}
|
||
|
||
const parsePlatformAccount = (value: string): PlatformAccountRow => {
|
||
const separatorIndex = value.indexOf(':')
|
||
if (separatorIndex < 0) {
|
||
return { platform: '', account: value }
|
||
}
|
||
return {
|
||
platform: value.slice(0, separatorIndex),
|
||
account: value.slice(separatorIndex + 1),
|
||
}
|
||
}
|
||
|
||
const formatPlatformAccount = (row: PlatformAccountRow): string => {
|
||
const platform = row.platform.trim()
|
||
const account = row.account.trim()
|
||
if (!platform) return account
|
||
if (!account) return `${platform}:`
|
||
return `${platform}:${account}`
|
||
}
|
||
|
||
export const ChatTalkValueRulesHook = createListItemEditorHook({
|
||
addLabel: '添加发言频率规则',
|
||
addButtonPlacement: 'top',
|
||
collapseWhen: ({ parentValues }) => parentValues?.enable_talk_value_rules === false,
|
||
collapsedText: '动态发言频率规则未启用,规则列表已折叠。展开后仍可查看或编辑已有规则。',
|
||
expandLabel: '展开规则',
|
||
collapseLabel: '折叠规则',
|
||
helperText: '可按平台/聊天流/时段分别配置发言频率,留空表示全局。',
|
||
emptyText: '尚未配置任何规则,将使用全局默认频率。',
|
||
fieldRows: [
|
||
['platform', 'item_id', 'rule_type'],
|
||
['time', 'value'],
|
||
],
|
||
itemTitle: (item) => {
|
||
const time =
|
||
typeof item.time === 'string' && item.time.trim()
|
||
? item.time.trim()
|
||
: '全天'
|
||
const value =
|
||
typeof item.value === 'number' ? item.value.toFixed(2) : '—'
|
||
return `${platformLabel(item)} · ${ruleTypeLabel(item.rule_type)} · ${time} · 频率 ${value}`
|
||
},
|
||
})
|
||
|
||
export const ChatPromptsHook = createListItemEditorHook({
|
||
addLabel: '添加额外 Prompt',
|
||
helperText: '为指定平台和聊天流添加额外提示。platform、item_id 和 prompt 同时留空时表示空条目;填写任意一项后这三项都需要填写。',
|
||
emptyText: '尚未配置任何聊天额外 Prompt。',
|
||
addButtonPlacement: 'top',
|
||
fieldRows: [['platform', 'item_id', 'rule_type']],
|
||
fieldSchemaOverrides: {
|
||
item_id: {
|
||
'x-input-width': '8rem',
|
||
'x-layout': 'inline-right',
|
||
},
|
||
platform: {
|
||
'x-input-width': '8rem',
|
||
'x-layout': 'inline-right',
|
||
},
|
||
prompt: {
|
||
'x-textarea-min-height': 38,
|
||
'x-textarea-rows': 1,
|
||
},
|
||
rule_type: {
|
||
'x-input-width': '8rem',
|
||
'x-layout': 'inline-right',
|
||
},
|
||
},
|
||
iconName: 'file-text',
|
||
itemTitle: (item) => {
|
||
const prompt = typeof item.prompt === 'string' ? item.prompt.trim() : ''
|
||
return `${platformLabel(item)} · ${ruleTypeLabel(item.rule_type)} · ${prompt ? truncate(prompt) : '未填写 Prompt'}`
|
||
},
|
||
})
|
||
|
||
export const ExpressionLearningListHook = createListItemEditorHook({
|
||
addLabel: '添加表达学习规则',
|
||
helperText: '为不同聊天流单独配置是否启用表达/jargon 学习。',
|
||
emptyText: '尚未配置任何学习规则。',
|
||
fieldRows: [
|
||
['platform', 'item_id', 'rule_type'],
|
||
['use_expression', 'enable_learning', 'enable_jargon_learning'],
|
||
],
|
||
itemTitle: (item) => {
|
||
const flags: string[] = []
|
||
if (item.use_expression) flags.push('表达')
|
||
if (item.enable_learning) flags.push('优化学习')
|
||
if (item.enable_jargon_learning) flags.push('jargon')
|
||
const flagText = flags.length ? flags.join(' / ') : '全部关闭'
|
||
return `${platformLabel(item)} · ${ruleTypeLabel(item.rule_type)} · ${flagText}`
|
||
},
|
||
})
|
||
|
||
export const BotPlatformsHook: FieldHookComponent = ({ onChange, value }) => {
|
||
const platforms = normalizePlatformAccounts(value)
|
||
const rows = platforms.map(parsePlatformAccount)
|
||
|
||
const updateRows = (nextRows: PlatformAccountRow[]) => {
|
||
onChange?.(nextRows.map(formatPlatformAccount))
|
||
}
|
||
|
||
const addRow = () => {
|
||
updateRows([...rows, { platform: '', account: '' }])
|
||
}
|
||
|
||
const removeRow = (rowIndex: number) => {
|
||
updateRows(rows.filter((_, index) => index !== rowIndex))
|
||
}
|
||
|
||
const updateRow = (rowIndex: number, patch: Partial<PlatformAccountRow>) => {
|
||
updateRows(
|
||
rows.map((row, index) =>
|
||
index === rowIndex ? { ...row, ...patch } : row
|
||
)
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||
<div className="space-y-1">
|
||
<Label className="text-sm font-medium">其他平台</Label>
|
||
<p className="text-xs text-muted-foreground">
|
||
每行保存为 platform:account,例如 wx:114514。
|
||
</p>
|
||
</div>
|
||
<Button type="button" size="sm" variant="outline" onClick={addRow}>
|
||
<Plus className="mr-2 h-4 w-4" />
|
||
添加平台
|
||
</Button>
|
||
</div>
|
||
|
||
{rows.length === 0 ? (
|
||
<div className="rounded-md border border-dashed bg-muted/30 px-4 py-5 text-center text-sm text-muted-foreground">
|
||
暂无其他平台账号。
|
||
</div>
|
||
) : (
|
||
<div className="space-y-2">
|
||
{rows.map((row, rowIndex) => (
|
||
<div
|
||
key={rowIndex}
|
||
className="grid gap-2 rounded-md border bg-muted/20 p-3 sm:grid-cols-[minmax(7rem,0.6fr)_minmax(10rem,1fr)_auto]"
|
||
>
|
||
<div className="space-y-1">
|
||
<Label className="text-xs">平台</Label>
|
||
<Input
|
||
value={row.platform}
|
||
placeholder="wx"
|
||
onChange={(event) =>
|
||
updateRow(rowIndex, { platform: event.target.value })
|
||
}
|
||
/>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label className="text-xs">账号</Label>
|
||
<Input
|
||
className="font-mono"
|
||
value={row.account}
|
||
placeholder="114514"
|
||
onChange={(event) =>
|
||
updateRow(rowIndex, { account: event.target.value })
|
||
}
|
||
/>
|
||
</div>
|
||
<div className="flex items-end justify-end">
|
||
<Button
|
||
type="button"
|
||
size="icon"
|
||
variant="ghost"
|
||
aria-label={`删除其他平台 ${rowIndex + 1}`}
|
||
onClick={() => removeRow(rowIndex)}
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export const KeywordRulesHook = createListItemEditorHook({
|
||
addLabel: '添加关键词规则',
|
||
helperText: '匹配命中后会用 reaction 内容作为额外上下文。keywords 至少填一条,或使用正则模式。',
|
||
emptyText: '尚未添加任何关键词规则。',
|
||
itemTitle: (item) => {
|
||
const keywords = collectStringList(item.keywords)
|
||
const regex = collectStringList(item.regex)
|
||
const reaction =
|
||
typeof item.reaction === 'string' ? item.reaction.trim() : ''
|
||
const left = keywords.length
|
||
? `关键词 ${keywords.length} 条`
|
||
: regex.length
|
||
? `正则 ${regex.length} 条`
|
||
: '未配置匹配项'
|
||
const right = reaction ? `→ ${truncate(reaction)}` : '→ 未填写反应'
|
||
return `${left} ${right}`
|
||
},
|
||
})
|
||
|
||
export const RegexRulesHook = createListItemEditorHook({
|
||
addLabel: '添加正则规则',
|
||
helperText: '正则模式按 Python 语法编写,命中时把 reaction 作为提示注入。',
|
||
emptyText: '尚未添加任何正则规则。',
|
||
itemTitle: (item) => {
|
||
const regex = collectStringList(item.regex)
|
||
const keywords = collectStringList(item.keywords)
|
||
const reaction =
|
||
typeof item.reaction === 'string' ? item.reaction.trim() : ''
|
||
const left = regex.length
|
||
? `正则 ${regex.length} 条`
|
||
: keywords.length
|
||
? `关键词 ${keywords.length} 条`
|
||
: '未配置匹配项'
|
||
const right = reaction ? `→ ${truncate(reaction)}` : '→ 未填写反应'
|
||
return `${left} ${right}`
|
||
},
|
||
})
|
||
|
||
export const ExpressionGroupsHook: FieldHookComponent = ({ onChange, value }) => {
|
||
const groups = normalizeExpressionGroups(value)
|
||
|
||
const updateGroups = (nextGroups: ExpressionGroupValue[]) => {
|
||
onChange?.(nextGroups)
|
||
}
|
||
|
||
const addGroup = () => {
|
||
updateGroups([...groups, { expression_groups: [] }])
|
||
}
|
||
|
||
const removeGroup = (groupIndex: number) => {
|
||
updateGroups(groups.filter((_, index) => index !== groupIndex))
|
||
}
|
||
|
||
const addMember = (groupIndex: number) => {
|
||
updateGroups(
|
||
groups.map((group, index) =>
|
||
index === groupIndex
|
||
? {
|
||
expression_groups: [
|
||
...group.expression_groups,
|
||
createExpressionTarget(),
|
||
],
|
||
}
|
||
: group
|
||
)
|
||
)
|
||
}
|
||
|
||
const removeMember = (groupIndex: number, memberIndex: number) => {
|
||
updateGroups(
|
||
groups.map((group, index) =>
|
||
index === groupIndex
|
||
? {
|
||
expression_groups: group.expression_groups.filter(
|
||
(_, currentMemberIndex) => currentMemberIndex !== memberIndex
|
||
),
|
||
}
|
||
: group
|
||
)
|
||
)
|
||
}
|
||
|
||
const updateMember = (
|
||
groupIndex: number,
|
||
memberIndex: number,
|
||
patch: Partial<ExpressionGroupTarget>
|
||
) => {
|
||
updateGroups(
|
||
groups.map((group, index) =>
|
||
index === groupIndex
|
||
? {
|
||
expression_groups: group.expression_groups.map(
|
||
(member, currentMemberIndex) =>
|
||
currentMemberIndex === memberIndex
|
||
? { ...member, ...patch }
|
||
: member
|
||
),
|
||
}
|
||
: group
|
||
)
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-3 rounded-lg border bg-card p-4 sm:p-5">
|
||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||
<div className="space-y-1">
|
||
<h3 className="text-base font-semibold">表达互通组</h3>
|
||
<p className="text-sm text-muted-foreground">
|
||
每个互通组内的聊天流会共享已学习的表达方式。成员会保存为
|
||
expression_groups 数组结构。
|
||
</p>
|
||
</div>
|
||
<Button type="button" size="sm" variant="outline" onClick={addGroup}>
|
||
<Plus className="mr-2 h-4 w-4" />
|
||
添加互通组
|
||
</Button>
|
||
</div>
|
||
|
||
{groups.length === 0 ? (
|
||
<div className="rounded-md border border-dashed bg-muted/30 px-4 py-8 text-center text-sm text-muted-foreground">
|
||
暂无互通组,点击“添加互通组”开始配置。
|
||
</div>
|
||
) : (
|
||
<div className="space-y-2">
|
||
{groups.map((group, groupIndex) => (
|
||
<div
|
||
key={groupIndex}
|
||
className="space-y-2 rounded-md border bg-muted/20 p-2.5 sm:p-3"
|
||
>
|
||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<span className="text-sm font-medium">
|
||
互通组 {groupIndex + 1}
|
||
</span>
|
||
<Badge variant="secondary">
|
||
{group.expression_groups.length} 个成员
|
||
</Badge>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() => addMember(groupIndex)}
|
||
>
|
||
<Plus className="mr-2 h-4 w-4" />
|
||
添加成员
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
size="icon"
|
||
variant="ghost"
|
||
aria-label={`删除互通组 ${groupIndex + 1}`}
|
||
onClick={() => removeGroup(groupIndex)}
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{group.expression_groups.length === 0 ? (
|
||
<div className="rounded-md bg-background/70 px-3 py-4 text-sm text-muted-foreground">
|
||
这个互通组还没有成员。
|
||
</div>
|
||
) : (
|
||
<div className="space-y-1.5">
|
||
{group.expression_groups.map((member, memberIndex) => (
|
||
<div
|
||
key={`${groupIndex}-${memberIndex}`}
|
||
className="grid items-end gap-2 rounded-md bg-background/80 px-2.5 py-2 md:grid-cols-[minmax(6rem,0.65fr)_minmax(9rem,1fr)_minmax(7rem,0.75fr)_2.25rem]"
|
||
>
|
||
<div className="space-y-0.5">
|
||
<Label className="text-[11px] leading-none text-muted-foreground">平台</Label>
|
||
<Input
|
||
className="h-8"
|
||
value={member.platform}
|
||
placeholder="qq"
|
||
onChange={(event) =>
|
||
updateMember(groupIndex, memberIndex, {
|
||
platform: event.target.value,
|
||
})
|
||
}
|
||
/>
|
||
</div>
|
||
<div className="space-y-0.5">
|
||
<Label className="text-[11px] leading-none text-muted-foreground">账号 / 群号</Label>
|
||
<Input
|
||
className="h-8 font-mono"
|
||
value={member.item_id}
|
||
placeholder="123456"
|
||
onChange={(event) =>
|
||
updateMember(groupIndex, memberIndex, {
|
||
item_id: event.target.value,
|
||
})
|
||
}
|
||
/>
|
||
</div>
|
||
<div className="space-y-0.5">
|
||
<Label className="text-[11px] leading-none text-muted-foreground">类型</Label>
|
||
<Select
|
||
value={member.rule_type}
|
||
onValueChange={(nextRuleType) =>
|
||
updateMember(groupIndex, memberIndex, {
|
||
rule_type: normalizeExpressionRuleType(nextRuleType),
|
||
})
|
||
}
|
||
>
|
||
<SelectTrigger className="h-8">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="group">群聊</SelectItem>
|
||
<SelectItem value="private">私聊</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="flex items-end justify-between gap-2 md:justify-end">
|
||
<span className="min-w-0 truncate text-xs text-muted-foreground md:hidden">
|
||
{formatExpressionTarget(member)}
|
||
</span>
|
||
<Button
|
||
type="button"
|
||
size="icon"
|
||
variant="ghost"
|
||
className="h-8 w-8"
|
||
aria-label={`删除互通组 ${groupIndex + 1} 的成员 ${memberIndex + 1}`}
|
||
onClick={() => removeMember(groupIndex, memberIndex)}
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export const MCPRootItemsHook = createJsonFieldHook({
|
||
emptyValue: [],
|
||
helperText: 'MCP Roots 条目为对象数组,使用 JSON 编辑。',
|
||
placeholder: '[\n {\n "enabled": true,\n "uri": "file:///Users/example/project",\n "name": "project-root"\n }\n]',
|
||
})
|
||
|
||
export const MCPServersHook = createJsonFieldHook({
|
||
emptyValue: [],
|
||
helperText: 'MCP 服务器配置结构较复杂,使用 JSON 编辑。',
|
||
placeholder: '[\n {\n "name": "example-server",\n "enabled": true,\n "transport": "stdio",\n "command": "uvx",\n "args": ["example-server"],\n "env": {},\n "url": "",\n "headers": {},\n "http_timeout_seconds": 30.0,\n "read_timeout_seconds": 300.0,\n "authorization": {\n "mode": "none",\n "bearer_token": ""\n }\n }\n]',
|
||
})
|