Refactor AI chat: streaming, tool calls, UI polish
All checks were successful
CI / check (pull_request) Successful in 3m25s
All checks were successful
CI / check (pull_request) Successful in 3m25s
This commit is contained in:
@@ -15,13 +15,13 @@ import {
|
||||
Typography,
|
||||
Space,
|
||||
Steps,
|
||||
Switch,
|
||||
} from 'antd';
|
||||
import {
|
||||
SaveOutlined,
|
||||
ApiOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
KeyOutlined,
|
||||
WarningOutlined,
|
||||
ReloadOutlined,
|
||||
CloudServerOutlined,
|
||||
@@ -58,6 +58,7 @@ interface AiConfigData {
|
||||
keySource: 'database' | 'environment' | 'none';
|
||||
defaultModel: string | null;
|
||||
enabled: boolean;
|
||||
supportsVision: boolean;
|
||||
timeoutMs: number;
|
||||
verified: boolean;
|
||||
lastTestedAt: string | null;
|
||||
@@ -97,6 +98,7 @@ interface FormValues {
|
||||
apiKey: string;
|
||||
defaultModel: string;
|
||||
timeoutMs: number;
|
||||
supportsVision: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_FORM_VALUES: FormValues = {
|
||||
@@ -105,6 +107,7 @@ const DEFAULT_FORM_VALUES: FormValues = {
|
||||
apiKey: '',
|
||||
defaultModel: '',
|
||||
timeoutMs: 30000,
|
||||
supportsVision: false,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -167,6 +170,7 @@ const AiConfigPage: React.FC = () => {
|
||||
apiKey: '',
|
||||
defaultModel: res.data.defaultModel ?? '',
|
||||
timeoutMs: res.data.timeoutMs,
|
||||
supportsVision: res.data.supportsVision,
|
||||
};
|
||||
form.setFieldsValue(initial);
|
||||
setFormValues(initial);
|
||||
@@ -248,7 +252,7 @@ const AiConfigPage: React.FC = () => {
|
||||
// Validate fields (for UI error display) — actual values come from state
|
||||
await form.validateFields(['provider', 'baseUrl', 'timeoutMs']);
|
||||
|
||||
const { provider, baseUrl, defaultModel, apiKey, timeoutMs } = formValues;
|
||||
const { provider, baseUrl, defaultModel, apiKey, timeoutMs, supportsVision } = formValues;
|
||||
|
||||
if (provider === 'OPENAI_COMPATIBLE' && !baseUrl) {
|
||||
message.error('OPENAI_COMPATIBLE 模式必须填写 Base URL');
|
||||
@@ -264,6 +268,7 @@ const AiConfigPage: React.FC = () => {
|
||||
baseUrl: resolvedBaseUrl,
|
||||
defaultModel: defaultModel || undefined,
|
||||
enabled: true,
|
||||
supportsVision,
|
||||
timeoutMs,
|
||||
};
|
||||
|
||||
@@ -567,6 +572,16 @@ const AiConfigPage: React.FC = () => {
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="supportsVision"
|
||||
label="图片理解"
|
||||
valuePropName="checked"
|
||||
extra="仅当所选模型确实支持图片输入时开启;关闭时 AI 助手会阻止发送图片。"
|
||||
preserve
|
||||
>
|
||||
<Switch disabled={!canWrite} checkedChildren="已启用" unCheckedChildren="未启用" />
|
||||
</Form.Item>
|
||||
|
||||
{config?.verified && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Tag icon={<CheckCircleOutlined />} color="success">
|
||||
@@ -624,6 +639,11 @@ const AiConfigPage: React.FC = () => {
|
||||
<Descriptions.Item label="超时">
|
||||
{formValues.timeoutMs}ms
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="图片理解">
|
||||
<Tag color={formValues.supportsVision ? 'blue' : 'default'}>
|
||||
{formValues.supportsVision ? '已启用' : '未启用'}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={config?.enabled ? 'green' : 'default'}>
|
||||
{config?.enabled ? '已启用' : '未启用'}
|
||||
|
||||
@@ -41,6 +41,12 @@ import {
|
||||
isAppSecretRequired,
|
||||
type DingTalkConfigFormValues,
|
||||
} from './integration-config-form';
|
||||
import {
|
||||
cacheDingTalkDraft,
|
||||
cacheDingTalkServerSnapshot,
|
||||
commitDingTalkConfig,
|
||||
readDingTalkConfigCache,
|
||||
} from './integration-config-cache';
|
||||
|
||||
interface DingTalkConfig {
|
||||
agentId: string;
|
||||
@@ -111,13 +117,14 @@ interface DeleteAttendanceGroupsResponse {
|
||||
}
|
||||
|
||||
const IntegrationConfigPage: React.FC = () => {
|
||||
const initialCache = useMemo(() => readDingTalkConfigCache(), []);
|
||||
const { hasPermission, hasAllPermissions } = usePermission();
|
||||
const canCreateClass = hasPermission('class:create');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loading, setLoading] = useState(!initialCache.loaded);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [config, setConfig] = useState<DingTalkConfig | null>(null);
|
||||
const [verified, setVerified] = useState<boolean | null>(null);
|
||||
const [config, setConfig] = useState<DingTalkConfig | null>(initialCache.config);
|
||||
const [verified, setVerified] = useState<boolean | null>(initialCache.verified);
|
||||
const [form] = Form.useForm<DingTalkConfigFormValues>();
|
||||
|
||||
// ── Manual organization sync ──
|
||||
@@ -137,8 +144,8 @@ const IntegrationConfigPage: React.FC = () => {
|
||||
const [loadingGroups, setLoadingGroups] = useState(false);
|
||||
const [deletingGroups, setDeletingGroups] = useState(false);
|
||||
|
||||
const fetchConfig = async () => {
|
||||
setLoading(true);
|
||||
const fetchConfig = useCallback(async (showLoading = false) => {
|
||||
if (showLoading) setLoading(true);
|
||||
try {
|
||||
const res = await api.get<{
|
||||
success: boolean;
|
||||
@@ -148,18 +155,24 @@ const IntegrationConfigPage: React.FC = () => {
|
||||
if (dt) {
|
||||
setConfig(dt.config);
|
||||
setVerified(dt.verify);
|
||||
form.setFieldsValue(dt.config);
|
||||
cacheDingTalkServerSnapshot(dt.config, dt.verify);
|
||||
form.setFieldsValue(readDingTalkConfigCache().formValues);
|
||||
} else {
|
||||
setConfig(null);
|
||||
setVerified(null);
|
||||
cacheDingTalkServerSnapshot(null, null);
|
||||
}
|
||||
} catch {
|
||||
// not configured
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (showLoading) setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [form]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchConfig();
|
||||
}, []);
|
||||
form.setFieldsValue(initialCache.formValues);
|
||||
void fetchConfig(!initialCache.loaded);
|
||||
}, [fetchConfig, form, initialCache]);
|
||||
|
||||
const handleSave = async () => {
|
||||
const values = await form.validateFields();
|
||||
@@ -168,6 +181,8 @@ const IntegrationConfigPage: React.FC = () => {
|
||||
try {
|
||||
await api.post('/integration/config', { type: 'DINGTALK', config: payload });
|
||||
message.success('配置已保存');
|
||||
commitDingTalkConfig({ corpId: payload.corpId, agentId: payload.agentId });
|
||||
form.setFieldValue('appSecret', undefined);
|
||||
await fetchConfig();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
@@ -631,7 +646,13 @@ const IntegrationConfigPage: React.FC = () => {
|
||||
showIcon
|
||||
/>
|
||||
|
||||
<Form form={form} layout="vertical" style={{ maxWidth: 520 }}>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={initialCache.formValues}
|
||||
onValuesChange={(_changed, values) => cacheDingTalkDraft(values)}
|
||||
style={{ maxWidth: 520 }}
|
||||
>
|
||||
<Form.Item
|
||||
name="corpId"
|
||||
label="CorpId(企业ID)"
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
cacheDingTalkDraft,
|
||||
cacheDingTalkServerSnapshot,
|
||||
commitDingTalkConfig,
|
||||
readDingTalkConfigCache,
|
||||
resetDingTalkConfigCache,
|
||||
} from './integration-config-cache';
|
||||
|
||||
describe('DingTalk integration config page cache', () => {
|
||||
beforeEach(resetDingTalkConfigCache);
|
||||
|
||||
it('keeps an unsaved secret when a background refresh returns', () => {
|
||||
cacheDingTalkDraft({ corpId: 'draft-corp', agentId: 'draft-key', appSecret: 'draft-secret' });
|
||||
cacheDingTalkServerSnapshot({ corpId: 'saved-corp', agentId: 'saved-key' }, true);
|
||||
|
||||
expect(readDingTalkConfigCache()).toMatchObject({
|
||||
loaded: true,
|
||||
dirty: true,
|
||||
config: { corpId: 'saved-corp', agentId: 'saved-key' },
|
||||
formValues: {
|
||||
corpId: 'draft-corp',
|
||||
agentId: 'draft-key',
|
||||
appSecret: 'draft-secret',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('clears the secret after a successful save', () => {
|
||||
cacheDingTalkDraft({ corpId: 'corp', agentId: 'key', appSecret: 'secret' });
|
||||
commitDingTalkConfig({ corpId: 'corp', agentId: 'key' });
|
||||
|
||||
expect(readDingTalkConfigCache()).toMatchObject({
|
||||
loaded: true,
|
||||
dirty: false,
|
||||
formValues: { corpId: 'corp', agentId: 'key', appSecret: undefined },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { DingTalkConfigFormValues } from './integration-config-form';
|
||||
|
||||
export interface DingTalkSavedConfig {
|
||||
agentId: string;
|
||||
corpId: string;
|
||||
}
|
||||
|
||||
interface DingTalkConfigCache {
|
||||
loaded: boolean;
|
||||
config: DingTalkSavedConfig | null;
|
||||
verified: boolean | null;
|
||||
formValues: Partial<DingTalkConfigFormValues>;
|
||||
dirty: boolean;
|
||||
}
|
||||
|
||||
const cache: DingTalkConfigCache = {
|
||||
loaded: false,
|
||||
config: null,
|
||||
verified: null,
|
||||
formValues: {},
|
||||
dirty: false,
|
||||
};
|
||||
|
||||
export function readDingTalkConfigCache(): DingTalkConfigCache {
|
||||
return {
|
||||
...cache,
|
||||
config: cache.config ? { ...cache.config } : null,
|
||||
formValues: { ...cache.formValues },
|
||||
};
|
||||
}
|
||||
|
||||
export function cacheDingTalkDraft(values: Partial<DingTalkConfigFormValues>): void {
|
||||
cache.formValues = { ...values };
|
||||
cache.dirty = true;
|
||||
}
|
||||
|
||||
export function cacheDingTalkServerSnapshot(
|
||||
config: DingTalkSavedConfig | null,
|
||||
verified: boolean | null,
|
||||
): void {
|
||||
cache.loaded = true;
|
||||
cache.config = config ? { ...config } : null;
|
||||
cache.verified = verified;
|
||||
if (!cache.dirty) {
|
||||
cache.formValues = config ? { ...config, appSecret: undefined } : {};
|
||||
}
|
||||
}
|
||||
|
||||
export function commitDingTalkConfig(config: DingTalkSavedConfig): void {
|
||||
cache.loaded = true;
|
||||
cache.config = { ...config };
|
||||
cache.formValues = { ...config, appSecret: undefined };
|
||||
cache.dirty = false;
|
||||
}
|
||||
|
||||
export function resetDingTalkConfigCache(): void {
|
||||
cache.loaded = false;
|
||||
cache.config = null;
|
||||
cache.verified = null;
|
||||
cache.formValues = {};
|
||||
cache.dirty = false;
|
||||
}
|
||||
Reference in New Issue
Block a user