InfoLens1 / client /src /features /chat /toolConfigDialog.ts
dqy08's picture
精简文档
37db941
Raw
History Blame Contribute Delete
9.25 kB
/** Config tools:编辑 tool config 快照的弹窗。 */
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;
/** 用户拖拽调整后的 textarea 高度(px),全量 re-render 前从 DOM 同步 */
textareaHeight?: number;
/** 非 catalog 条目(用户新增或快照游离项) */
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);
},
});
}