File size: 6,334 Bytes
1ec996f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
import React, { useMemo, useState } from 'react';
import { RotateCcw } from 'lucide-react';

/**
 * Settings modal for the user-tunable repetition / failsafe limits.
 *
 * The schema (defaults, bounds, descriptions, group assignments) is
 * fetched from GET /api/chat/limits/defaults so the entire UI is
 * server-driven: adding a knob in `services.models.ConversationLimits`
 * makes it appear here automatically. The user's overrides are
 * persisted to localStorage and sent on the next /chat/start, where
 * the backend re-clamps them to the declared bounds.
 *
 * "Effective" values shown to the user are: override (if set) ->
 * server default. Values are pre-clamped client-side too so the
 * stepper UI stays in range.
 */
export default function ConversationLimitsModal({
  isOpen,
  schema,
  overrides,
  onClose,
  onChange,
  onResetAll,
}) {
  const [draft, setDraft] = useState(() => ({ ...(overrides || {}) }));

  // Reset draft whenever the modal is reopened so we don't leak edits
  // from a previous open.
  React.useEffect(() => {
    if (isOpen) setDraft({ ...(overrides || {}) });
  }, [isOpen, overrides]);

  const grouped = useMemo(() => groupFields(schema), [schema]);

  if (!isOpen) return null;

  if (!schema) {
    return (
      <div className="ccai-credentials-overlay">
        <div className="ccai-credentials-card">
          <div className="ccai-credentials-header">
            <div>
              <h2>Conversation limits</h2>
            </div>
            <div className="ccai-tab-spacer" />
            <button className="modal-close" onClick={onClose}>&times;</button>
          </div>
          <div className="ccai-credentials-body">
            <div className="ccai-credentials-empty">Loading limits...</div>
          </div>
        </div>
      </div>
    );
  }

  const handleChange = (field, value) => {
    const next = { ...draft, [field]: value };
    setDraft(next);
    onChange?.(next);
  };

  const handleResetField = (field) => {
    const next = { ...draft };
    delete next[field];
    setDraft(next);
    onChange?.(next);
  };

  const handleResetAll = () => {
    setDraft({});
    onResetAll?.();
  };

  return (
    <div className="ccai-credentials-overlay">
      <div className="ccai-credentials-card">
        <div className="ccai-credentials-header">
          <div>
            <h2>Conversation limits</h2>
            <div className="ccai-credentials-subtitle">
              These knobs control how long each phase of the discussion
              runs and when the conversation pauses for a Continue
              confirmation. Changes apply to the next chat you start.
            </div>
          </div>
          <div className="ccai-tab-spacer" />
          <button
            className="btn-sm btn-outline"
            onClick={handleResetAll}
            title="Restore every knob to the server default"
          >
            <RotateCcw size={14} style={{ marginRight: 4 }} />
            Reset all
          </button>
          <button className="modal-close" onClick={onClose}>&times;</button>
        </div>

        <div className="ccai-credentials-body">
          {grouped.map(({ group, fields }) => (
            <div key={group} className="ccai-limits-group">
              <div className="ccai-limits-group-title">{group}</div>
              {fields.map((f) => (
                <LimitRow
                  key={f.field}
                  field={f.field}
                  label={f.label}
                  help={f.help}
                  defaultValue={f.defaultValue}
                  min={f.min}
                  max={f.max}
                  override={draft[f.field]}
                  onChange={(v) => handleChange(f.field, v)}
                  onResetField={() => handleResetField(f.field)}
                />
              ))}
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}

function LimitRow({
  field,
  label,
  help,
  defaultValue,
  min,
  max,
  override,
  onChange,
  onResetField,
}) {
  const isOverride = override !== undefined && override !== null;
  const effective = isOverride ? override : defaultValue;

  const setClamped = (raw) => {
    if (raw === '' || raw === null || raw === undefined) {
      // Empty input = revert to default for this field.
      onResetField();
      return;
    }
    let n = parseInt(raw, 10);
    if (Number.isNaN(n)) return;
    if (n < min) n = min;
    if (n > max) n = max;
    onChange(n);
  };

  return (
    <div className="ccai-limit-row">
      <div className="ccai-limit-row-head">
        <label className="ccai-limit-label" htmlFor={`limit-${field}`}>
          {label}
        </label>
        <div className="ccai-limit-stepper">
          <input
            id={`limit-${field}`}
            type="number"
            min={min}
            max={max}
            step={1}
            value={effective}
            onChange={(e) => setClamped(e.target.value)}
            className="ccai-limit-input"
          />
          <span className="ccai-limit-range">
            ({min}-{max}, default {defaultValue})
          </span>
          {isOverride && (
            <button
              className="ccai-limit-reset"
              onClick={onResetField}
              title="Reset this field to the default"
            >
              reset
            </button>
          )}
        </div>
      </div>
      <div className="ccai-limit-help">{help}</div>
    </div>
  );
}

/**
 * Convert the flat `descriptions` map into [{group, fields[]}] in
 * the order the groups first appear, then the order each field is
 * declared in `bounds` (which mirrors the dataclass field order).
 */
function groupFields(schema) {
  if (!schema) return [];
  const { defaults, bounds, descriptions } = schema;
  const orderedFields = Object.keys(bounds);
  const seen = new Map();
  for (const field of orderedFields) {
    const desc = descriptions[field] || {};
    const group = desc.group || 'Other';
    if (!seen.has(group)) seen.set(group, []);
    seen.get(group).push({
      field,
      label: desc.label || field,
      help: desc.help || '',
      defaultValue: defaults[field],
      min: bounds[field].min,
      max: bounds[field].max,
    });
  }
  return Array.from(seen.entries()).map(([group, fields]) => ({ group, fields }));
}