| |
| import * as d3 from 'd3'; |
| import { tr } from '../../shared/lang/i18n-lite'; |
| import { showDialog } from '../../shared/ui/dialog'; |
| import { cloneCatalogEntry, getCatalogEntryByName, TOOL_CATALOG } from './toolCatalog'; |
| import { |
| buildToolConfigFromCheckedJson, |
| createBlankToolEntry, |
| formatToolCatalogEntryJson, |
| nextAvailableNewToolName, |
| parseToolCatalogEntryJson, |
| type ToolCatalogEntry, |
| type ToolConfig, |
| } from './toolConfig'; |
|
|
| type DialogRow = { |
| id: string; |
| rowKey: string; |
| template: ToolCatalogEntry; |
| checked: boolean; |
| jsonText: string; |
| |
| textareaHeight?: number; |
| |
| custom: boolean; |
| }; |
|
|
| let dialogRowIdSeq = 0; |
|
|
| function allocDialogRowId(): string { |
| dialogRowIdSeq += 1; |
| return `tool-dialog-row-${dialogRowIdSeq}`; |
| } |
|
|
| function buildInitialRows(config: ToolConfig): DialogRow[] { |
| const snapshotEntries = isConfigRestorable(config) ? config.entries : []; |
| const snapshotNames = new Set(snapshotEntries.map((e) => e.function.name)); |
| const rows: DialogRow[] = snapshotEntries.map((entry) => ({ |
| id: allocDialogRowId(), |
| rowKey: entry.function.name, |
| template: getCatalogEntryByName(entry.function.name) ?? entry, |
| checked: true, |
| jsonText: formatToolCatalogEntryJson(entry), |
| custom: getCatalogEntryByName(entry.function.name) === undefined, |
| })); |
| for (const catalogEntry of TOOL_CATALOG) { |
| if (!snapshotNames.has(catalogEntry.function.name)) { |
| rows.push({ |
| id: allocDialogRowId(), |
| rowKey: catalogEntry.function.name, |
| template: catalogEntry, |
| checked: false, |
| jsonText: formatToolCatalogEntryJson(cloneCatalogEntry(catalogEntry)), |
| custom: false, |
| }); |
| } |
| } |
| return rows; |
| } |
|
|
| function isConfigRestorable(config: ToolConfig): boolean { |
| return config.entries.length > 0; |
| } |
|
|
| function collectUsedToolNames(rows: DialogRow[]): string[] { |
| const names: string[] = []; |
| for (const row of rows) { |
| names.push(row.rowKey); |
| const text = row.jsonText.trim(); |
| if (!text) continue; |
| const parsed = parseToolCatalogEntryJson(text); |
| if ('entry' in parsed) { |
| names.push(parsed.entry.function.name); |
| } |
| } |
| return names; |
| } |
|
|
| function addCustomToolRow(rows: DialogRow[]): DialogRow { |
| const name = nextAvailableNewToolName(collectUsedToolNames(rows)); |
| const entry = createBlankToolEntry(name); |
| const row: DialogRow = { |
| id: allocDialogRowId(), |
| rowKey: name, |
| template: entry, |
| checked: true, |
| jsonText: formatToolCatalogEntryJson(entry), |
| custom: true, |
| }; |
| rows.push(row); |
| return row; |
| } |
|
|
| function resolveRowBaseName(row: DialogRow): string { |
| const text = row.jsonText.trim(); |
| if (text) { |
| const parsed = parseToolCatalogEntryJson(text); |
| if ('entry' in parsed) { |
| return parsed.entry.function.name; |
| } |
| } |
| return row.rowKey; |
| } |
|
|
| function rowDisplayLabel(row: DialogRow): string { |
| const name = resolveRowBaseName(row); |
| if (!row.custom) return name; |
| if (!row.checked) { |
| return `${name} (${tr('custom, will not save')})`; |
| } |
| return `${name} (${tr('custom')})`; |
| } |
|
|
| export function showEditableToolConfigDialog( |
| config: ToolConfig, |
| onConfirm: (next: ToolConfig) => void, |
| ): void { |
| dialogRowIdSeq = 0; |
| const rows: DialogRow[] = buildInitialRows(config); |
| let parsedOnConfirm: ToolConfig | null = null; |
|
|
| showDialog({ |
| title: tr('Config tools'), |
| width: 'clamp(360px, 94vw, 720px)', |
| confirmText: tr('Confirm'), |
| cancelText: tr('Cancel'), |
| content: (contentRoot) => { |
| const form = contentRoot |
| .append('div') |
| .attr('class', 'dialog-form-container dialog-form-container--fill chat-tool-config-form'); |
|
|
| const errorEl = form |
| .append('div') |
| .attr('class', 'chat-tool-config-error') |
| .style('display', 'none'); |
|
|
| const scrollRegion = form.append('div').attr('class', 'dialog-scroll-region'); |
| const listEl = scrollRegion.append('div').attr('class', 'chat-tool-config-list'); |
|
|
| const syncTextareasFromDom = (): void => { |
| listEl.selectAll<HTMLDivElement, unknown>('.chat-tool-config-item').each(function () { |
| const rowId = this.getAttribute('data-row-id'); |
| const row = rows.find((r) => r.id === rowId); |
| const ta = this.querySelector('textarea'); |
| if (row && ta) { |
| row.jsonText = ta.value; |
| row.textareaHeight = ta.offsetHeight; |
| } |
| }); |
| }; |
|
|
| let scrollToRowId: string | null = null; |
|
|
| const render = (): void => { |
| syncTextareasFromDom(); |
| listEl.selectAll('*').remove(); |
| for (const row of rows) { |
| const item = listEl |
| .append('div') |
| .attr('class', 'chat-tool-config-item') |
| .classed('chat-tool-config-item--expanded', row.checked) |
| .attr('data-row-id', row.id); |
|
|
| const head = item.append('label').attr('class', 'chat-tool-config-item-head'); |
| head |
| .append('input') |
| .attr('type', 'checkbox') |
| .property('checked', row.checked) |
| .on('change', function () { |
| syncTextareasFromDom(); |
| const checked = (this as HTMLInputElement).checked; |
| row.checked = checked; |
| if (checked && !row.jsonText.trim()) { |
| row.jsonText = formatToolCatalogEntryJson(cloneCatalogEntry(row.template)); |
| } |
| render(); |
| }); |
| head.append('span').attr('class', 'chat-tool-config-item-label').text(rowDisplayLabel(row)); |
|
|
| if (row.checked) { |
| const ta = item |
| .append('textarea') |
| .attr('class', 'dialog-textarea chat-tool-config-entry-json') |
| .attr('spellcheck', 'false') |
| .property('value', row.jsonText) |
| .on('input', function () { |
| row.jsonText = (this as HTMLTextAreaElement).value; |
| }); |
| if (row.textareaHeight != null) { |
| ta.style('height', `${row.textareaHeight}px`); |
| } |
| } |
| } |
|
|
| if (scrollToRowId) { |
| const scrollEl = scrollRegion.node(); |
| const el = listEl |
| .select(`[data-row-id="${scrollToRowId}"]`) |
| .node() as HTMLElement | null; |
| if (scrollEl && el) { |
| el.scrollIntoView({ block: 'nearest' }); |
| } |
| scrollToRowId = null; |
| } |
| }; |
|
|
| const shell = d3.select(contentRoot.node()!.parentElement!); |
| shell |
| .select('.dialog-buttons') |
| .insert('button', '.dialog-button.cancel') |
| .attr('type', 'button') |
| .attr('class', 'text-action-btn chat-tool-config-add-btn') |
| .text(tr('Add tool')) |
| .on('click', () => { |
| syncTextareasFromDom(); |
| const added = addCustomToolRow(rows); |
| scrollToRowId = added.id; |
| errorEl.style('display', 'none'); |
| render(); |
| }); |
|
|
| render(); |
|
|
| return { |
| validate: () => { |
| syncTextareasFromDom(); |
| const checked = rows.filter((r) => r.checked); |
| const built = buildToolConfigFromCheckedJson( |
| checked.map((r) => ({ label: rowDisplayLabel(r), text: r.jsonText })), |
| ); |
| if ('error' in built) { |
| errorEl.text(built.error).style('display', 'block'); |
| parsedOnConfirm = null; |
| return false; |
| } |
| errorEl.style('display', 'none'); |
| parsedOnConfirm = built.config; |
| return true; |
| }, |
| getValue: () => parsedOnConfirm, |
| }; |
| }, |
| onConfirm: (value: ToolConfig | null) => { |
| if (!value) return false; |
| onConfirm(value); |
| }, |
| }); |
| } |
|
|