mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-07 01:54:59 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d5e802b83 | ||
|
|
6d750ede95 |
No files matched your search
@@ -15,10 +15,10 @@ export function HttpToolForm({ editor }: { editor: ServerEditor }) {
|
||||
<div className="portal-settings-heading"><h3>{t("servers.tool")}</h3><p>{t("servers.http_lead")}</p></div>
|
||||
<div className="portal-settings-card server-form">
|
||||
<div className="server-two-fields">
|
||||
<div><FormField label={t("servers.display_name")} value={draft.name} error={errors.name?.join(" ")} disabled={busy} onChange={(name) => change({ ...draft, name })} /><small>{t("servers.display_name_hint")}</small></div>
|
||||
<div><FormField label={t("servers.tool_name")} value={server.toolName ?? ""} error={errors.toolName?.join(" ")} mono disabled={busy} onChange={(toolName) => update({ toolName })} />{!errors.toolName?.length && <small>{t("servers.tool_name_hint")}</small>}</div>
|
||||
<div><FormField label={t("servers.display_name")} value={draft.name} error={errors.name?.join(" ")} disabled={busy} onBlur={() => editor.blur("name")} onChange={(name) => change({ ...draft, name })} /><small>{t("servers.display_name_hint")}</small></div>
|
||||
<div><FormField label={t("servers.tool_name")} value={server.toolName ?? ""} error={errors.toolName?.join(" ")} mono disabled={busy} onBlur={() => editor.blur("toolName")} onChange={(toolName) => update({ toolName })} />{!errors.toolName?.length && <small>{t("servers.tool_name_hint")}</small>}</div>
|
||||
</div>
|
||||
<div><TextAreaField label={t("servers.ai_description")} value={server.description} disabled={busy} onChange={(description) => update({ description })} />
|
||||
<div onBlur={() => editor.blur("description")}><TextAreaField label={t("servers.ai_description")} value={server.description} disabled={busy} onChange={(description) => update({ description })} />
|
||||
<small>{t("servers.ai_description_hint")}</small><ServerErrors messages={errors.description} /></div>
|
||||
<ServerEnabled editor={editor} />
|
||||
</div>
|
||||
@@ -29,7 +29,7 @@ export function HttpToolForm({ editor }: { editor: ServerEditor }) {
|
||||
<div className="server-request-fields">
|
||||
<Segmented className="server-methods" value={server.method ?? "GET"} items={[["GET", "GET"], ["POST", "POST"]]}
|
||||
disabledKeys={busy ? ["GET", "POST"] : []} setValue={(method) => update({ method })} />
|
||||
<TemplateAddressField label={t("servers.request_url")} value={server.url} disabled={busy} invalid={Boolean(errors.url?.length)} onChange={(url) => update({ url })} />
|
||||
<TemplateAddressField label={t("servers.request_url")} value={server.url} disabled={busy} invalid={Boolean(errors.url?.length)} onBlur={() => editor.blur("url")} onChange={(url) => update({ url })} />
|
||||
</div>
|
||||
{!errors.url?.length && <small>{t("servers.url_hint")}</small>}
|
||||
<ServerErrors messages={errors.url} />
|
||||
|
||||
@@ -14,10 +14,11 @@ export function ServerConnection({ editor }: { editor: ServerEditor }) {
|
||||
<ServerEnabled editor={editor} />
|
||||
<div className="portal-settings-card server-form">
|
||||
<FormField label={t("servers.name")} value={draft.name} disabled={busy} error={errors.name?.join(" ")}
|
||||
onBlur={() => editor.blur("name")}
|
||||
onChange={(name) => change({ ...draft, name })} />
|
||||
<div><TextAreaField label={t("servers.description")} value={server.description} disabled={busy} onChange={(description) => update({ description })} />
|
||||
<small>{t("servers.description_hint")}</small></div>
|
||||
<div><FormField label={t("servers.address")} value={server.url} mono disabled={busy} onChange={(url) => update({ url })} />
|
||||
<div><FormField label={t("servers.address")} value={server.url} mono disabled={busy} onBlur={() => editor.blur("url")} onChange={(url) => update({ url })} />
|
||||
<small>{t("servers.address_hint")}</small><ServerErrors messages={errors.url} /></div>
|
||||
<ServerHeaders headers={server.headers} onChange={(headers) => update({ headers })} disabled={busy} mcp />
|
||||
<ServerErrors messages={errors.headers} />
|
||||
|
||||
@@ -24,7 +24,7 @@ export function ServerSaveActions({ editor, mcp = false }: { editor: ServerEdito
|
||||
return <>
|
||||
{editor.error && <div className="integration-form-error" role="alert">{editor.error}</div>}
|
||||
<div className="server-actions">
|
||||
<Button variant="primary" disabled={editor.busy || editor.checkingAddress || count > 0} onClick={() => void editor.save()}>{t(editor.saving ? "common.saving" : "common.save")}</Button>
|
||||
<Button variant="primary" disabled={editor.busy || editor.checkingAddress || (editor.validationSubmitted && count > 0)} onClick={() => void editor.save()}>{t(editor.saving ? "common.saving" : "common.save")}</Button>
|
||||
{mcp && <Button variant="secondary" icon="refresh" disabled={editor.busy || !editor.integration || editor.dirty}
|
||||
title={!editor.integration || editor.dirty ? t("servers.save_first") : undefined}
|
||||
onClick={() => void editor.action("test/")}>{t("servers.test")}</Button>}
|
||||
|
||||
+3
-3
@@ -1,14 +1,14 @@
|
||||
import { useId, useState } from "react";
|
||||
|
||||
// X6/X8: подстановки подсвечены; при вводе работает обычное текстовое поле.
|
||||
export function TemplateAddressField({ label, value, onChange, disabled, invalid }: {
|
||||
label: string; value: string; onChange: (value: string) => void; disabled: boolean; invalid: boolean;
|
||||
export function TemplateAddressField({ label, value, onChange, onBlur, disabled, invalid }: {
|
||||
label: string; value: string; onChange: (value: string) => void; onBlur?: () => void; disabled: boolean; invalid: boolean;
|
||||
}) {
|
||||
const id = useId();
|
||||
const [focused, setFocused] = useState(false);
|
||||
return <div className={`server-template-field${invalid ? " is-invalid" : ""}`}>
|
||||
<input id={id} aria-label={label} aria-invalid={invalid} type="text" value={value} disabled={disabled} onChange={(event) => onChange(event.target.value)}
|
||||
onFocus={() => setFocused(true)} onBlur={(event) => { event.target.scrollLeft = 0; setFocused(false); }} />
|
||||
onFocus={() => setFocused(true)} onBlur={(event) => { event.target.scrollLeft = 0; setFocused(false); onBlur?.(); }} />
|
||||
{!focused && <div aria-hidden="true">{value.split(/(\{[^{}]*\})/).map((part, i) => /^\{/.test(part)
|
||||
? <mark key={i}>{part}</mark> : <span key={i}>{part}</span>)}</div>}
|
||||
</div>;
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Integration } from "../model";
|
||||
import { serverDraft } from "./model";
|
||||
import { draftErrors, mergeErrors } from "./validation";
|
||||
import { useAddressValidation } from "./useAddressValidation";
|
||||
import { useServerValidation } from "./useServerValidation";
|
||||
import type { FieldErrors, ServerDraft, ServerKind } from "./types";
|
||||
|
||||
export function useServerEditor(kind: ServerKind, initial: Integration | null, onCreated: (id: number) => void) {
|
||||
@@ -16,14 +17,19 @@ export function useServerEditor(kind: ServerKind, initial: Integration | null, o
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const address = useAddressValidation(draft.externalServer.url, kind === "http");
|
||||
const errors = mergeErrors(serverErrors, draftErrors(draft), address.errors);
|
||||
const validation = useServerValidation();
|
||||
const address = useAddressValidation(draft.externalServer.url, kind === "http" && validation.showField("url"));
|
||||
const allErrors = mergeErrors(draftErrors(draft), address.errors);
|
||||
const errors = mergeErrors(validation.visibleErrors(allErrors), serverErrors);
|
||||
const dirty = JSON.stringify(draft) !== JSON.stringify(serverDraft(kind, integration));
|
||||
|
||||
function change(next: ServerDraft) { setDraft(next); setServerErrors({}); setError(""); setSaved(false); }
|
||||
function change(next: ServerDraft) {
|
||||
validation.change(draft, next);
|
||||
setDraft(next); setServerErrors({}); setError(""); setSaved(false);
|
||||
}
|
||||
function accept(next: Integration, resetDraft = false) {
|
||||
setIntegration(next);
|
||||
if (resetDraft) setDraft(serverDraft(kind, next));
|
||||
if (resetDraft) { setDraft(serverDraft(kind, next)); validation.reset(); }
|
||||
}
|
||||
function fail(caught: unknown) {
|
||||
if (caught instanceof ApiError && caught.status === 400) {
|
||||
@@ -33,7 +39,9 @@ export function useServerEditor(kind: ServerKind, initial: Integration | null, o
|
||||
} else setError(t("common.request_failed"));
|
||||
}
|
||||
async function save() {
|
||||
if (Object.keys(errors).length || busy || address.checking) return;
|
||||
if (busy || address.checking) return;
|
||||
validation.submit();
|
||||
if (Object.keys(allErrors).length || Object.keys(serverErrors).length) return;
|
||||
setBusy(true); setSaving(true); setError("");
|
||||
const { tools: _tools, toolsState: _state, toolsRefreshedAt: _refreshed, ...settings } = draft.externalServer;
|
||||
try {
|
||||
@@ -59,6 +67,7 @@ export function useServerEditor(kind: ServerKind, initial: Integration | null, o
|
||||
return true;
|
||||
} catch (caught) { fail(caught); return false; } finally { setBusy(false); setRefreshing(false); }
|
||||
}
|
||||
return { integration, draft, change, errors, error, busy, saving, refreshing, dirty, saved, save, action, checkingAddress: address.checking };
|
||||
return { integration, draft, change, errors, error, busy, saving, refreshing, dirty, saved, save, action,
|
||||
blur: validation.blur, validationSubmitted: validation.submitted, checkingAddress: address.checking };
|
||||
}
|
||||
export type ServerEditor = ReturnType<typeof useServerEditor>;
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useState } from "react";
|
||||
import type { FieldErrors, ServerDraft } from "./types";
|
||||
import { initialValidationState, validationAfterBlur, validationAfterChange, visibleValidationErrors } from "./validationState";
|
||||
|
||||
export function useServerValidation() {
|
||||
const [state, setState] = useState(initialValidationState);
|
||||
return {
|
||||
submitted: state.submitted,
|
||||
showField: (field: string) => state.submitted || state.blurred.includes(field),
|
||||
visibleErrors: (errors: FieldErrors) => visibleValidationErrors(errors, state),
|
||||
change: (previous: ServerDraft, next: ServerDraft) => setState((current) => validationAfterChange(current, previous, next)),
|
||||
blur: (field: string) => setState((current) => validationAfterBlur(current, field)),
|
||||
submit: () => setState((current) => ({ ...current, submitted: true })),
|
||||
reset: () => setState(initialValidationState),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { t } from "../../../i18n";
|
||||
import { HttpToolForm } from "./HttpToolForm";
|
||||
import { ServerConnection } from "./ServerConnection";
|
||||
import { serverDraft } from "./model";
|
||||
import { useServerEditor } from "./useServerEditor";
|
||||
import { draftErrors } from "./validation";
|
||||
import { initialValidationState, validationAfterBlur, validationAfterChange, visibleValidationErrors } from "./validationState";
|
||||
import type { ServerKind } from "./types";
|
||||
|
||||
describe("показ ошибок настроек MCP и HTTP", () => {
|
||||
it.each(["mcp", "http"] as const)("открывает пустую форму %s без ошибок и позволяет проверить сохранение", (kind: ServerKind) => {
|
||||
function Form() {
|
||||
const editor = useServerEditor(kind, null, () => undefined);
|
||||
return kind === "mcp" ? <ServerConnection editor={editor} /> : <HttpToolForm editor={editor} />;
|
||||
}
|
||||
const markup = renderToStaticMarkup(<Form />);
|
||||
expect(markup).not.toContain('role="alert"');
|
||||
expect(markup).not.toContain("is-invalid");
|
||||
expect(markup).not.toContain('aria-invalid="true"');
|
||||
expect(markup).toMatch(new RegExp(`<button[^>]*(?<!disabled="")>${t("common.save")}</button>`));
|
||||
const saveButton = markup.match(new RegExp(`<button[^>]*>${t("common.save")}</button>`))?.[0];
|
||||
expect(saveButton).not.toContain("disabled");
|
||||
});
|
||||
|
||||
it("не показывает ошибки при выходе из нетронутого поля или во время первого ввода", () => {
|
||||
const draft = serverDraft("http");
|
||||
expect(visibleValidationErrors(draftErrors(draft), validationAfterBlur(initialValidationState, "name"))).toEqual({});
|
||||
const changed = { ...draft, name: " " };
|
||||
const state = validationAfterChange(initialValidationState, draft, changed);
|
||||
expect(visibleValidationErrors(draftErrors(changed), state)).toEqual({});
|
||||
expect(Object.keys(visibleValidationErrors(draftErrors(changed), validationAfterBlur(state, "name")))).toEqual(["name"]);
|
||||
});
|
||||
|
||||
it("показывает ошибку изменённого HTTP-поля после выхода и убирает её после исправления", () => {
|
||||
const draft = serverDraft("http");
|
||||
const invalid = { ...draft, externalServer: { ...draft.externalServer, toolName: "get order" } };
|
||||
const state = validationAfterBlur(validationAfterChange(initialValidationState, draft, invalid), "toolName");
|
||||
expect(Object.keys(visibleValidationErrors(draftErrors(invalid), state))).toEqual(["toolName"]);
|
||||
const corrected = { ...invalid, externalServer: { ...invalid.externalServer, toolName: "get_order" } };
|
||||
expect(visibleValidationErrors(draftErrors(corrected), state)).toEqual({});
|
||||
});
|
||||
|
||||
it.each(["mcp", "http"] as const)("после попытки сохранения %s показывает все ошибки, сохраняя обязательность полей", (kind) => {
|
||||
const errors = draftErrors(serverDraft(kind));
|
||||
expect(Object.keys(errors)).toEqual(kind === "mcp" ? ["name", "url"] : ["name", "url", "toolName", "description"]);
|
||||
expect(visibleValidationErrors(errors, { ...initialValidationState, submitted: true })).toEqual(errors);
|
||||
});
|
||||
|
||||
it("не раскрывает ошибку адреса при изменении другого поля", () => {
|
||||
const draft = serverDraft("http");
|
||||
const next = { ...draft, name: "Название" };
|
||||
const state = validationAfterBlur(validationAfterChange(initialValidationState, draft, next), "name");
|
||||
expect(visibleValidationErrors({ url: ["Недопустимый адрес"] }, state)).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { FieldErrors, ServerDraft } from "./types";
|
||||
|
||||
export type ValidationState = { changed: string[]; blurred: string[]; submitted: boolean };
|
||||
export const initialValidationState: ValidationState = { changed: [], blurred: [], submitted: false };
|
||||
|
||||
export function validationAfterChange(state: ValidationState, previous: ServerDraft, next: ServerDraft): ValidationState {
|
||||
const fields = new Set(state.changed);
|
||||
if (previous.name !== next.name) fields.add("name");
|
||||
for (const key of Object.keys(next.externalServer) as Array<keyof ServerDraft["externalServer"]>) {
|
||||
if (JSON.stringify(previous.externalServer[key]) !== JSON.stringify(next.externalServer[key])) fields.add(key);
|
||||
}
|
||||
return { ...state, changed: [...fields] };
|
||||
}
|
||||
|
||||
export function validationAfterBlur(state: ValidationState, field: string): ValidationState {
|
||||
if (!state.changed.includes(field) || state.blurred.includes(field)) return state;
|
||||
return { ...state, blurred: [...state.blurred, field] };
|
||||
}
|
||||
|
||||
export function visibleValidationErrors(errors: FieldErrors, state: ValidationState): FieldErrors {
|
||||
return Object.fromEntries(Object.entries(errors).filter(([field]) => state.submitted || state.blurred.includes(field)));
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
# Chatballs 1.17.1
|
||||
|
||||
## Исправления
|
||||
|
||||
- Настройки MCP-сервера и HTTP-запроса открываются без красной подсветки
|
||||
и сообщений об ошибках для ещё не изменённых полей.
|
||||
- Ошибка изменённого поля появляется после выхода из него и исчезает
|
||||
после исправления значения. Нетронутые поля остаются нейтральными.
|
||||
- При попытке сохранения проверяется вся форма. Некорректные данные
|
||||
не сохраняются.
|
||||
|
||||
## Проверки
|
||||
|
||||
Пройдены 21 тест затронутых сценариев и сборка внутреннего интерфейса.
|
||||
Браузерная проверка не завершена: штатный локальный backend не отвечает,
|
||||
а инструмент Windows Computer Use не смог достоверно определить URL.
|
||||
|
||||
## Обновление
|
||||
|
||||
Изменения относительно 1.17.0 затрагивают только внутренний интерфейс.
|
||||
Новых миграций базы данных нет.
|
||||
Reference in new issue
Block a user