File size: 11,419 Bytes
adc1e1c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
/*
Copyright (c) 2025 Tethys Plex

This file is part of Veloera.

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
  Button,
  Input,
  Typography,
  TextArea,
} from '@douyinfe/semi-ui';
import {
  IconPlusCircle,
  IconMinusCircle,
} from '@douyinfe/semi-icons';

const MODEL_MAPPING_EXAMPLE = {
  'gpt-3.5-turbo': 'gpt-3.5-turbo-0125',
};

// Unsafe keys to prevent prototype pollution
const UNSAFE_KEYS = new Set(['__proto__', 'prototype', 'constructor']);

/**
 * ModelMappingEditor 组件 - 用于可视化编辑模型映射配置
 * 
 * @param {Object} props
 * @param {string} props.value - 当前的 JSON 字符串值
 * @param {Function} props.onChange - 值变化时的回调函数
 * @param {string} props.placeholder - 占位符文本
 * @param {Function} props.onRealtimeChange - 实时变化回调(可选)
 */
const ModelMappingEditor = ({ value, onChange, placeholder, onRealtimeChange }) => {
  const { t } = useTranslation();
  const [mappingPairs, setMappingPairs] = useState([]);
  const [mode, setMode] = useState('visual'); // 'visual' or 'json'
  const [jsonValue, setJsonValue] = useState('');
  const [jsonError, setJsonError] = useState('');

  // 用于标记是否是内部更新,避免循环更新
  const isInternalUpdateRef = useRef(false);

  // Parse JSON value to key-value pairs
  const parseJsonToMappings = (jsonStr) => {
    if (!jsonStr || jsonStr.trim() === '') {
      return [];
    }
    try {
      const parsed = JSON.parse(jsonStr);
      if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
        const unsafe = UNSAFE_KEYS;
        return Object.entries(parsed)
          .map(([key, value]) => [String(key).trim(), value])
          .filter(([k]) => k !== '' && !unsafe.has(k))
          .map(([key, value]) => ({
            id: Date.now() + Math.random(),
            key,
            value: value == null ? '' : String(value).trim(),
          }));
      }
      return [];
    } catch {
      return [];
    }
  };

  // Convert key-value pairs to JSON string
  const mappingsToJson = (pairs) => {
    if (!pairs || pairs.length === 0) {
      return '';
    }
    const obj = Object.create(null);
    const unsafe = UNSAFE_KEYS;
    pairs.forEach(pair => {
      if (pair.key && pair.key.trim() !== '') {
        const k = String(pair.key).trim();
        if (!unsafe.has(k)) {
          const v = pair.value == null ? '' : String(pair.value).trim();
          obj[k] = v;
        }
       }
    });
    return Object.keys(obj).length > 0 ? JSON.stringify(obj, null, 2) : '';
  };

  // Initialize component state from value prop
  useEffect(() => {
    // 只有在非内部更新时才同步外部状态
    if (!isInternalUpdateRef.current) {
      const pairs = parseJsonToMappings(value);
      setMappingPairs(pairs.length > 0 ? pairs : [{ id: Date.now() + Math.random(), key: '', value: '' }]);
      setJsonValue(value || '');
      setJsonError('');
    }
    isInternalUpdateRef.current = false;
  }, [value]);

  // Add new mapping pair
  const addMappingPair = () => {
    const newPairs = [...mappingPairs, { id: Date.now() + Math.random(), key: '', value: '' }];
    setMappingPairs(newPairs);
  };

  // Remove mapping pair
  const removeMappingPair = (index) => {
    const newPairs = mappingPairs.filter((_, i) => i !== index);
    const finalPairs = newPairs.length > 0 ? newPairs : [{ id: Date.now() + Math.random(), key: '', value: '' }];
    setMappingPairs(finalPairs);

    // 立即更新父组件
    const jsonStr = mappingsToJson(finalPairs);
    isInternalUpdateRef.current = true;
    onChange(jsonStr);

    // 触发实时同步
    if (onRealtimeChange) {
      onRealtimeChange(jsonStr);
    }
  };

  // Update mapping pair - 只更新本地状态,不触发父组件更新
  const updateMappingPair = (index, field, value) => {
    if (index < 0 || index >= mappingPairs.length) return;
    if (field !== 'key' && field !== 'value') return;
    const newPairs = [...mappingPairs];
    newPairs[index] = { ...newPairs[index], [field]: value };
    setMappingPairs(newPairs);

    // 触发实时同步回调,但不更新父组件的 model_mapping 状态
    if (onRealtimeChange) {
      const jsonStr = mappingsToJson(newPairs);
      onRealtimeChange(jsonStr);
    }
  };

  // 在失去焦点时同步到父组件
  const handleInputBlur = () => {
    const jsonStr = mappingsToJson(mappingPairs);
    isInternalUpdateRef.current = true;
    onChange(jsonStr);
  };

  // Handle mode switch
  const switchMode = (newMode) => {
    if (newMode === 'json' && mode === 'visual') {
      // Switching from visual to JSON
      const jsonStr = mappingsToJson(mappingPairs);
      setJsonValue(jsonStr);
      setJsonError('');
    } else if (newMode === 'visual' && mode === 'json') {
      // Switching from JSON to visual
      if (jsonValue.trim() === '') {
        setMappingPairs([{ id: Date.now() + Math.random(), key: '', value: '' }]);
        setJsonError('');
      } else {
        const pairs = parseJsonToMappings(jsonValue);
        setMappingPairs(pairs.length > 0 ? pairs : [{ id: Date.now() + Math.random(), key: '', value: '' }]);
        setJsonError('');
      }
    }
    setMode(newMode);
  };

  // Handle JSON input change
  const handleJsonChange = (newValue) => {
    setJsonValue(newValue);

    // Validate JSON
    if (newValue.trim() === '') {
      setJsonError('');
      // 触发实时同步
      if (onRealtimeChange) {
        onRealtimeChange('');
      }
      return;
    }

    try {
      const parsed = JSON.parse(newValue);
      if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
        // Filter out unsafe keys before calling onRealtimeChange
        const unsafe = UNSAFE_KEYS;
        const filtered = Object.fromEntries(
          Object.entries(parsed).filter(([k]) => !unsafe.has(k))
        );
        setJsonError('');
        // 触发实时同步
        if (onRealtimeChange) {
          onRealtimeChange(JSON.stringify(filtered, null, 2));
        }
      } else {
        setJsonError(t('请输入有效的JSON对象格式'));
      }
    } catch (error) {
      setJsonError(t('JSON格式错误: {{message}}', { message: error.message }));
    }
  };

  // 在 JSON 模式失去焦点时同步到父组件
  const handleJsonBlur = () => {
    if (!jsonError && jsonValue.trim() !== '') {
      try {
        const parsed = JSON.parse(jsonValue);
        if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
          const sanitized = mappingsToJson(parseJsonToMappings(jsonValue));
          isInternalUpdateRef.current = true;
          onChange(sanitized);
          setJsonValue(sanitized);
        }
      } catch (error) {
        // 忽略错误,不更新父组件
      }
    } else if (jsonValue.trim() === '') {
      isInternalUpdateRef.current = true;
      onChange('');
    }
  };

  // Fill template
  const fillTemplate = () => {
    const templateJson = JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2);
    if (mode === 'visual') {
      const pairs = parseJsonToMappings(templateJson);
      setMappingPairs(pairs);
    } else {
      setJsonValue(templateJson);
    }

    // 立即更新父组件和触发实时同步
    isInternalUpdateRef.current = true;
    onChange(templateJson);
    if (onRealtimeChange) {
      onRealtimeChange(templateJson);
    }
  };

  return (
    <div>
      <div style={{ display: 'flex', alignItems: 'center', marginBottom: 10 }}>
        <div style={{ display: 'flex', marginRight: 16 }}>
          <Button
            type={mode === 'visual' ? 'primary' : 'tertiary'}
            onClick={() => switchMode('visual')}
            style={{
              borderRadius: '6px 0 0 6px'
            }}
          >
            {t('可视化编辑')}
          </Button>
          <Button
            type={mode === 'json' ? 'primary' : 'tertiary'}
            onClick={() => switchMode('json')}
            style={{
              borderRadius: '0 6px 6px 0'
            }}
          >
            {t('JSON编辑')}
          </Button>
        </div>
        <Typography.Text
          style={{
            color: 'rgba(var(--semi-blue-5), 1)',
            userSelect: 'none',
            cursor: 'pointer',
          }}
          onClick={fillTemplate}
        >
          {t('填入模板')}
        </Typography.Text>
      </div>

      {mode === 'visual' ? (
        <div>
          <div style={{ marginBottom: 10 }}>
            <Typography.Text type="secondary">{placeholder}</Typography.Text>
          </div>

          {mappingPairs.map((pair, index) => (
            <div key={pair.id} style={{ display: 'flex', alignItems: 'center', marginBottom: 8 }}>
              <Input
                placeholder={t('目标模型名称')}
                value={pair.key}
                onChange={(value) => updateMappingPair(index, 'key', value)}
                onBlur={handleInputBlur}
                style={{ flex: 1, marginRight: 8 }}
              />
              <Typography.Text style={{ margin: '0 8px' }}></Typography.Text>
              <Input
                placeholder={t('实际模型名称')}
                value={pair.value}
                onChange={(value) => updateMappingPair(index, 'value', value)}
                onBlur={handleInputBlur}
                style={{ flex: 1, marginRight: 8 }}
              />
              <Button
                type="danger"
                icon={<IconMinusCircle />}
                size="small"
                onClick={() => removeMappingPair(index)}
                style={{ marginLeft: 4 }}
              />
            </div>
          ))}

          <Button
            type="tertiary"
            icon={<IconPlusCircle />}
            onClick={addMappingPair}
            style={{ width: '100%', marginTop: 8 }}
          >
            {t('添加映射')}
          </Button>
        </div>
      ) : (
        <div>
          <TextArea
            placeholder={
              t(
                '此项可选用于修改请求体中的模型名称为一个 JSON 字符串键为请求中模型名称值为要替换的模型名称例如:',
              ) + `\n${JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2)}`
            }
            value={jsonValue}
            onChange={handleJsonChange}
            onBlur={handleJsonBlur}
            autosize
            autoComplete='new-password'
          />
          {jsonError && (
            <Typography.Text type="danger" style={{ marginTop: 4, display: 'block' }}>
              {jsonError}
            </Typography.Text>
          )}
        </div>
      )}
    </div>
  );
};

export default ModelMappingEditor;