mirror of
https://github.com/ValueCell-ai/ClawX.git
synced 2026-08-14 08:53:09 +00:00
Compare commits
5
Commits
v0.5.4
...
v0.3.9-beta.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b71713917 | ||
|
|
09aa7c2e52 | ||
|
|
5b8a35391d | ||
|
|
cf43237c67 | ||
|
|
9a84da4f8e |
@@ -25,7 +25,6 @@ import {
|
||||
} from '../../utils/agent-config';
|
||||
import {
|
||||
ensureDingTalkPluginInstalled,
|
||||
ensureFeishuPluginInstalled,
|
||||
ensureWeChatPluginInstalled,
|
||||
ensureWeComPluginInstalled,
|
||||
} from '../../utils/plugin-install';
|
||||
@@ -1349,14 +1348,7 @@ export async function handleChannelRoutes(
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// QQBot is a built-in channel since OpenClaw 3.31 — no plugin install needed
|
||||
if (storedChannelType === 'feishu') {
|
||||
const installResult = await ensureFeishuPluginInstalled();
|
||||
if (!installResult.installed) {
|
||||
sendJson(res, 500, { success: false, error: installResult.warning || 'Feishu plugin install failed' });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Feishu is a built-in extension since OpenClaw 2026.4.11 — no plugin install needed
|
||||
if (storedChannelType === OPENCLAW_WECHAT_CHANNEL_TYPE) {
|
||||
const installResult = await ensureWeChatPluginInstalled();
|
||||
if (!installResult.installed) {
|
||||
|
||||
@@ -48,7 +48,6 @@ export interface GatewayLaunchContext {
|
||||
const CHANNEL_PLUGIN_MAP: Record<string, { dirName: string; npmName: string }> = {
|
||||
dingtalk: { dirName: 'dingtalk', npmName: '@soimy/dingtalk' },
|
||||
wecom: { dirName: 'wecom', npmName: '@wecom/wecom-openclaw-plugin' },
|
||||
feishu: { dirName: 'feishu-openclaw-plugin', npmName: '@larksuite/openclaw-lark' },
|
||||
|
||||
'openclaw-weixin': { dirName: 'openclaw-weixin', npmName: '@tencent-weixin/openclaw-weixin' },
|
||||
};
|
||||
@@ -59,7 +58,7 @@ const CHANNEL_PLUGIN_MAP: Record<string, { dirName: string; npmName: string }> =
|
||||
* ~/.openclaw/extensions/, the broken copy overrides the working built-in
|
||||
* plugin and must be removed.
|
||||
*/
|
||||
const BUILTIN_CHANNEL_EXTENSIONS = ['discord', 'telegram', 'qqbot'];
|
||||
const BUILTIN_CHANNEL_EXTENSIONS = ['discord', 'telegram', 'qqbot', 'feishu-openclaw-plugin'];
|
||||
|
||||
function cleanupStaleBuiltInExtensions(): void {
|
||||
for (const ext of BUILTIN_CHANNEL_EXTENSIONS) {
|
||||
@@ -274,11 +273,6 @@ export async function syncGatewayConfigBeforeLaunch(
|
||||
for (const [channelType, info] of Object.entries(CHANNEL_PLUGIN_MAP)) {
|
||||
pluginIdToChannel[info.dirName] = channelType;
|
||||
}
|
||||
// Known manifest IDs that differ from their dirName/channelType
|
||||
|
||||
pluginIdToChannel['openclaw-lark'] = 'feishu';
|
||||
pluginIdToChannel['feishu-openclaw-plugin'] = 'feishu';
|
||||
|
||||
for (const pluginId of allowList) {
|
||||
const channelType = pluginIdToChannel[pluginId] ?? pluginId;
|
||||
if (CHANNEL_PLUGIN_MAP[channelType] && !configuredChannels.includes(channelType)) {
|
||||
|
||||
@@ -36,7 +36,6 @@ import { toOpenClawChannelType, toUiChannelType } from '../utils/channel-alias';
|
||||
import { checkUvInstalled, installUv, setupManagedPython } from '../utils/uv-setup';
|
||||
import {
|
||||
ensureDingTalkPluginInstalled,
|
||||
ensureFeishuPluginInstalled,
|
||||
ensureWeComPluginInstalled,
|
||||
} from '../utils/plugin-install';
|
||||
import { updateSkillConfig, getSkillConfig, getAllSkillConfigs } from '../utils/skill-config';
|
||||
@@ -1497,23 +1496,7 @@ function registerOpenClawHandlers(gatewayManager: GatewayManager): void {
|
||||
warning: installResult.warning,
|
||||
};
|
||||
}
|
||||
// QQBot is a built-in channel since OpenClaw 3.31 — no plugin install needed
|
||||
if (channelType === 'feishu') {
|
||||
const installResult = await ensureFeishuPluginInstalled();
|
||||
if (!installResult.installed) {
|
||||
return {
|
||||
success: false,
|
||||
error: installResult.warning || 'Feishu plugin install failed',
|
||||
};
|
||||
}
|
||||
await saveChannelConfig(channelType, config);
|
||||
scheduleGatewayChannelSaveRefresh(channelType, `channel:saveConfig (${channelType})`);
|
||||
return {
|
||||
success: true,
|
||||
pluginInstalled: installResult.installed,
|
||||
warning: installResult.warning,
|
||||
};
|
||||
}
|
||||
// Feishu is a built-in extension since OpenClaw 2026.4.11 — no plugin install needed
|
||||
await saveChannelConfig(channelType, config);
|
||||
scheduleGatewayChannelSaveRefresh(channelType, `channel:saveConfig (${channelType})`);
|
||||
return { success: true };
|
||||
|
||||
@@ -24,7 +24,7 @@ const CONFIG_FILE = join(OPENCLAW_DIR, 'openclaw.json');
|
||||
const WECOM_PLUGIN_ID = 'wecom';
|
||||
// Note: QQBot is a built-in channel since OpenClaw 3.31 — no plugin ID needed.
|
||||
const WECHAT_PLUGIN_ID = OPENCLAW_WECHAT_CHANNEL_TYPE;
|
||||
const FEISHU_PLUGIN_ID_CANDIDATES = ['openclaw-lark', 'feishu-openclaw-plugin'] as const;
|
||||
const FEISHU_PLUGIN_ID_CANDIDATES = ['feishu', 'openclaw-lark', 'feishu-openclaw-plugin'] as const;
|
||||
const DEFAULT_ACCOUNT_ID = 'default';
|
||||
// Channels whose plugin schema uses additionalProperties:false, meaning
|
||||
// credential keys MUST NOT appear at the top level of `channels.<type>`.
|
||||
|
||||
@@ -208,7 +208,7 @@ async function discoverAgentIds(): Promise<string[]> {
|
||||
// ── OpenClaw Config Helpers ──────────────────────────────────────
|
||||
|
||||
const OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json');
|
||||
const FEISHU_PLUGIN_ID_CANDIDATES = ['openclaw-lark', 'feishu-openclaw-plugin'] as const;
|
||||
const FEISHU_PLUGIN_ID_CANDIDATES = ['feishu', 'openclaw-lark', 'feishu-openclaw-plugin'] as const;
|
||||
const VALID_COMPACTION_MODES = new Set(['default', 'safeguard']);
|
||||
const BUILTIN_CHANNEL_IDS = new Set([
|
||||
'discord',
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Shared OpenClaw Plugin Install Utilities
|
||||
*
|
||||
* Provides version-aware install/upgrade logic for bundled OpenClaw plugins
|
||||
* (DingTalk, WeCom, Feishu, WeChat). Used both at app startup (to auto-upgrade
|
||||
* (DingTalk, WeCom, WeChat). Used both at app startup (to auto-upgrade
|
||||
* stale plugins) and when a user configures a channel.
|
||||
*
|
||||
* Note: QQBot was moved to a built-in channel in OpenClaw 3.31 and is no longer
|
||||
@@ -233,7 +233,6 @@ function patchPluginEntryIds(targetDir: string): void {
|
||||
const PLUGIN_NPM_NAMES: Record<string, string> = {
|
||||
dingtalk: '@soimy/dingtalk',
|
||||
wecom: '@wecom/wecom-openclaw-plugin',
|
||||
'feishu-openclaw-plugin': '@larksuite/openclaw-lark',
|
||||
|
||||
'openclaw-weixin': '@tencent-weixin/openclaw-weixin',
|
||||
};
|
||||
@@ -503,15 +502,6 @@ export function ensureWeComPluginInstalled(): { installed: boolean; warning?: st
|
||||
return ensurePluginInstalled('wecom', buildCandidateSources('wecom'), 'WeCom');
|
||||
}
|
||||
|
||||
export function ensureFeishuPluginInstalled(): { installed: boolean; warning?: string } {
|
||||
return ensurePluginInstalled(
|
||||
'feishu-openclaw-plugin',
|
||||
buildCandidateSources('feishu-openclaw-plugin'),
|
||||
'Feishu',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function ensureWeChatPluginInstalled(): { installed: boolean; warning?: string } {
|
||||
return ensurePluginInstalled('openclaw-weixin', buildCandidateSources('openclaw-weixin'), 'WeChat');
|
||||
@@ -525,8 +515,6 @@ export function ensureWeChatPluginInstalled(): { installed: boolean; warning?: s
|
||||
const ALL_BUNDLED_PLUGINS = [
|
||||
{ fn: ensureDingTalkPluginInstalled, label: 'DingTalk' },
|
||||
{ fn: ensureWeComPluginInstalled, label: 'WeCom' },
|
||||
|
||||
{ fn: ensureFeishuPluginInstalled, label: 'Feishu' },
|
||||
{ fn: ensureWeChatPluginInstalled, label: 'WeChat' },
|
||||
] as const;
|
||||
|
||||
|
||||
+4
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "clawx",
|
||||
"version": "0.3.8",
|
||||
"version": "0.3.9-beta.0",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"@discordjs/opus",
|
||||
@@ -79,7 +79,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@larksuite/openclaw-lark": "2026.4.7",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
@@ -94,7 +93,7 @@
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@soimy/dingtalk": "^3.5.3",
|
||||
"@tencent-weixin/openclaw-weixin": "^2.1.7",
|
||||
"@tencent-weixin/openclaw-weixin": "^2.1.8",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/node": "^25.3.0",
|
||||
@@ -119,7 +118,7 @@
|
||||
"i18next": "^25.8.11",
|
||||
"jsdom": "^28.1.0",
|
||||
"lucide-react": "^0.563.0",
|
||||
"openclaw": "2026.4.9",
|
||||
"openclaw": "2026.4.11",
|
||||
"png2icons": "^2.0.1",
|
||||
"postcss": "^8.5.6",
|
||||
"react": "^19.2.4",
|
||||
@@ -143,4 +142,4 @@
|
||||
"zx": "^8.8.5"
|
||||
},
|
||||
"packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+450
-721
File diff suppressed because it is too large
Load Diff
@@ -575,7 +575,6 @@ exports.default = async function afterPack(context) {
|
||||
const BUNDLED_PLUGINS = [
|
||||
{ npmName: '@soimy/dingtalk', pluginId: 'dingtalk' },
|
||||
{ npmName: '@wecom/wecom-openclaw-plugin', pluginId: 'wecom' },
|
||||
{ npmName: '@larksuite/openclaw-lark', pluginId: 'feishu-openclaw-plugin' },
|
||||
{ npmName: '@tencent-weixin/openclaw-weixin', pluginId: 'openclaw-weixin' },
|
||||
];
|
||||
|
||||
|
||||
@@ -5,10 +5,13 @@
|
||||
*
|
||||
* Build a self-contained mirror of OpenClaw third-party plugins for packaging.
|
||||
* Current plugins:
|
||||
* - @soimy/dingtalk -> build/openclaw-plugins/dingtalk
|
||||
* - @soimy/dingtalk -> build/openclaw-plugins/dingtalk
|
||||
* - @wecom/wecom-openclaw-plugin -> build/openclaw-plugins/wecom
|
||||
* - @tencent-weixin/openclaw-weixin -> build/openclaw-plugins/openclaw-weixin
|
||||
*
|
||||
* Note: Feishu (@larksuite/openclaw-lark) is now a built-in extension in
|
||||
* OpenClaw 2026.4.11+ and no longer needs to be bundled separately.
|
||||
*
|
||||
* The output plugin directory contains:
|
||||
* - plugin source files (index.ts, openclaw.plugin.json, package.json, ...)
|
||||
* - plugin runtime node_modules/ (flattened direct + transitive deps)
|
||||
@@ -38,7 +41,6 @@ function normWin(p) {
|
||||
const PLUGINS = [
|
||||
{ npmName: '@soimy/dingtalk', pluginId: 'dingtalk' },
|
||||
{ npmName: '@wecom/wecom-openclaw-plugin', pluginId: 'wecom' },
|
||||
{ npmName: '@larksuite/openclaw-lark', pluginId: 'feishu-openclaw-plugin' },
|
||||
{ npmName: '@tencent-weixin/openclaw-weixin', pluginId: 'openclaw-weixin' },
|
||||
];
|
||||
|
||||
|
||||
@@ -470,7 +470,7 @@ function cleanupBundle(outputDir) {
|
||||
'node_modules/koffi/src',
|
||||
'node_modules/koffi/vendor',
|
||||
'node_modules/koffi/doc',
|
||||
'extensions/feishu', // Removed in favor of official @larksuite/openclaw-lark plugin
|
||||
// Note: extensions/feishu is the built-in Feishu plugin since OpenClaw 2026.4.11
|
||||
];
|
||||
for (const rel of LARGE_REMOVALS) {
|
||||
if (rmSafe(path.join(outputDir, rel))) removedCount++;
|
||||
|
||||
+54
-31
@@ -109,15 +109,26 @@ function buildChatEventDedupeKey(eventState: string, event: Record<string, unkno
|
||||
return null;
|
||||
}
|
||||
|
||||
function getFinalMessageIdDedupeKey(eventState: string, event: Record<string, unknown>): string | null {
|
||||
if (eventState !== 'final') return null;
|
||||
const msg = (event.message && typeof event.message === 'object')
|
||||
? event.message as Record<string, unknown>
|
||||
: null;
|
||||
if (msg?.id != null) return `final-msgid|${String(msg.id)}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
function isDuplicateChatEvent(eventState: string, event: Record<string, unknown>): boolean {
|
||||
const key = buildChatEventDedupeKey(eventState, event);
|
||||
if (!key) return false;
|
||||
const msgKey = getFinalMessageIdDedupeKey(eventState, event);
|
||||
if (!key && !msgKey) return false;
|
||||
const now = Date.now();
|
||||
pruneChatEventDedupe(now);
|
||||
if (_chatEventDedupe.has(key)) {
|
||||
if ((key && _chatEventDedupe.has(key)) || (msgKey && _chatEventDedupe.has(msgKey))) {
|
||||
return true;
|
||||
}
|
||||
_chatEventDedupe.set(key, now);
|
||||
if (key) _chatEventDedupe.set(key, now);
|
||||
if (msgKey) _chatEventDedupe.set(msgKey, now);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1118,38 +1129,50 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
}
|
||||
|
||||
// Background: fetch first user message for every non-main session to populate labels upfront.
|
||||
// Uses a small limit so it's cheap; runs in parallel and doesn't block anything.
|
||||
// Retries on "gateway startup" errors since the gateway may still be initializing.
|
||||
const sessionsToLabel = sessionsWithCurrent.filter((s) => !s.key.endsWith(':main'));
|
||||
if (sessionsToLabel.length > 0) {
|
||||
void Promise.all(
|
||||
sessionsToLabel.map(async (session) => {
|
||||
try {
|
||||
const r = await useGatewayStore.getState().rpc<Record<string, unknown>>(
|
||||
'chat.history',
|
||||
{ sessionKey: session.key, limit: 1000 },
|
||||
);
|
||||
const msgs = Array.isArray(r.messages) ? r.messages as RawMessage[] : [];
|
||||
const firstUser = msgs.find((m) => m.role === 'user');
|
||||
const lastMsg = msgs[msgs.length - 1];
|
||||
set((s) => {
|
||||
const next: Partial<typeof s> = {};
|
||||
if (firstUser) {
|
||||
const labelText = getMessageText(firstUser.content).trim();
|
||||
if (labelText) {
|
||||
const truncated = labelText.length > 50 ? `${labelText.slice(0, 50)}…` : labelText;
|
||||
next.sessionLabels = { ...s.sessionLabels, [session.key]: truncated };
|
||||
const LABEL_RETRY_DELAYS = [2_000, 5_000, 10_000];
|
||||
void (async () => {
|
||||
let pending = sessionsToLabel;
|
||||
for (let attempt = 0; attempt <= LABEL_RETRY_DELAYS.length; attempt += 1) {
|
||||
const failed: typeof pending = [];
|
||||
await Promise.all(
|
||||
pending.map(async (session) => {
|
||||
try {
|
||||
const r = await useGatewayStore.getState().rpc<Record<string, unknown>>(
|
||||
'chat.history',
|
||||
{ sessionKey: session.key, limit: 1000 },
|
||||
);
|
||||
const msgs = Array.isArray(r.messages) ? r.messages as RawMessage[] : [];
|
||||
const firstUser = msgs.find((m) => m.role === 'user');
|
||||
const lastMsg = msgs[msgs.length - 1];
|
||||
set((s) => {
|
||||
const next: Partial<typeof s> = {};
|
||||
if (firstUser) {
|
||||
const labelText = getMessageText(firstUser.content).trim();
|
||||
if (labelText) {
|
||||
const truncated = labelText.length > 50 ? `${labelText.slice(0, 50)}…` : labelText;
|
||||
next.sessionLabels = { ...s.sessionLabels, [session.key]: truncated };
|
||||
}
|
||||
}
|
||||
if (lastMsg?.timestamp) {
|
||||
next.sessionLastActivity = { ...s.sessionLastActivity, [session.key]: toMs(lastMsg.timestamp) };
|
||||
}
|
||||
return next;
|
||||
});
|
||||
} catch (err) {
|
||||
if (classifyHistoryStartupRetryError(err) === 'gateway_startup') {
|
||||
failed.push(session);
|
||||
}
|
||||
}
|
||||
if (lastMsg?.timestamp) {
|
||||
next.sessionLastActivity = { ...s.sessionLastActivity, [session.key]: toMs(lastMsg.timestamp) };
|
||||
}
|
||||
return next;
|
||||
});
|
||||
} catch {
|
||||
// ignore per-session errors
|
||||
}
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
if (failed.length === 0 || attempt >= LABEL_RETRY_DELAYS.length) break;
|
||||
await sleep(LABEL_RETRY_DELAYS[attempt]!);
|
||||
pending = failed;
|
||||
}
|
||||
})();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -253,10 +253,12 @@ export function createHistoryActions(
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCurrentSession() && isInitialForegroundLoad && classifyHistoryStartupRetryError(lastError)) {
|
||||
const errorKind = classifyHistoryStartupRetryError(lastError);
|
||||
if (isCurrentSession() && isInitialForegroundLoad && errorKind) {
|
||||
console.warn('[chat.history] startup retry exhausted', {
|
||||
sessionKey: currentSessionKey,
|
||||
gatewayState: useGatewayStore.getState().status.state,
|
||||
errorKind,
|
||||
error: String(lastError),
|
||||
});
|
||||
}
|
||||
@@ -267,6 +269,11 @@ export function createHistoryActions(
|
||||
if (applied && isInitialForegroundLoad) {
|
||||
foregroundHistoryLoadSeen.add(currentSessionKey);
|
||||
}
|
||||
} else if (errorKind === 'gateway_startup') {
|
||||
// Suppress error UI for gateway startup -- the history will load
|
||||
// once the gateway finishes initializing (via sidebar refresh or
|
||||
// the next session switch).
|
||||
set({ loading: false });
|
||||
} else {
|
||||
applyLoadFailure(
|
||||
result?.error
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { GatewayStatus } from '@/types/gateway';
|
||||
|
||||
export const CHAT_HISTORY_RPC_TIMEOUT_MS = 35_000;
|
||||
export const CHAT_HISTORY_STARTUP_RETRY_DELAYS_MS = [600] as const;
|
||||
export const CHAT_HISTORY_STARTUP_CONNECTION_GRACE_MS = 15_000;
|
||||
export const CHAT_HISTORY_STARTUP_RETRY_DELAYS_MS = [800, 2_000, 4_000, 8_000] as const;
|
||||
export const CHAT_HISTORY_STARTUP_CONNECTION_GRACE_MS = 30_000;
|
||||
export const CHAT_HISTORY_STARTUP_RUNNING_WINDOW_MS =
|
||||
CHAT_HISTORY_RPC_TIMEOUT_MS + CHAT_HISTORY_STARTUP_CONNECTION_GRACE_MS;
|
||||
export const CHAT_HISTORY_DEFAULT_LOADING_SAFETY_TIMEOUT_MS = 15_000;
|
||||
@@ -11,11 +11,20 @@ export const CHAT_HISTORY_LOADING_SAFETY_TIMEOUT_MS =
|
||||
+ CHAT_HISTORY_STARTUP_RETRY_DELAYS_MS.reduce((sum, delay) => sum + delay, 0)
|
||||
+ 2_000;
|
||||
|
||||
export type HistoryRetryErrorKind = 'timeout' | 'gateway_unavailable';
|
||||
export type HistoryRetryErrorKind = 'timeout' | 'gateway_unavailable' | 'gateway_startup';
|
||||
|
||||
export function classifyHistoryStartupRetryError(error: unknown): HistoryRetryErrorKind | null {
|
||||
const message = String(error).toLowerCase();
|
||||
|
||||
if (
|
||||
message.includes('unavailable during gateway startup')
|
||||
|| message.includes('unavailable during startup')
|
||||
|| message.includes('not yet ready')
|
||||
|| message.includes('service not initialized')
|
||||
) {
|
||||
return 'gateway_startup';
|
||||
}
|
||||
|
||||
if (
|
||||
message.includes('rpc timeout: chat.history')
|
||||
|| message.includes('gateway rpc timeout: chat.history')
|
||||
@@ -47,6 +56,11 @@ export function shouldRetryStartupHistoryLoad(
|
||||
): boolean {
|
||||
if (!gatewayStatus || !errorKind) return false;
|
||||
|
||||
// The gateway explicitly told us it's still initializing -- always retry
|
||||
if (errorKind === 'gateway_startup') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (gatewayStatus.state === 'starting') {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { invokeIpc } from '@/lib/api-client';
|
||||
import { getCanonicalPrefixFromSessions, getMessageText, toMs } from './helpers';
|
||||
import { classifyHistoryStartupRetryError, sleep } from './history-startup-retry';
|
||||
import { DEFAULT_CANONICAL_PREFIX, DEFAULT_SESSION_KEY, type ChatSession, type RawMessage } from './types';
|
||||
import type { ChatGet, ChatSet, SessionHistoryActions } from './store-api';
|
||||
|
||||
@@ -111,38 +112,54 @@ export function createSessionActions(
|
||||
}
|
||||
|
||||
// Background: fetch first user message for every non-main session to populate labels upfront.
|
||||
// Uses a small limit so it's cheap; runs in parallel and doesn't block anything.
|
||||
// Retries on "gateway startup" errors since the gateway may still be initializing.
|
||||
const sessionsToLabel = sessionsWithCurrent.filter((s) => !s.key.endsWith(':main'));
|
||||
if (sessionsToLabel.length > 0) {
|
||||
void Promise.all(
|
||||
sessionsToLabel.map(async (session) => {
|
||||
try {
|
||||
const r = await invokeIpc(
|
||||
'gateway:rpc',
|
||||
'chat.history',
|
||||
{ sessionKey: session.key, limit: 1000 },
|
||||
) as { success: boolean; result?: Record<string, unknown> };
|
||||
if (!r.success || !r.result) return;
|
||||
const msgs = Array.isArray(r.result.messages) ? r.result.messages as RawMessage[] : [];
|
||||
const firstUser = msgs.find((m) => m.role === 'user');
|
||||
const lastMsg = msgs[msgs.length - 1];
|
||||
set((s) => {
|
||||
const next: Partial<typeof s> = {};
|
||||
if (firstUser) {
|
||||
const labelText = getMessageText(firstUser.content).trim();
|
||||
if (labelText) {
|
||||
const truncated = labelText.length > 50 ? `${labelText.slice(0, 50)}…` : labelText;
|
||||
next.sessionLabels = { ...s.sessionLabels, [session.key]: truncated };
|
||||
const LABEL_RETRY_DELAYS = [2_000, 5_000, 10_000];
|
||||
void (async () => {
|
||||
let pending = sessionsToLabel;
|
||||
for (let attempt = 0; attempt <= LABEL_RETRY_DELAYS.length; attempt += 1) {
|
||||
const failed: typeof pending = [];
|
||||
await Promise.all(
|
||||
pending.map(async (session) => {
|
||||
try {
|
||||
const r = await invokeIpc(
|
||||
'gateway:rpc',
|
||||
'chat.history',
|
||||
{ sessionKey: session.key, limit: 1000 },
|
||||
) as { success: boolean; result?: Record<string, unknown>; error?: string };
|
||||
if (!r.success) {
|
||||
if (classifyHistoryStartupRetryError(r.error) === 'gateway_startup') {
|
||||
failed.push(session);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (lastMsg?.timestamp) {
|
||||
next.sessionLastActivity = { ...s.sessionLastActivity, [session.key]: toMs(lastMsg.timestamp) };
|
||||
}
|
||||
return next;
|
||||
});
|
||||
} catch { /* ignore per-session errors */ }
|
||||
}),
|
||||
);
|
||||
if (!r.result) return;
|
||||
const msgs = Array.isArray(r.result.messages) ? r.result.messages as RawMessage[] : [];
|
||||
const firstUser = msgs.find((m) => m.role === 'user');
|
||||
const lastMsg = msgs[msgs.length - 1];
|
||||
set((s) => {
|
||||
const next: Partial<typeof s> = {};
|
||||
if (firstUser) {
|
||||
const labelText = getMessageText(firstUser.content).trim();
|
||||
if (labelText) {
|
||||
const truncated = labelText.length > 50 ? `${labelText.slice(0, 50)}…` : labelText;
|
||||
next.sessionLabels = { ...s.sessionLabels, [session.key]: truncated };
|
||||
}
|
||||
}
|
||||
if (lastMsg?.timestamp) {
|
||||
next.sessionLastActivity = { ...s.sessionLastActivity, [session.key]: toMs(lastMsg.timestamp) };
|
||||
}
|
||||
return next;
|
||||
});
|
||||
} catch { /* ignore per-session errors */ }
|
||||
}),
|
||||
);
|
||||
if (failed.length === 0 || attempt >= LABEL_RETRY_DELAYS.length) break;
|
||||
await sleep(LABEL_RETRY_DELAYS[attempt]!);
|
||||
pending = failed;
|
||||
}
|
||||
})();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
+16
-3
@@ -67,15 +67,28 @@ function buildGatewayEventDedupeKey(event: Record<string, unknown>): string | nu
|
||||
return null;
|
||||
}
|
||||
|
||||
function getMessageIdDedupeKey(event: Record<string, unknown>): string | null {
|
||||
const state = event.state != null ? String(event.state) : '';
|
||||
if (state !== 'final') return null;
|
||||
const message = event.message;
|
||||
if (message && typeof message === 'object') {
|
||||
const msgId = (message as Record<string, unknown>).id;
|
||||
if (msgId != null) return `final-msgid|${String(msgId)}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function shouldProcessGatewayEvent(event: Record<string, unknown>): boolean {
|
||||
const key = buildGatewayEventDedupeKey(event);
|
||||
if (!key) return true;
|
||||
const msgKey = getMessageIdDedupeKey(event);
|
||||
if (!key && !msgKey) return true;
|
||||
const now = Date.now();
|
||||
pruneGatewayEventDedupe(now);
|
||||
if (gatewayEventDedupe.has(key)) {
|
||||
if ((key && gatewayEventDedupe.has(key)) || (msgKey && gatewayEventDedupe.has(msgKey))) {
|
||||
return false;
|
||||
}
|
||||
gatewayEventDedupe.set(key, now);
|
||||
if (key) gatewayEventDedupe.set(key, now);
|
||||
if (msgKey) gatewayEventDedupe.set(msgKey, now);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,6 @@ vi.mock('@electron/utils/agent-config', () => ({
|
||||
|
||||
vi.mock('@electron/utils/plugin-install', () => ({
|
||||
ensureDingTalkPluginInstalled: vi.fn(),
|
||||
ensureFeishuPluginInstalled: vi.fn(),
|
||||
ensureWeChatPluginInstalled: vi.fn(),
|
||||
ensureWeComPluginInstalled: vi.fn(),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user