Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
9e4cb1b
fix(web-ui): validate provider modal input
awsl233777 Apr 8, 2026
8bb9a2a
test(web-ui): relax parity baseline drift assertions
awsl233777 Apr 8, 2026
563f0ee
fix(web-ui): harden usage tab rendering
awsl233777 Apr 8, 2026
1f28abe
fix(web-ui): use production vue runtime
awsl233777 Apr 8, 2026
47b291f
docs(readme): update reset command examples
awsl233777 Apr 8, 2026
915b764
fix(usage): skip session detail loading for charts
awsl233777 Apr 8, 2026
f16a61d
fix(provider): persist normalized urls on add
awsl233777 Apr 8, 2026
657e95c
fix(reset): silence git repo probe output
awsl233777 Apr 8, 2026
6b441c0
fix(usage): decouple charts from heavy session loads
awsl233777 Apr 8, 2026
48ac808
fix(openclaw): sync default config with live file
awsl233777 Apr 8, 2026
1a1ff13
fix(openclaw): read provider fields from legacy shapes
awsl233777 Apr 8, 2026
bb27952
fix(openclaw): fallback to sole provider config
awsl233777 Apr 8, 2026
a06f44b
fix(openclaw): show structured provider refs in quick form
awsl233777 Apr 8, 2026
9096a81
refactor(openclaw): remove add-config button from tab
awsl233777 Apr 8, 2026
07be648
fix(openclaw): align config reading with official secret fields
awsl233777 Apr 8, 2026
a1fd9a8
fix(openclaw): normalize provider lookup in quick form
awsl233777 Apr 8, 2026
62e1b24
fix(openclaw): surface auth profile fallback in quick form
awsl233777 Apr 8, 2026
50381f8
fix(openclaw): make external auth values visible and editable
awsl233777 Apr 8, 2026
265bfe4
perf(sessions): defer detail hydration on tab switch
awsl233777 Apr 8, 2026
2e82f22
perf(sessions): batch session list rendering on demand
awsl233777 Apr 8, 2026
7621627
fix(cli): remove duplicate isPlainObject helper
awsl233777 Apr 9, 2026
9eab383
fix(web-ui): restore session browser runtime
ymkiux Apr 9, 2026
4dff290
fix(web-ui): align session browser implementation
ymkiux Apr 9, 2026
b9e6da2
test(web-ui): sync parity drift expectations
ymkiux Apr 9, 2026
de4ac6a
fix(openclaw): redact secrets and harden session usage
ymkiux Apr 9, 2026
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
4 changes: 2 additions & 2 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,11 +151,11 @@ npm start run --no-browser

```bash
npm run reset
npm run reset -- 79
npm run reset 79
```

- `npm run reset`: prompt for a PR number; leave it blank to return to default `origin/main`
- `npm run reset -- 79`: sync directly to the latest head snapshot of PR `#79`
- `npm run reset 79`: sync directly to the latest head snapshot of PR `#79`
- The script also handles local branch switching, workspace cleanup, untracked file cleanup, and final state validation

## Command Reference
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,11 +145,11 @@ npm start run --no-browser

```bash
npm run reset
npm run reset -- 79
npm run reset 79
```

- `npm run reset`:交互输入 PR 编号;留空则回到默认 `origin/main`
- `npm run reset -- 79`:直接同步到 PR `#79` 的最新 head 快照
- `npm run reset 79`:直接同步到 PR `#79` 的最新 head 快照
- 脚本会自动完成本地分支切换、工作区清理、未跟踪文件清理与最终状态校验

