Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src-tauri/src/app_settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,23 @@ pub(crate) fn get_gui_settings(
Ok(GuiSettings::from(&config))
}

#[tauri::command]
pub(crate) fn get_api_access_order_scheduling(
gui_config_state: tauri::State<'_, GuiConfigState>,
) -> Result<bool, String> {
let config = gui_config_state.snapshot()?;
Ok(config.api_access_order_scheduling)
}

#[tauri::command]
pub(crate) fn set_api_access_order_scheduling(
enabled: bool,
gui_config_state: tauri::State<'_, GuiConfigState>,
) -> Result<bool, String> {
gui_config_state.set_api_access_order_scheduling(enabled)?;
Ok(enabled)
}

#[tauri::command]
pub(crate) fn resolve_api_access_remarks(
queries: Vec<ApiAccessRemarkQuery>,
Expand Down
11 changes: 11 additions & 0 deletions src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -597,6 +597,7 @@ struct GuiConfigFile {
#[serde(deserialize_with = "deserialize_gui_api_keys")]
api_keys: Vec<GuiApiKeyEntry>,
api_access_remarks: Vec<GuiApiAccessRemark>,
api_access_order_scheduling: bool,
management_secret_key: String,
debug: bool,
commercial_mode: bool,
Expand Down Expand Up @@ -889,6 +890,7 @@ impl Default for GuiConfigFile {
auth_dir: DEFAULT_AUTH_DIR.to_string(),
api_keys: vec![default_api_key_entry()],
api_access_remarks: Vec::new(),
api_access_order_scheduling: false,
// Populated with an OS-generated secret while loading the GUI
// configuration. Core hashes the value written into config.yaml.
management_secret_key: String::new(),
Expand Down Expand Up @@ -1976,6 +1978,13 @@ impl GuiConfigState {
})
}

fn set_api_access_order_scheduling(&self, enabled: bool) -> Result<GuiConfigFile, String> {
self.update(|config| {
config.api_access_order_scheduling = enabled;
Ok(())
})
}

fn set_software_preferences(
&self,
close_behavior: WindowsCloseBehavior,
Expand Down Expand Up @@ -2481,6 +2490,8 @@ fn main() {
resolve_windows_close_request,
get_software_settings,
save_software_settings,
get_api_access_order_scheduling,
set_api_access_order_scheduling,
get_agent_config_statuses,
refresh_agent_config_statuses,
get_agent_models,
Expand Down
16 changes: 16 additions & 0 deletions src-tauri/src/tests/app_settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ fn gui_config_defaults_are_stable() {
assert!(content.contains("max-retry-credentials = 0"));
assert!(content.contains("max-retry-interval = 30"));
assert!(content.contains("streaming-bootstrap-retries = 0"));
assert!(content.contains("api-access-order-scheduling = false"));
}

#[test]
Expand Down Expand Up @@ -200,3 +201,18 @@ fn physical_window_size_uses_display_scale_and_ignores_minimized_sizes() {
assert!(logical_window_size_from_physical(&tauri::PhysicalSize::new(0, 0), 1.0).is_none());
assert!(logical_window_size_from_physical(&physical_size, 0.0).is_none());
}

#[test]
fn api_access_order_scheduling_defaults_off_and_round_trips() {
let legacy = toml::from_str::<GuiConfigFile>("port = 8317\n").unwrap();
assert!(!legacy.api_access_order_scheduling);

let config = GuiConfigFile {
api_access_order_scheduling: true,
..GuiConfigFile::default()
};
let content = toml::to_string_pretty(&config).unwrap();
assert!(content.contains("api-access-order-scheduling = true"));
let restored = toml::from_str::<GuiConfigFile>(&content).unwrap();
assert!(restored.api_access_order_scheduling);
}
1 change: 1 addition & 0 deletions src-tauri/src/tests/core_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1120,6 +1120,7 @@ fn startup_preserves_all_user_owned_yaml_and_only_applies_gui_managed_values() {
},
],
api_access_remarks: Vec::new(),
api_access_order_scheduling: false,
management_secret_key: String::new(),
debug: true,
commercial_mode: true,
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/usage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4095,6 +4095,7 @@ mod tests {
auth_dir: String::new(),
api_keys: Vec::new(),
api_access_remarks: Vec::new(),
api_access_order_scheduling: false,
management_secret_key: "123456".to_string(),
debug: false,
commercial_mode: false,
Expand Down
4 changes: 4 additions & 0 deletions src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -897,6 +897,10 @@ export const jaOverrides = {
'apiAccess.notice.deleted': '接続を削除しました',
'apiAccess.notice.enabled': '接続を有効にしました',
'apiAccess.notice.disabled': '接続を無効にしました',
'apiAccess.orderScheduling': 'リスト順序でスケジュール',
'apiAccess.orderSchedulingHint':
'有効にすると、接続をドラッグした際にそのリスト位置が priority フィールドに書き込まれ、コアはこのカテゴリ内で上から下へ厳密にフェイルオーバーします。次回のドラッグで未操作の接続の既存優先度が上書きされます。スイッチをオフにしても書き込み済みの priority はクリアされません。',
'apiAccess.orderSchedulingApplied': '現在のリスト順序から優先度を同期しました',
'apiAccess.title': 'API 接続',
'apiAccess.count': '接続 {count} 件',
'apiAccess.add': '追加',
Expand Down
4 changes: 4 additions & 0 deletions src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -897,6 +897,10 @@ export const en: Record<MessageKey, string> = {
'apiAccess.notice.deleted': 'Connection deleted',
'apiAccess.notice.enabled': 'Connection enabled',
'apiAccess.notice.disabled': 'Connection disabled',
'apiAccess.orderScheduling': 'Schedule by list order',
'apiAccess.orderSchedulingHint':
'When enabled, dragging a connection writes its list position into the priority field, so the core fails over strictly from top to bottom within this category. Existing priorities of untouched connections are overwritten on the next drag. Turning it off does not clear written priorities.',
'apiAccess.orderSchedulingApplied': 'Priorities synced from the current list order',
'apiAccess.title': 'API Access',
'apiAccess.count': '{count} connections',
'apiAccess.add': 'Add',
Expand Down
4 changes: 4 additions & 0 deletions src/i18n/locales/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -896,6 +896,10 @@ export const zhCN = {
'apiAccess.notice.deleted': '接入已删除',
'apiAccess.notice.enabled': '接入已启用',
'apiAccess.notice.disabled': '接入已停用',
'apiAccess.orderScheduling': '按列表顺序调度',
'apiAccess.orderSchedulingHint':
'启用后,拖动接入会将其列表位置写入 priority 字段,核心在该分类内严格按从上到下的顺序故障转移。下次拖动会覆盖未拖动接入的已有优先级。关闭开关不会清除已写入的 priority。',
'apiAccess.orderSchedulingApplied': '已按当前列表顺序同步优先级',
'apiAccess.title': 'API 接入',
'apiAccess.count': '{count} 个接入',
'apiAccess.add': '新增',
Expand Down
88 changes: 86 additions & 2 deletions src/pages/ApiAccessPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -773,6 +773,28 @@ export const reorderProviderRecords = (
return next;
};

// After a reorder, a scoped row's display position equals its record index sorted
// ascending within the scope block, so priorities are derived straight from position.
export const applyOrderSchedulingPriorities = (
records: Record<string, unknown>[],
scopedRows: ProviderRecordIdentity[],
): Record<string, unknown>[] => {
const scopedIndexes = scopedRows
.map((row) => resolveProviderRecordIndex(records, row))
.filter((index, position, indexes) => index >= 0 && indexes.indexOf(index) === position)
.sort((left, right) => left - right);
if (scopedIndexes.length === 0) return records;
const next = records.map(stripResponseFields);
scopedIndexes.forEach((recordIndex, position) => {
const record = { ...next[recordIndex] };
const priority = scopedIndexes.length - 1 - position;
if (priority > 0) record.priority = priority;
else delete record.priority;
next[recordIndex] = record;
});
return next;
};

export const providerRecordWithDisabledState = (
section: ProviderSection,
record: Record<string, unknown>,
Expand Down Expand Up @@ -810,13 +832,26 @@ export function ApiAccessPage() {
const [apiAccessRemarks, setApiAccessRemarks] = useState<Record<string, string>>({});
const [healthDialogRow, setHealthDialogRow] = useState<ProviderRow | null>(null);
const [dragOverId, setDragOverId] = useState<string | null>(null);
const [orderScheduling, setOrderScheduling] = useState(false);
const activeDefinition = definitionFor(activeCategory);
const activeSection = activeDefinition.section;
const dragSensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);

useEffect(() => {
let disposed = false;
void invoke<boolean>('get_api_access_order_scheduling')
.then((enabled) => {
if (!disposed) setOrderScheduling(enabled);
})
.catch(() => {});
return () => {
disposed = true;
};
}, []);

const loadProviders = useCallback(async (showLoading = true) => {
if (showLoading) setLoading(true);
setError('');
Expand Down Expand Up @@ -1127,6 +1162,36 @@ export function ApiAccessPage() {
}
};

const toggleOrderScheduling = async (enabled: boolean) => {
setOrderScheduling(enabled);
try {
await invoke('set_api_access_order_scheduling', { enabled });
} catch (requestError) {
setNotice(requestErrorMessage(requestError), 'error');
return;
}
if (!enabled) return;
setBusy(true);
setNotice('');
try {
const latestConfig = await managementApi.get('/config');
const latestRows = sectionRecordsFromConfig(latestConfig, activeSection);
const categoryRows = latestRows
.map((record, index) => rowFromRecord(activeSection, record, index))
.filter((row) => providerCategoryMatchesRecord(activeCategory, row.record));
if (categoryRows.length > 0) {
const prioritized = applyOrderSchedulingPriorities(latestRows, categoryRows);
await managementApi.put(`/${activeSection}`, prioritized);
}
await loadProviders(false);
setNotice(t('apiAccess.orderSchedulingApplied'));
} catch (requestError) {
setNotice(requestErrorMessage(requestError), 'error');
} finally {
setBusy(false);
}
};

const reorderProviders = async (source: ProviderRow, target: ProviderRow) => {
if (source.section !== target.section || source.index === target.index) return;
setFeedbackRow(providerDragId(source));
Expand All @@ -1138,7 +1203,8 @@ export function ApiAccessPage() {
const latestRows = sectionRecordsFromConfig(latestConfig, source.section);
const nextRows = reorderProviderRecords(latestRows, rows, source, target);
if (!nextRows) throw new Error(t('apiAccess.error.stale'));
await managementApi.put(`/${source.section}`, nextRows);
const scheduledRows = orderScheduling ? applyOrderSchedulingPriorities(nextRows, rows) : nextRows;
await managementApi.put(`/${source.section}`, scheduledRows);
await loadProviders(false);
} catch (requestError) {
await loadProviders(false);
Expand All @@ -1162,7 +1228,12 @@ export function ApiAccessPage() {
target,
);
if (optimisticRows) {
setRecords((current) => ({ ...current, [source.section]: optimisticRows }));
setRecords((current) => ({
...current,
[source.section]: orderScheduling
? applyOrderSchedulingPriorities(optimisticRows, rows)
: optimisticRows,
}));
}
void reorderProviders(source, target);
};
Expand All @@ -1181,6 +1252,19 @@ export function ApiAccessPage() {
<h1>{t('apiAccess.title')}</h1>
</div>
<div className="management-heading-actions">
<label className="switch-control api-access-order-scheduling" title={t('apiAccess.orderSchedulingHint')}>
<input
type="checkbox"
checked={orderScheduling}
onChange={(event) => void toggleOrderScheduling(event.currentTarget.checked)}
disabled={busy || loading}
aria-label={t('apiAccess.orderScheduling')}
/>
<span className="switch-track" />
</label>
<span className="muted-summary" title={t('apiAccess.orderSchedulingHint')}>
{t('apiAccess.orderScheduling')}
</span>
<span className="muted-summary">{t('apiAccess.count', { count: totalCount })}</span>
<button type="button" className="secondary-button compact-button" onClick={() => void loadProviders()} disabled={loading || busy}>
<RefreshCw size={16} aria-hidden="true" />
Expand Down