Merge pull request 'Refactor AI chat: streaming, tool calls, UI polish' (#51) from refactor-ai-chat-streaming into main

This commit is contained in:
2026-07-24 08:28:10 +00:00
55 changed files with 2774 additions and 459 deletions

View File

@@ -15,6 +15,7 @@ import {
Typography,
Space,
Steps,
Switch,
} from 'antd';
import {
SaveOutlined,
@@ -57,6 +58,7 @@ interface AiConfigData {
keySource: 'database' | 'environment' | 'none';
defaultModel: string | null;
enabled: boolean;
supportsVision: boolean;
timeoutMs: number;
verified: boolean;
lastTestedAt: string | null;
@@ -96,6 +98,7 @@ interface FormValues {
apiKey: string;
defaultModel: string;
timeoutMs: number;
supportsVision: boolean;
}
const DEFAULT_FORM_VALUES: FormValues = {
@@ -104,6 +107,7 @@ const DEFAULT_FORM_VALUES: FormValues = {
apiKey: '',
defaultModel: '',
timeoutMs: 30000,
supportsVision: false,
};
// ---------------------------------------------------------------------------
@@ -166,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);
@@ -247,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');
@@ -263,6 +268,7 @@ const AiConfigPage: React.FC = () => {
baseUrl: resolvedBaseUrl,
defaultModel: defaultModel || undefined,
enabled: true,
supportsVision,
timeoutMs,
};
@@ -566,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">
@@ -623,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 ? '已启用' : '未启用'}

View File

@@ -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"

View File

@@ -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 },
});
});
});

View File

@@ -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;
}