## 命令速查
Expand Down
60 changes: 56 additions & 4 deletions cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -3449,12 +3449,16 @@ function addProviderToConfig(params = {}) {
const url = typeof params.url === 'string' ? params.url.trim() : '';
const key = typeof params.key === 'string' ? params.key.trim() : '';
const allowManaged = !!params.allowManaged;
const normalizedUrl = normalizeBaseUrl(url);

if (!name) return { error: '名称不能为空' };
if (!url) return { error: 'URL 不能为空' };
if (!isValidProviderName(name)) {
return { error: '名称仅支持字母/数字/._-' };
}
if (!isValidHttpUrl(normalizedUrl)) {
return { error: 'URL 仅支持 http/https' };
}
if (isReservedProviderNameForCreation(name)) {
return { error: 'local provider 为系统保留名称,不可新增' };
}
Expand Down Expand Up @@ -3496,7 +3500,7 @@ function addProviderToConfig(params = {}) {

const lineEnding = content.includes('\r\n') ? '\r\n' : '\n';
const safeName = escapeTomlBasicString(name);
const safeUrl = escapeTomlBasicString(url);
const safeUrl = escapeTomlBasicString(normalizedUrl);
const safeKey = escapeTomlBasicString(key);
const block = [
buildModelProviderTableHeader(name),
Expand Down Expand Up @@ -3533,6 +3537,9 @@ function updateProviderInConfig(params = {}) {
if (!url && key === undefined) {
return { error: 'URL 或密钥至少填写一项' };
}
if (url && !isValidHttpUrl(normalizeBaseUrl(url))) {
return { error: 'URL 仅支持 http/https' };
}
if (isNonEditableProvider(name) && !allowManaged) {
if (isDefaultLocalProvider(name)) {
return { error: 'local provider 为系统保留项,不可编辑' };
Expand Down Expand Up @@ -5436,6 +5443,29 @@ async function listAllSessionsData(params = {}) {
return result;
}

async function listSessionUsage(params = {}) {
const source = params.source === 'codex' || params.source === 'claude'
? params.source
: 'all';
const rawLimit = Number(params.limit);
const limit = Number.isFinite(rawLimit)
? Math.max(1, Math.min(rawLimit, MAX_SESSION_LIST_SIZE))
: 200;
const sessions = await listAllSessions({
source,
limit,
forceRefresh: !!params.forceRefresh
});
return sessions.map((item) => {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
return item;
}
const normalized = { ...item };
delete normalized.__messageCountExact;
return normalized;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
});
}

function listSessionPaths(params = {}) {
const source = typeof params.source === 'string' ? params.source.trim().toLowerCase() : '';
if (source && source !== 'codex' && source !== 'claude' && source !== 'all') {
Expand Down Expand Up @@ -8228,7 +8258,7 @@ function cmdUseModel(modelName, silent = false) {
// 添加提供商
function cmdAdd(name, baseUrl, apiKey, silent = false) {
const providerName = typeof name === 'string' ? name.trim() : '';
const providerBaseUrl = typeof baseUrl === 'string' ? baseUrl.trim() : '';
const providerBaseUrl = normalizeBaseUrl(baseUrl);

if (!providerName || !providerBaseUrl) {
if (!silent) {
Expand All @@ -8250,6 +8280,10 @@ function cmdAdd(name, baseUrl, apiKey, silent = false) {
if (!silent) console.error('错误: codexmate-proxy 为保留名称,不可手动添加');
throw new Error('codexmate-proxy 为保留名称,不可手动添加');
}
if (!isValidHttpUrl(providerBaseUrl)) {
if (!silent) console.error('错误: URL 仅支持 http/https');
throw new Error('URL 仅支持 http/https');
}

const config = readConfig();
if (config.model_providers && config.model_providers[providerName]) {
Expand Down Expand Up @@ -8307,6 +8341,7 @@ function cmdDelete(name, silent = false) {
// 更新提供商
function cmdUpdate(name, baseUrl, apiKey, silent = false, options = {}) {
const allowManaged = !!(options && options.allowManaged);
const normalizedBaseUrl = baseUrl === undefined ? undefined : normalizeBaseUrl(baseUrl);
if (!name) {
if (!silent) console.error('错误: 提供商名称必填');
throw new Error('提供商名称必填');
Expand Down Expand Up @@ -8335,6 +8370,10 @@ function cmdUpdate(name, baseUrl, apiKey, silent = false, options = {}) {
if (!silent) console.error('错误: 无法找到提供商配置块');
throw new Error('无法找到提供商配置块');
}
if (normalizedBaseUrl !== undefined && !isValidHttpUrl(normalizedBaseUrl)) {
if (!silent) console.error('错误: URL 仅支持 http/https');
throw new Error('URL 仅支持 http/https');
}

const replaceTomlStringField = (block, fieldName, rawValue) => {
const safeValue = escapeTomlBasicString(rawValue);
Expand Down Expand Up @@ -8460,8 +8499,8 @@ function cmdUpdate(name, baseUrl, apiKey, silent = false, options = {}) {
for (const range of sorted) {
const providerBlock = newContent.slice(range.start, range.end);
let updatedBlock = providerBlock;
if (baseUrl) {
updatedBlock = replaceTomlStringField(updatedBlock, 'base_url', baseUrl);
if (normalizedBaseUrl) {
updatedBlock = replaceTomlStringField(updatedBlock, 'base_url', normalizedBaseUrl);
}
if (apiKey !== undefined) {
updatedBlock = replaceTomlStringField(updatedBlock, 'preferred_auth_method', apiKey);
Expand Down Expand Up @@ -10690,6 +10729,19 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
}
}
break;
case 'list-sessions-usage':
{
const source = typeof params.source === 'string' ? params.source.trim().toLowerCase() : '';
if (source && source !== 'codex' && source !== 'claude' && source !== 'all') {
result = { error: 'Invalid source. Must be codex, claude, or all' };
} else {
result = {
sessions: await listSessionUsage(params || {}),
source: source || 'all'
};
}
}
break;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
case 'list-session-paths':
{
const source = typeof params.source === 'string' ? params.source.trim().toLowerCase() : '';
Expand Down
2 changes: 1 addition & 1 deletion cmd/reset-main.js
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ function promptForPrNumber({ stdin = process.stdin, stdout = process.stdout } =

async function main({ argv = process.argv.slice(2), stdin = process.stdin, stdout = process.stdout } = {}) {
try {
run('git rev-parse --is-inside-work-tree');
run('git rev-parse --is-inside-work-tree', { stdio: 'ignore' });
} catch (err) {
console.error('Not inside a git repository.');
process.exit(1);
Expand Down
13 changes: 13 additions & 0 deletions res/vue.global.prod.js

Large diffs are not rendered by default.

19 changes: 17 additions & 2 deletions tests/e2e/test-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -291,11 +291,16 @@ preferred_auth_method = "shadow-key"
assert(apiPathsInvalid.error, 'list-session-paths should fail for invalid source');

// ========== Add Provider Tests ==========
const addProvider = await api('add-provider', { name: 'e2e-api', url: mockProviderUrl, key: 'sk-e2e-api' });
const addProviderInputUrl = `${mockProviderUrl}/`;
const addProvider = await api('add-provider', { name: 'e2e-api', url: addProviderInputUrl, key: 'sk-e2e-api' });
assert(addProvider.success === true, 'add-provider failed');

const apiListAfterAdd = await api('list');
assert(Array.isArray(apiListAfterAdd.providers) && apiListAfterAdd.providers.some(p => p.name === 'e2e-api'), 'add-provider not reflected in list');
const addedProvider = Array.isArray(apiListAfterAdd.providers)
? apiListAfterAdd.providers.find((p) => p && p.name === 'e2e-api')
: null;
assert(addedProvider, 'add-provider not reflected in list');
assert(addedProvider.url === mockProviderUrl, 'add-provider should persist normalized provider url');

const addProviderEmptyName = await api('add-provider', { name: '', url: mockProviderUrl });
assert(addProviderEmptyName.error, 'add-provider should reject empty name');
Expand All @@ -311,11 +316,19 @@ preferred_auth_method = "shadow-key"

const addProviderInvalidName = await api('add-provider', { name: 'bad name', url: mockProviderUrl });
assert(addProviderInvalidName.error, 'add-provider should reject invalid provider name');
const addProviderInvalidUrl = await api('add-provider', { name: 'bad-url', url: 'not-a-url' });
assert(addProviderInvalidUrl.error, 'add-provider should reject invalid provider url');
const cliAddInvalidUrl = runSync(node, [cliPath, 'add', 'cli-bad-url', 'not-a-url'], { env });
assert(cliAddInvalidUrl.status !== 0, 'cli add should reject invalid provider url');
const apiListAfterInvalidName = await api('list');
assert(
!apiListAfterInvalidName.providers.some((item) => item && item.name === 'bad name'),
'add-provider invalid name should not pollute provider list'
);
assert(
!apiListAfterInvalidName.providers.some((item) => item && item.name === 'bad-url'),
'add-provider invalid url should not pollute provider list'
);
const apiStatusAfterInvalidName = await api('status');
assert(apiStatusAfterInvalidName.provider, 'status should remain readable after invalid add-provider');

Expand Down Expand Up @@ -1356,6 +1369,8 @@ preferred_auth_method = "shadow-key"

// ========== Update Provider Tests ==========
const updatedUrl = `${mockProviderUrl}/v2`;
const updateProviderInvalidUrl = await api('update-provider', { name: 'e2e-api', url: 'ftp://bad.example.com' });
assert(updateProviderInvalidUrl.error, 'update-provider should reject invalid provider url');
const updateProvider = await api('update-provider', { name: 'e2e-api', url: updatedUrl, key: 'sk-e2e-api-upd' });
assert(updateProvider.success === true, 'update-provider failed');

Expand Down
10 changes: 10 additions & 0 deletions tests/e2e/test-sessions.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,16 @@ module.exports = async function testSessions(ctx) {
const apiSessionsInvalid = await api('list-sessions', { source: 'invalid', limit: 50 });
assert(apiSessionsInvalid.error, 'list-sessions should fail for invalid source');

// ========== Usage Session Summary Tests ==========
const usageSessions = await api('list-sessions-usage', { source: 'all', limit: 50, forceRefresh: true });
assert(Array.isArray(usageSessions.sessions), 'list-sessions-usage missing sessions');
assert(usageSessions.sessions.some((item) => item.sessionId === sessionId), 'list-sessions-usage missing codex entry');
assert(usageSessions.sessions.some((item) => item.sessionId === claudeSessionId), 'list-sessions-usage missing claude entry');
assert(usageSessions.sessions.every((item) => !Object.prototype.hasOwnProperty.call(item, '__messageCountExact')), 'list-sessions-usage should not expose exact hydration markers');

const usageSessionsInvalid = await api('list-sessions-usage', { source: 'invalid', limit: 50 });
assert(usageSessionsInvalid.error, 'list-sessions-usage should fail for invalid source');

// ========== List Sessions Tests - Query ==========
const claudeCodeQuery = await api('list-sessions', { source: 'claude', query: 'claude code', limit: 50, forceRefresh: true });
assert(Array.isArray(claudeCodeQuery.sessions), 'claude code query missing sessions');
Expand Down
16 changes: 13 additions & 3 deletions tests/unit/config-tabs-ui.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ test('config template keeps expected config tabs in top and side navigation', ()
assert.match(html, /isProviderConfigMode/);
assert.match(html, /provider-fast-switch-select/);
assert.match(html, /forceCompactLayout/);
assert.match(html, /<script src="\/res\/vue\.global\.prod\.js"><\/script>/);
assert.match(html, /quickSwitchProvider\(\$event\.target\.value\)/);
assert.match(html, /onMainTabPointerDown\('sessions', \$event\)/);
assert.match(html, /onConfigTabPointerDown\('codex', \$event\)/);
Expand Down Expand Up @@ -205,8 +206,13 @@ test('config template keeps expected config tabs in top and side navigation', ()
assert.doesNotMatch(sessionsPanel, /sessionUsageSummaryCards/);
assert.match(usagePanel, /sessionsUsageTimeRange === '7d'/);
assert.match(usagePanel, /sessionsUsageTimeRange === '30d'/);
assert.match(usagePanel, /sessionsUsageList\.length/);
assert.match(usagePanel, /loadSessionsUsage\(\{ forceRefresh: true \}\)/);
assert.match(usagePanel, /sessionUsageSummaryCards/);
assert.match(usagePanel, /sessionUsageCharts\.buckets/);
assert.doesNotMatch(usagePanel, /sessionUsageCharts\.topPaths\[0\]\?\.count/);
assert.doesNotMatch(html, /sessionUsageSummaryCards\[0\]\?\.value/);
assert.doesNotMatch(html, /sessionUsageSummaryCards\[1\]\?\.value/);
assert.match(html, /class="pin-icon"/);
assert.match(html, /:aria-selected="mainTab === 'sessions'"/);
assert.match(html, /:aria-selected="mainTab === 'usage'"/);
Expand All @@ -229,7 +235,8 @@ test('config template keeps expected config tabs in top and side navigation', ()
assert.match(html, /<button class="card-action-btn delete"[^>]*@click="deleteClaudeConfig\(name\)"[^>]*:aria-label="`Delete Claude config \$\{name\}`"[^>]*title="删除">/);
assert.match(html, /<button class="card-action-btn"[^>]*@click="copyClaudeShareCommand\(name\)"[^>]*disabled[^>]*>/);
assert.match(html, /<button class="card-action-btn"[^>]*@click="openOpenclawEditModal\(name\)"[^>]*:aria-label="`Edit OpenClaw config \$\{name\}`"[^>]*title="编辑">/);
assert.match(html, /<button class="card-action-btn delete"[^>]*@click="deleteOpenclawConfig\(name\)"[^>]*:aria-label="`Delete OpenClaw config \$\{name\}`"[^>]*title="删除">/);
assert.match(html, /<div class="docs-command-row">[\s\S]*<code class="install-command">\{\{ target\.command \}\}<\/code>[\s\S]*<button type="button" class="btn-mini docs-copy-btn"/);
assert.match(html, /<button v-if="name !== '默认配置'" class="card-action-btn delete"[^>]*@click="deleteOpenclawConfig\(name\)"[^>]*:aria-label="`Delete OpenClaw config \$\{name\}`"[^>]*title="删除">/);
assert.match(modalsBasic, /<div v-if="showAddModal" class="modal-overlay" @click\.self="closeAddModal">/);
assert.match(modalsBasic, /<div v-if="showModelModal" class="modal-overlay" @click\.self="closeModelModal">/);
assert.match(modalsBasic, /<div v-if="showClaudeConfigModal" class="modal-overlay" @click\.self="closeClaudeConfigModal">/);
Expand Down Expand Up @@ -268,7 +275,7 @@ test('config template keeps expected config tabs in top and side navigation', ()
assert.match(openclawModal, /<div class="modal-title" id="openclaw-config-modal-title">{{ openclawEditorTitle }}<\/div>/);
assert.match(openclawModal, /:readonly="openclawSaving \|\| openclawApplying"/);
assert.match(openclawModal, /<button class="btn btn-cancel" @click="closeOpenclawConfigModal" :disabled="openclawSaving \|\| openclawApplying">取消<\/button>/);
assert.match(openclawModal, /<button class="btn btn-confirm" @click="saveOpenclawConfig" :disabled="openclawSaving \|\| openclawApplying">/);
assert.match(openclawModal, /<button class="btn btn-confirm" @click="saveOpenclawConfig" :disabled="openclawSaving \|\| openclawApplying \|\| \(openclawEditing\.lockName && openclawEditing\.name === '默认配置'\)">/);
assert.match(openclawModal, /<button class="btn btn-confirm secondary" @click="saveAndApplyOpenclawConfig" :disabled="openclawSaving \|\| openclawApplying">/);
assert.doesNotMatch(baseTheme, /fonts\.googleapis\.com/);
assert.match(controlsForms, /\.btn-tool-compact:disabled:hover,\s*\.btn-tool-compact\[disabled\]:hover/);
Expand All @@ -288,6 +295,7 @@ test('web ui script defines provider mode metadata for codex only', () => {
assert.match(appScript, /this\.switchMainTab\('config'\);/);
assert.match(appScript, /if \(this\.mainTab === 'config'\) {/);
assert.match(appScript, /this\.clearMainTabSwitchIntent\('config'\);/);
assert.match(appScript, /__mainTabSwitchState:\s*\{[\s\S]*intent:\s*''[\s\S]*pendingTarget:\s*''[\s\S]*pendingConfigMode:\s*''[\s\S]*ticket:\s*0[\s\S]*\}/);
assert.match(appScript, /setMainTabSwitchIntent\(tab\)/);
assert.match(appScript, /ensureMainTabSwitchState\(\)/);
assert.match(appScript, /ensureImmediateNavDomState\(\)/);
Expand All @@ -309,7 +317,9 @@ test('web ui script defines provider mode metadata for codex only', () => {
assert.match(appScript, /isMainTabNavActive\(tab\)/);
assert.match(appScript, /isConfigModeNavActive\(mode\)/);
assert.match(appScript, /const isLeavingSessions = previousTab === 'sessions' && targetTab !== 'sessions';/);
assert.match(appScript, /const enteringSessionDataTab = nextTab === 'sessions' \|\| nextTab === 'usage';/);
assert.match(appScript, /const enteringSessionsTab = nextTab === 'sessions';/);
assert.match(appScript, /const enteringUsageTab = nextTab === 'usage';/);
assert.match(appScript, /this\.loadSessionsUsage\(\);/);
assert.match(appScript, /if \(targetTab === previousTab\) {/);
assert.match(appScript, /const shouldDeferApply = isLeavingSessions;/);
assert.match(appScript, /if \(isLeavingSessions && !this\.isSessionPanelFastHidden\(\)\) {/);
Expand Down
Loading
Loading