Commit 0d0a22ea authored by edy's avatar edy

feat(ui): port Feishu integration from Windows client, fix blank replies on model errors

Port Feishu channel integration from Windows client:
- Add Feishu Mobile config injection (injectFeishuChannelConfig)
- Add expert agent injection from manifest (injectExpertAgents)
- Add Feishu channel session merging (mergeChannelSessions)
- Add isChannelSession flag to ProjectSessionSummary
- Add listChannelSessions to gateway-client
- Add Feishu guards in sendPrompt/streamPrompt
- Add Feishu session tag in sidebar (SessionList.tsx)
- Disable composer for channel sessions (ChatComposer.tsx)
- Add Feishu Mobile env vars to project model runtime

Fix blank assistant replies when model API fails:
- gateway-client: keep pending run alive when chat final has empty content
- renderer: don't finalize stream on empty completed event
- renderer: add 45s timeout fallback if error never arrives
- renderer: surface error message in failActiveStream fallback chain

Code review fixes:
- Add safeClone() helper to prevent original config mutation
- Deduplicate concurrent ensureLocalTranscript calls (transcriptInFlight)
- Call closeSession for Feishu sessions (server-side cleanup)
- Extract isChannelSessionId() helper to replace magic string
- Cache expert manifest read once per process lifetime
- Parallelize Feishu secret fetches with Promise.all
- Reuse resolveExpertPromptsRoot and ExpertManifestRecord from expert-catalog
- Use appropriate log levels (console.log/warn vs console.error)
- Add diagnostic logging at gateway-client, IPC, and renderer levels
Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
parent 9d7f1c0a
import path from "node:path"; import path from "node:path";
import { access, appendFile, readFile, writeFile } from "node:fs/promises"; import { access, appendFile, readFile, writeFile } from "node:fs/promises";
import { existsSync, readFileSync } from "node:fs";
import { BrowserWindow, app, nativeImage } from "electron"; import { BrowserWindow, app, nativeImage } from "electron";
import { GatewayClient } from "@qjclaw/gateway-client"; import { GatewayClient } from "@qjclaw/gateway-client";
import { RuntimeManager } from "@qjclaw/runtime-manager"; import { RuntimeManager } from "@qjclaw/runtime-manager";
...@@ -26,7 +27,8 @@ import { resolveGenericSkillsRoot } from "./services/generic-skills-root.js"; ...@@ -26,7 +27,8 @@ import { resolveGenericSkillsRoot } from "./services/generic-skills-root.js";
import { SkillCatalogService } from "./services/skill-catalog.js"; import { SkillCatalogService } from "./services/skill-catalog.js";
import { SkillClient } from "./services/skill-client.js"; import { SkillClient } from "./services/skill-client.js";
import { SkillStoreService } from "./services/skill-store.js"; import { SkillStoreService } from "./services/skill-store.js";
import { ExpertCatalogService } from "./services/expert-catalog.js"; import { ExpertCatalogService, resolveExpertPromptsRoot } from "./services/expert-catalog.js";
import type { ExpertManifestRecord } from "./services/expert-catalog.js";
import { ProjectStoreService } from "./services/project-store.js"; import { ProjectStoreService } from "./services/project-store.js";
import { ProjectBundleService } from "./services/project-bundle.js"; import { ProjectBundleService } from "./services/project-bundle.js";
import { ProjectChatTargetResolverService } from "./services/project-chat-target-resolver.js"; import { ProjectChatTargetResolverService } from "./services/project-chat-target-resolver.js";
...@@ -511,6 +513,255 @@ function resolveRequestedRuntimeMode(configMode: RuntimeModePreference): Runtime ...@@ -511,6 +513,255 @@ function resolveRequestedRuntimeMode(configMode: RuntimeModePreference): Runtime
return override === "bundled-runtime" || override === "external-gateway" ? override : configMode; return override === "bundled-runtime" || override === "external-gateway" ? override : configMode;
} }
function safeClone<T>(obj: T): T {
try {
return structuredClone(obj);
} catch {
// Fallback: JSON round-trip handles plain config objects without
// symbols, functions, or circular references.
try {
return JSON.parse(JSON.stringify(obj)) as T;
} catch {
return obj;
}
}
}
function resolveExpertManifestPath(systemSummary: SystemSummary): string {
return path.join(resolveExpertPromptsRoot(systemSummary), "manifest.json");
}
/** Cached expert manifest – only read once per process lifetime. */
let cachedExpertManifest: ExpertManifestRecord[] | null | undefined;
function readExpertManifest(systemSummary: SystemSummary): ExpertManifestRecord[] | null {
if (cachedExpertManifest !== undefined) {
return cachedExpertManifest;
}
const manifestPath = resolveExpertManifestPath(systemSummary);
if (!existsSync(manifestPath)) {
cachedExpertManifest = null;
return null;
}
try {
cachedExpertManifest = JSON.parse(readFileSync(manifestPath, "utf8")) as ExpertManifestRecord[];
return cachedExpertManifest;
} catch (err) {
console.warn(
`readExpertManifest: failed to parse ${manifestPath}: ${String(err)}. ` +
`Expert routing will be disabled.`,
);
cachedExpertManifest = null;
return null;
}
}
/**
* Resolve the runtime state directory.
*/
function resolveAgentStateDir(systemSummary: SystemSummary): string {
return path.join(systemSummary.userDataPath, "runtime", "state");
}
/**
* Inject expert agents into the runtime config's agents.list so the Feishu
* plugin's LLM intent classifier can route messages to them.
*/
function injectExpertAgents(
managedConfig: Record<string, unknown>,
systemSummary: SystemSummary,
): Record<string, unknown> {
const nextConfig = safeClone(managedConfig);
const agentsSection = (
nextConfig.agents && typeof nextConfig.agents === "object"
? nextConfig.agents
: {}
) as Record<string, unknown>;
const existingList: Array<Record<string, unknown>> = Array.isArray(agentsSection.list)
? (agentsSection.list as Array<Record<string, unknown>>)
: [];
const existingById = new Map(
existingList.filter((a) => a.id).map((a) => [a.id as string, a]),
);
const DEFAULT_MAIN_AGENT_ID = "main";
const ensureMainDefaultAgent = () => {
const entry = existingById.get(DEFAULT_MAIN_AGENT_ID);
if (!entry) {
existingById.set(DEFAULT_MAIN_AGENT_ID, {
id: DEFAULT_MAIN_AGENT_ID,
default: true,
});
} else if (!entry.default) {
entry.default = true;
}
};
ensureMainDefaultAgent();
const manifest = readExpertManifest(systemSummary);
if (!manifest) {
agentsSection.list = [...existingById.values()];
nextConfig.agents = agentsSection;
return nextConfig;
}
const standaloneExperts = manifest.filter(
(e) => e.id && e.name && e.entryMode === "standalone",
);
if (standaloneExperts.length === 0) {
console.warn(
"injectExpertAgents: no standalone agents found in manifest. " +
"Expert routing will be disabled.",
);
agentsSection.list = [...existingById.values()];
nextConfig.agents = agentsSection;
return nextConfig;
}
const projectsRoot = path.join(systemSummary.userDataPath, "projects");
const stateDir = resolveAgentStateDir(systemSummary);
const newAgents: Array<Record<string, unknown>> = [];
for (const expert of standaloneExperts) {
const projectDir = path.join(projectsRoot, expert.id);
const agent: Record<string, unknown> = {
id: expert.id,
name: expert.name,
workspace: projectDir,
agentDir: path.join(stateDir, "agents", expert.id, "agent"),
};
agent.identity = { name: expert.name };
newAgents.push(agent);
}
console.log(
`injectExpertAgents: injecting ${newAgents.length} standalone expert(s) into agents.list: ` +
newAgents.map((a) => a.id).join(", "),
);
for (const agent of newAgents) {
existingById.set(agent.id as string, agent);
}
ensureMainDefaultAgent();
agentsSection.list = [...existingById.values()];
nextConfig.agents = agentsSection;
return nextConfig;
}
/**
* Inject Feishu channel config into the runtime config so the Feishu plugin
* can connect. Reads credentials from the secret manager and desktop config.
*/
async function injectFeishuChannelConfig(
managedConfig: Record<string, unknown>,
latestConfig: AppConfig,
secretManager: SecretManager,
systemSummary: SystemSummary,
): Promise<Record<string, unknown>> {
const mobileCfg = latestConfig.feishuMobileConfig;
if (!mobileCfg?.appIdConfigured || !mobileCfg?.appSecretConfigured) {
return managedConfig;
}
const vendorRuntimeDir = resolveVendorRuntimeDir(systemSummary);
const feishuExtensionDir = path.join(
vendorRuntimeDir,
"openclaw",
"package",
"dist",
"extensions",
"feishu",
);
if (!existsSync(feishuExtensionDir)) {
console.warn(
"injectFeishuChannelConfig: Feishu extension not found at %s, skipping channel injection",
feishuExtensionDir,
);
return managedConfig;
}
const [appId, appSecret] = await Promise.all([
secretManager.getFeishuMobileAppId(),
secretManager.getFeishuMobileAppSecret(),
]);
if (!appId || !appSecret) {
return managedConfig;
}
const nextConfig = safeClone(managedConfig);
const channelsSection = (
nextConfig.channels && typeof nextConfig.channels === "object"
? (nextConfig.channels as Record<string, unknown>)
: {}
) as Record<string, unknown>;
const existingFeishuCfg = (
channelsSection.feishu && typeof channelsSection.feishu === "object"
? (channelsSection.feishu as Record<string, unknown>)
: {}
) as Record<string, unknown>;
channelsSection.feishu = {
...existingFeishuCfg,
accounts: {
default: {
appId,
appSecret,
},
},
};
nextConfig.channels = channelsSection;
console.log(
"injectFeishuChannelConfig: injected Feishu mobile account into channels.feishu",
);
return nextConfig;
}
/**
* Finalize the managed plugins config before it is written to the runtime.
*/
function finalizeManagedPluginsConfig(
managedConfig: Record<string, unknown>,
): Record<string, unknown> {
const nextConfig = safeClone(managedConfig);
const pluginsSection = (
nextConfig.plugins && typeof nextConfig.plugins === "object"
? nextConfig.plugins
: {}
) as Record<string, unknown>;
const slots = (
pluginsSection.slots && typeof pluginsSection.slots === "object"
? pluginsSection.slots
: {}
) as Record<string, unknown>;
if (slots.memory === undefined || slots.memory === null) {
slots.memory = "none";
}
pluginsSection.slots = slots;
nextConfig.plugins = pluginsSection;
console.log(
"finalizeManagedPluginsConfig: slots.memory=none",
);
return nextConfig;
}
function buildManagedConfigFromEndpoint(input: { function buildManagedConfigFromEndpoint(input: {
defaultConfig: Record<string, unknown>; defaultConfig: Record<string, unknown>;
providerKey: string; providerKey: string;
...@@ -2282,20 +2533,33 @@ async function bootstrap(): Promise<void> { ...@@ -2282,20 +2533,33 @@ async function bootstrap(): Promise<void> {
managedConfigResolver: async ({ defaultConfig }) => { managedConfigResolver: async ({ defaultConfig }) => {
const latestConfig = await configService.load(); const latestConfig = await configService.load();
const apiKey = await secretManager.getApiKey(); const apiKey = await secretManager.getApiKey();
let managedConfig: Record<string, unknown>;
if (latestConfig.setupMode === "direct-provider") { if (latestConfig.setupMode === "direct-provider") {
if (!apiKey) { if (!apiKey) {
throw new Error("Direct provider API Key is not configured."); throw new Error("Direct provider API Key is not configured.");
} }
return buildDirectProviderManagedConfig(defaultConfig, latestConfig, apiKey); managedConfig = buildDirectProviderManagedConfig(defaultConfig, latestConfig, apiKey);
} } else if (!apiKey) {
if (!apiKey) { managedConfig = defaultConfig;
return defaultConfig; } else {
}
const chatModelApiKey = await secretManager.getCopywritingModelApiKey(); const chatModelApiKey = await secretManager.getCopywritingModelApiKey();
if (!hasConfiguredClientChatModel(latestConfig, chatModelApiKey)) { if (!hasConfiguredClientChatModel(latestConfig, chatModelApiKey)) {
return defaultConfig; managedConfig = defaultConfig;
} else {
managedConfig = buildClientChatManagedConfig(defaultConfig, latestConfig, chatModelApiKey);
}
}
try {
const withAgents = injectExpertAgents(managedConfig, systemSummary);
const withFeishu = await injectFeishuChannelConfig(withAgents, latestConfig, secretManager, systemSummary);
return finalizeManagedPluginsConfig(withFeishu);
} catch (err) {
console.error(
"managedConfigResolver: config injection failed, falling back to unmodified config:",
String(err),
);
return managedConfig;
} }
return buildClientChatManagedConfig(defaultConfig, latestConfig, chatModelApiKey);
}, },
strictBundledRuntime: systemSummary.isPackaged strictBundledRuntime: systemSummary.isPackaged
}); });
......
...@@ -18,6 +18,7 @@ import { ...@@ -18,6 +18,7 @@ import {
type PluginSummary, type PluginSummary,
type ProjectIntentSuggestion, type ProjectIntentSuggestion,
type ProjectResolvedAttachment, type ProjectResolvedAttachment,
type ProjectSessionSummary,
type TaskPanelArtifact, type TaskPanelArtifact,
type RuntimeCloudFetchAction, type RuntimeCloudFetchAction,
type RuntimeCloudStatus, type RuntimeCloudStatus,
...@@ -799,7 +800,9 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -799,7 +800,9 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
xhsFeishuAppId, xhsFeishuAppId,
xhsFeishuAppSecret, xhsFeishuAppSecret,
xhsFeishuAppToken, xhsFeishuAppToken,
xhsFeishuTableId xhsFeishuTableId,
feishuMobileAppId,
feishuMobileAppSecret
] = await Promise.all([ ] = await Promise.all([
secretManager.getCopywritingModelApiKey(), secretManager.getCopywritingModelApiKey(),
secretManager.getImageModelApiKey(), secretManager.getImageModelApiKey(),
...@@ -814,7 +817,9 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -814,7 +817,9 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
secretManager.getXhsFeishuAppId(), secretManager.getXhsFeishuAppId(),
secretManager.getXhsFeishuAppSecret(), secretManager.getXhsFeishuAppSecret(),
secretManager.getXhsFeishuAppToken(), secretManager.getXhsFeishuAppToken(),
secretManager.getXhsFeishuTableId() secretManager.getXhsFeishuTableId(),
secretManager.getFeishuMobileAppId(),
secretManager.getFeishuMobileAppSecret()
]); ]);
const runtime = buildProjectModelRuntime(projectId, config, { const runtime = buildProjectModelRuntime(projectId, config, {
copywritingApiKey, copywritingApiKey,
...@@ -830,7 +835,9 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -830,7 +835,9 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
xhsFeishuAppId, xhsFeishuAppId,
xhsFeishuAppSecret, xhsFeishuAppSecret,
xhsFeishuAppToken, xhsFeishuAppToken,
xhsFeishuTableId xhsFeishuTableId,
feishuMobileAppId,
feishuMobileAppSecret
}); });
const envFilePath = await materializeProjectModelRuntime(projectRoot, runtime); const envFilePath = await materializeProjectModelRuntime(projectRoot, runtime);
...@@ -1367,6 +1374,12 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -1367,6 +1374,12 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
if (typeof input.xhsFeishuConfig?.tableId === "string") { if (typeof input.xhsFeishuConfig?.tableId === "string") {
await secretManager.setXhsFeishuTableId(input.xhsFeishuConfig.tableId || undefined); await secretManager.setXhsFeishuTableId(input.xhsFeishuConfig.tableId || undefined);
} }
if (typeof input.feishuMobileConfig?.appId === "string") {
await secretManager.setFeishuMobileAppId(input.feishuMobileConfig.appId || undefined);
}
if (typeof input.feishuMobileConfig?.appSecret === "string") {
await secretManager.setFeishuMobileAppSecret(input.feishuMobileConfig.appSecret || undefined);
}
if ( if (
config.setupMode === "direct-provider" config.setupMode === "direct-provider"
|| previousConfig.setupMode !== config.setupMode || previousConfig.setupMode !== config.setupMode
...@@ -1417,6 +1430,47 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -1417,6 +1430,47 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
return getEffectiveConfig(); return getEffectiveConfig();
}; };
const mergeChannelSessions = async (projectSessions: ProjectSessionSummary[], projectId: string): Promise<ProjectSessionSummary[]> => {
try {
const channelSessions = await gatewayClient.listChannelSessions("feishu");
const existingIds = new Set(projectSessions.map((s) => s.id));
const allProjects = await projectStore.listProjects();
const resolveChannelProjectId = (sessionKey: string): string => {
const parts = sessionKey.split(":");
if (parts.length < 3 || parts[0] !== "agent") return BUILTIN_HOME_PROJECT_ID;
const agentId = parts[1];
if (agentId === "main") return BUILTIN_HOME_PROJECT_ID;
const matchedProject = allProjects.find(
(p) => p.id === agentId || p.name?.toLowerCase() === agentId.toLowerCase()
);
return matchedProject?.id ?? BUILTIN_HOME_PROJECT_ID;
};
const newSessions = channelSessions
.filter((cs) => !existingIds.has(cs.id))
.map((cs) => ({
id: cs.id,
projectId: resolveChannelProjectId(cs.id),
title: cs.title,
updatedAt: cs.updatedAt,
channelType: cs.channelType,
isChannelSession: true
}))
.filter((s) => {
return s.projectId === projectId;
});
return [...projectSessions, ...newSessions];
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.warn(`mergeChannelSessions: failed to merge Feishu sessions: ${message}`);
void startupLogger.warn("diagnostics", "feishu-sessions.merge.error", `Failed to merge Feishu sessions: ${message}`);
return projectSessions;
}
};
const buildWorkspaceSummary = async (): Promise<WorkspaceSummary> => { const buildWorkspaceSummary = async (): Promise<WorkspaceSummary> => {
const config = await getEffectiveConfig(); const config = await getEffectiveConfig();
await projectStore.initialize(); await projectStore.initialize();
...@@ -1872,21 +1926,58 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -1872,21 +1926,58 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
} }
}; };
const isChannelSessionId = (sessionId: string): boolean => sessionId.includes(":feishu:");
const stripSystemHints = (content: string): string => content.replace(/\n?\[System:[^\]]*\]/g, "").trim();
// Deduplicate concurrent transcript loads for the same session to prevent
// TOCTOU races on seedSessionMessages.
const transcriptInFlight = new Map<string, Promise<ChatMessage[]>>();
const ensureLocalTranscript = async (sessionId: string): Promise<ChatMessage[]> => { const ensureLocalTranscript = async (sessionId: string): Promise<ChatMessage[]> => {
const inflight = transcriptInFlight.get(sessionId);
if (inflight) {
return inflight;
}
const channel = isChannelSessionId(sessionId);
const promise = (async (): Promise<ChatMessage[]> => {
if (!channel) {
const localMessages = await projectStore.listSessionMessages(sessionId); const localMessages = await projectStore.listSessionMessages(sessionId);
if (localMessages.length > 0) { if (localMessages.length > 0) {
return localMessages; return localMessages;
} }
}
try { try {
const gatewayMessages = await gatewayClient.listMessages(sessionId); const rawMessages = await gatewayClient.listMessages(sessionId);
const gatewayMessages = channel
? rawMessages.map((m) => ({ ...m, content: stripSystemHints(m.content) })).filter((m) => m.content)
: rawMessages;
if (gatewayMessages.length > 0) { if (gatewayMessages.length > 0) {
let shouldSeed = true;
if (channel) {
const localMessages = await projectStore.listSessionMessages(sessionId);
shouldSeed = localMessages.length !== gatewayMessages.length;
}
if (shouldSeed) {
await projectStore.seedSessionMessages(sessionId, gatewayMessages); await projectStore.seedSessionMessages(sessionId, gatewayMessages);
} }
}
return gatewayMessages; return gatewayMessages;
} catch { } catch {
const localMessages = await projectStore.listSessionMessages(sessionId);
return localMessages; return localMessages;
} }
})();
transcriptInFlight.set(sessionId, promise);
try {
return await promise;
} finally {
transcriptInFlight.delete(sessionId);
}
}; };
const resolveProjectIntentSuggestion = async (prompt: string, currentProjectId?: string): Promise<ProjectIntentSuggestion | null> => { const resolveProjectIntentSuggestion = async (prompt: string, currentProjectId?: string): Promise<ProjectIntentSuggestion | null> => {
...@@ -2005,6 +2096,9 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -2005,6 +2096,9 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
const listChatMessages = async (sessionId: string): Promise<ChatMessage[]> => ensureLocalTranscript(sessionId); const listChatMessages = async (sessionId: string): Promise<ChatMessage[]> => ensureLocalTranscript(sessionId);
const sendPrompt = async (sessionId: string, prompt: string, skillId?: string, attachments?: ChatAttachment[]) => { const sendPrompt = async (sessionId: string, prompt: string, skillId?: string, attachments?: ChatAttachment[]) => {
if (isChannelSessionId(sessionId)) {
throw new Error("飞书会话请在飞书中回复,此处仅支持查看消息。");
}
if (isHomeImageAttachmentRequest(sessionId, skillId, attachments)) { if (isHomeImageAttachmentRequest(sessionId, skillId, attachments)) {
return sendHomeImagePrompt(sessionId, prompt, attachments); return sendHomeImagePrompt(sessionId, prompt, attachments);
} }
...@@ -2230,6 +2324,9 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -2230,6 +2324,9 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
}; };
const streamPrompt = async (sessionId: string, prompt: string, skillId?: string, attachments?: ChatAttachment[], sender?: WebContents) => { const streamPrompt = async (sessionId: string, prompt: string, skillId?: string, attachments?: ChatAttachment[], sender?: WebContents) => {
if (isChannelSessionId(sessionId)) {
throw new Error("飞书会话请在飞书中回复,此处仅支持查看消息。");
}
const requestId = randomUUID(); const requestId = randomUUID();
const userMessageId = randomUUID(); const userMessageId = randomUUID();
const assistantMessageId = randomUUID(); const assistantMessageId = randomUUID();
...@@ -2872,6 +2969,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -2872,6 +2969,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
}); });
}, },
onCompleted: ({ sessionId: nextSessionId, runId, reply }) => { onCompleted: ({ sessionId: nextSessionId, runId, reply }) => {
console.log("[ipc] onCompleted: sessionId:", nextSessionId, "runId:", runId, "contentLen:", reply.content.length);
if (activeChatStream.cancelled) { if (activeChatStream.cancelled) {
return; return;
} }
...@@ -2911,7 +3009,9 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -2911,7 +3009,9 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
queueProjectContextRefresh(); queueProjectContextRefresh();
}, },
onError: ({ sessionId: nextSessionId, runId, error }) => { onError: ({ sessionId: nextSessionId, runId, error }) => {
console.error("[ipc] onError received:", error.message, "sessionId:", nextSessionId, "runId:", runId);
if (activeChatStream.cancelled) { if (activeChatStream.cancelled) {
console.error("[ipc] onError: stream cancelled, dropping error");
return; return;
} }
executionSessionId = nextSessionId; executionSessionId = nextSessionId;
...@@ -2933,6 +3033,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -2933,6 +3033,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
modelId: executionPolicy?.modelId, modelId: executionPolicy?.modelId,
sessionId: nextSessionId sessionId: nextSessionId
}); });
console.log("[ipc] onError: forwarding error event to renderer");
queueOrSend({ queueOrSend({
type: "error", type: "error",
requestId, requestId,
...@@ -3175,14 +3276,16 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -3175,14 +3276,16 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
}); });
ipcMain.handle(IPC_CHANNELS.chatListSessions, async () => { ipcMain.handle(IPC_CHANNELS.chatListSessions, async () => {
const sessions = await listSessionsForActiveProject(projectStore); const projectSessions = await listSessionsForActiveProject(projectStore);
runtimeCloudSupervisor.noteSessions(sessions.map((session) => session.id)); const merged = await mergeChannelSessions(projectSessions, (await projectStore.getActiveProject()).id);
return sessions; runtimeCloudSupervisor.noteSessions(merged.map((session) => session.id));
return merged;
}); });
ipcMain.handle(IPC_CHANNELS.chatListSessionsByProject, async (_event, projectId: string) => { ipcMain.handle(IPC_CHANNELS.chatListSessionsByProject, async (_event, projectId: string) => {
const sessions = await projectStore.listSessions(projectId); const projectSessions = await projectStore.listSessions(projectId);
runtimeCloudSupervisor.noteSessions(sessions.map((session) => session.id)); const merged = await mergeChannelSessions(projectSessions, projectId);
return sessions; runtimeCloudSupervisor.noteSessions(merged.map((session) => session.id));
return merged;
}); });
ipcMain.handle(IPC_CHANNELS.chatCreateSession, async (_event, title?: string) => { ipcMain.handle(IPC_CHANNELS.chatCreateSession, async (_event, title?: string) => {
const session = await createSessionForActiveProject(projectStore, title); const session = await createSessionForActiveProject(projectStore, title);
...@@ -3309,14 +3412,16 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -3309,14 +3412,16 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
}, },
chat: { chat: {
listSessions: async () => { listSessions: async () => {
const sessions = await listSessionsForActiveProject(projectStore); const projectSessions = await listSessionsForActiveProject(projectStore);
runtimeCloudSupervisor.noteSessions(sessions.map((session) => session.id)); const merged = await mergeChannelSessions(projectSessions, (await projectStore.getActiveProject()).id);
return sessions; runtimeCloudSupervisor.noteSessions(merged.map((session) => session.id));
return merged;
}, },
listSessionsByProject: async (projectId: string) => { listSessionsByProject: async (projectId: string) => {
const sessions = await projectStore.listSessions(projectId); const projectSessions = await projectStore.listSessions(projectId);
runtimeCloudSupervisor.noteSessions(sessions.map((session) => session.id)); const merged = await mergeChannelSessions(projectSessions, projectId);
return sessions; runtimeCloudSupervisor.noteSessions(merged.map((session) => session.id));
return merged;
}, },
createSession: async (title?: string) => { createSession: async (title?: string) => {
const session = await createSessionForActiveProject(projectStore, title); const session = await createSessionForActiveProject(projectStore, title);
......
...@@ -2,7 +2,7 @@ import { existsSync, readFileSync } from "node:fs"; ...@@ -2,7 +2,7 @@ import { existsSync, readFileSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import type { ExpertDefinition, ExpertEntryMode, SystemSummary } from "@qjclaw/shared-types"; import type { ExpertDefinition, ExpertEntryMode, SystemSummary } from "@qjclaw/shared-types";
interface ExpertManifestRecord { export interface ExpertManifestRecord {
id: string; id: string;
name: string; name: string;
entryMode: ExpertEntryMode; entryMode: ExpertEntryMode;
......
...@@ -17,6 +17,8 @@ export interface ProjectModelRuntimeSecrets { ...@@ -17,6 +17,8 @@ export interface ProjectModelRuntimeSecrets {
xhsFeishuAppSecret?: string; xhsFeishuAppSecret?: string;
xhsFeishuAppToken?: string; xhsFeishuAppToken?: string;
xhsFeishuTableId?: string; xhsFeishuTableId?: string;
feishuMobileAppId?: string;
feishuMobileAppSecret?: string;
} }
export interface ProjectModelRuntimePreparation { export interface ProjectModelRuntimePreparation {
...@@ -246,6 +248,8 @@ export function buildProjectModelRuntime( ...@@ -246,6 +248,8 @@ export function buildProjectModelRuntime(
const xhsFeishuAppSecret = normalizeValue(secrets.xhsFeishuAppSecret); const xhsFeishuAppSecret = normalizeValue(secrets.xhsFeishuAppSecret);
const xhsFeishuAppToken = normalizeValue(secrets.xhsFeishuAppToken); const xhsFeishuAppToken = normalizeValue(secrets.xhsFeishuAppToken);
const xhsFeishuTableId = normalizeValue(secrets.xhsFeishuTableId); const xhsFeishuTableId = normalizeValue(secrets.xhsFeishuTableId);
const feishuMobileAppId = normalizeValue(secrets.feishuMobileAppId);
const feishuMobileAppSecret = normalizeValue(secrets.feishuMobileAppSecret);
const env: Record<string, string> = {}; const env: Record<string, string> = {};
if (XHS_PROJECT_IDS.has(normalizedProjectId)) { if (XHS_PROJECT_IDS.has(normalizedProjectId)) {
...@@ -285,6 +289,13 @@ export function buildProjectModelRuntime( ...@@ -285,6 +289,13 @@ export function buildProjectModelRuntime(
} }
} }
if (feishuMobileAppId) {
env.FEISHU_MOBILE_APP_ID = feishuMobileAppId;
}
if (feishuMobileAppSecret) {
env.FEISHU_MOBILE_APP_SECRET = feishuMobileAppSecret;
}
if (DOUYIN_PROJECT_IDS.has(normalizedProjectId)) { if (DOUYIN_PROJECT_IDS.has(normalizedProjectId)) {
const writerBaseUrl = normalizeChatCompletionsBaseUrl(copywritingBaseUrl); const writerBaseUrl = normalizeChatCompletionsBaseUrl(copywritingBaseUrl);
const seedreamBaseUrl = normalizeArkBaseUrl(imageBaseUrl); const seedreamBaseUrl = normalizeArkBaseUrl(imageBaseUrl);
......
...@@ -663,6 +663,19 @@ export default function App() { ...@@ -663,6 +663,19 @@ export default function App() {
setVectcutFileBaseUrlDraft(drafts.vectcutFileBaseUrl); setVectcutFileBaseUrlDraft(drafts.vectcutFileBaseUrl);
setVectcutApiKeyDraft(drafts.vectcutApiKey); setVectcutApiKeyDraft(drafts.vectcutApiKey);
}, [config]); }, [config]);
const modelDraftsInitializedRef = useRef(false);
useEffect(() => {
if (modelDraftsInitializedRef.current || !config) {
return;
}
modelDraftsInitializedRef.current = true;
resetCopywritingSettingsDrafts();
resetImageSettingsDrafts();
resetVideoSettingsDrafts();
resetDouyinRuntimeSettingsDrafts();
}, [config, resetCopywritingSettingsDrafts, resetImageSettingsDrafts, resetVideoSettingsDrafts, resetDouyinRuntimeSettingsDrafts]);
const startupProgressTarget = chatLaunchState === "ready" ? 1 : getStartupProgress(startupPhase); const startupProgressTarget = chatLaunchState === "ready" ? 1 : getStartupProgress(startupPhase);
const [visualStartupProgress, setVisualStartupProgress] = useState(startupProgressTarget); const [visualStartupProgress, setVisualStartupProgress] = useState(startupProgressTarget);
useEffect(() => { useEffect(() => {
...@@ -727,6 +740,10 @@ export default function App() { ...@@ -727,6 +740,10 @@ export default function App() {
() => scopedSessions.some((session) => session.id === activeSessionId) ? activeSessionId : preferredSessionId, () => scopedSessions.some((session) => session.id === activeSessionId) ? activeSessionId : preferredSessionId,
[activeSessionId, preferredSessionId, scopedSessions] [activeSessionId, preferredSessionId, scopedSessions]
); );
const visibleSessionIsChannel = useMemo(
() => scopedSessions.some((s) => s.id === visibleSessionId && s.isChannelSession),
[scopedSessions, visibleSessionId]
);
const { const {
messagesBySession, messagesBySession,
messages, messages,
...@@ -1498,6 +1515,7 @@ export default function App() { ...@@ -1498,6 +1515,7 @@ export default function App() {
skillMenuRef, skillMenuRef,
prompt, prompt,
isBound, isBound,
isChannelSession: visibleSessionIsChannel,
canSend, canSend,
isComposerDragOver, isComposerDragOver,
isComposerResizeActive, isComposerResizeActive,
......
...@@ -18,6 +18,7 @@ interface ComposerSkill { ...@@ -18,6 +18,7 @@ interface ComposerSkill {
interface ChatComposerProps { interface ChatComposerProps {
prompt: string prompt: string
isBound: boolean isBound: boolean
isChannelSession: boolean
sending: boolean sending: boolean
canSend: boolean canSend: boolean
isDragOver: boolean isDragOver: boolean
...@@ -61,6 +62,7 @@ interface ChatComposerProps { ...@@ -61,6 +62,7 @@ interface ChatComposerProps {
export function ChatComposer({ export function ChatComposer({
prompt, prompt,
isBound, isBound,
isChannelSession,
sending, sending,
canSend, canSend,
isDragOver, isDragOver,
...@@ -135,11 +137,11 @@ export function ChatComposer({ ...@@ -135,11 +137,11 @@ export function ChatComposer({
<label className="composer-field"> <label className="composer-field">
<textarea <textarea
value={prompt} value={prompt}
disabled={!isBound} disabled={!isBound || isChannelSession}
onChange={(event) => onPromptChange(event.target.value)} onChange={(event) => onPromptChange(event.target.value)}
onKeyDown={(event) => void onTextareaKeyDown(event)} onKeyDown={(event) => void onTextareaKeyDown(event)}
placeholder={placeholder} placeholder={isChannelSession ? "飞书会话请在飞书中回复" : placeholder}
className="composer-textarea" className={"composer-textarea" + (isChannelSession ? " readonly" : "")}
/> />
</label> </label>
{attachments.length ? ( {attachments.length ? (
...@@ -156,7 +158,7 @@ export function ChatComposer({ ...@@ -156,7 +158,7 @@ export function ChatComposer({
) : null} ) : null}
<div className="composer-footer"> <div className="composer-footer">
<div className="composer-left-tools"> <div className="composer-left-tools">
<button type="button" className="attachment-trigger icon-only" disabled={!isBound || sending} onClick={onOpenAttachmentPicker} aria-label="上传附件" title="上传附件"> <button type="button" className="attachment-trigger icon-only" disabled={!isBound || isChannelSession || sending} onClick={onOpenAttachmentPicker} aria-label="上传附件" title="上传附件">
{attachmentIcon} {attachmentIcon}
</button> </button>
</div> </div>
...@@ -172,7 +174,7 @@ export function ChatComposer({ ...@@ -172,7 +174,7 @@ export function ChatComposer({
<span className="visually-hidden">{sendButtonLabel}</span> <span className="visually-hidden">{sendButtonLabel}</span>
</button> </button>
</div> </div>
<p className="composer-hint">按 Enter 发送,Shift + Enter 换行</p> <p className="composer-hint">{isChannelSession ? "飞书会话仅支持查看消息,回复请在飞书中操作" : "按 Enter 发送,Shift + Enter 换行"}</p>
</div> </div>
</form> </form>
) )
......
...@@ -101,6 +101,7 @@ interface ConversationWorkspaceComposerProps { ...@@ -101,6 +101,7 @@ interface ConversationWorkspaceComposerProps {
skillMenuRef: RefObject<HTMLDivElement | null> skillMenuRef: RefObject<HTMLDivElement | null>
prompt: string prompt: string
isBound: boolean isBound: boolean
isChannelSession: boolean
canSend: boolean canSend: boolean
isComposerDragOver: boolean isComposerDragOver: boolean
isComposerResizeActive: boolean isComposerResizeActive: boolean
...@@ -320,6 +321,7 @@ export function ConversationWorkspaceView({ ...@@ -320,6 +321,7 @@ export function ConversationWorkspaceView({
<ChatComposer <ChatComposer
prompt={composer.prompt} prompt={composer.prompt}
isBound={composer.isBound} isBound={composer.isBound}
isChannelSession={composer.isChannelSession}
sending={messages.sending} sending={messages.sending}
canSend={composer.canSend} canSend={composer.canSend}
isDragOver={composer.isComposerDragOver} isDragOver={composer.isComposerDragOver}
......
...@@ -264,6 +264,20 @@ export function useChatSessionsController(deps: UseChatSessionsControllerDeps) { ...@@ -264,6 +264,20 @@ export function useChatSessionsController(deps: UseChatSessionsControllerDeps) {
return return
} }
const isChannelSession = sessionId.includes(":feishu:")
if (isChannelSession) {
setProjectActionPending(true)
setErrorText("")
setSessions((current) => current.filter((s) => s.id !== sessionId))
if (activeSessionId === sessionId) {
setActiveProjectSession(EMPTY_SESSION_ID)
}
// Also close server-side so it doesn't reappear on next mergeChannelSessions
desktopApi.chat.closeSession(sessionId).catch(() => undefined)
setProjectActionPending(false)
return
}
setProjectActionPending(true) setProjectActionPending(true)
setErrorText("") setErrorText("")
try { try {
......
...@@ -126,6 +126,7 @@ export function useChatStreamingController(deps: UseChatStreamingControllerDeps) ...@@ -126,6 +126,7 @@ export function useChatStreamingController(deps: UseChatStreamingControllerDeps)
const [sendPhase, setSendPhase] = useState<SendPhase>("idle") const [sendPhase, setSendPhase] = useState<SendPhase>("idle")
const [streamSmoke, setStreamSmoke] = useState<SmokeStreamSnapshot | null>(null) const [streamSmoke, setStreamSmoke] = useState<SmokeStreamSnapshot | null>(null)
const activeStreamRef = useRef<ActiveStreamState | null>(null) const activeStreamRef = useRef<ActiveStreamState | null>(null)
const emptyCompletedTimeoutRef = useRef<{ timer: ReturnType<typeof setTimeout>; requestId: string } | null>(null)
const workspaceRef = useRef(workspace) const workspaceRef = useRef(workspace)
const runtimeStatusRef = useRef(runtimeStatus) const runtimeStatusRef = useRef(runtimeStatus)
const gatewayStatusRef = useRef(gatewayStatus) const gatewayStatusRef = useRef(gatewayStatus)
...@@ -305,15 +306,20 @@ export function useChatStreamingController(deps: UseChatStreamingControllerDeps) ...@@ -305,15 +306,20 @@ export function useChatStreamingController(deps: UseChatStreamingControllerDeps)
const failActiveStream = useCallback((message: string) => { const failActiveStream = useCallback((message: string) => {
const activeStream = activeStreamRef.current const activeStream = activeStreamRef.current
console.error("[renderer] failActiveStream called, message:", message, "hasActiveStream:", !!activeStream);
if (activeStream) { if (activeStream) {
cancelTypewriter() cancelTypewriter()
updateMessageById(activeStream.assistantMessageId, (current) => ({ updateMessageById(activeStream.assistantMessageId, (current) => {
const nextContent = activeStream.renderedText || activeStream.targetText || message || current.content;
console.log("[renderer] failActiveStream: setting content:", nextContent, "streamed:", activeStream.renderedText, "target:", activeStream.targetText);
return {
...current, ...current,
content: activeStream.renderedText || activeStream.targetText || current.content, content: nextContent,
streamState: "error", streamState: "error",
statusLabel: undefined, statusLabel: undefined,
statusDetail: undefined statusDetail: undefined
})) };
})
updateStreamSmoke((current) => current ? { updateStreamSmoke((current) => current ? {
...current, ...current,
phase: "error", phase: "error",
...@@ -818,6 +824,25 @@ export function useChatStreamingController(deps: UseChatStreamingControllerDeps) ...@@ -818,6 +824,25 @@ export function useChatStreamingController(deps: UseChatStreamingControllerDeps)
if (event.reply.content.length >= activeStream.targetText.length) { if (event.reply.content.length >= activeStream.targetText.length) {
activeStream.targetText = event.reply.content activeStream.targetText = event.reply.content
} }
// Guard: if the reply is empty and no deltas arrived, the model likely failed.
// Keep activeStream alive so a subsequent error event can update the message.
// Set a 45s timeout to recover if the error event never arrives.
if (!event.reply.content.trim() && !activeStream.targetText.trim()) {
console.warn("[renderer] completed event with empty content, no deltas — waiting for error")
updateAssistantStatus(activeStream.assistantMessageId, "正在等待回复…")
const timeoutRequestId = event.requestId
emptyCompletedTimeoutRef.current = {
requestId: timeoutRequestId,
timer: setTimeout(() => {
if (activeStreamRef.current?.requestId === timeoutRequestId) {
console.warn("[renderer] empty completed timeout — forcing error")
failActiveStream("等待回复超时,请检查 API Key 和模型配置后重试")
}
emptyCompletedTimeoutRef.current = null
}, 45_000)
}
return
}
appendTrace(activeStream.assistantMessageId, "completed", ui.replyReady, undefined, "success") appendTrace(activeStream.assistantMessageId, "completed", ui.replyReady, undefined, "success")
updateStreamSmoke((current) => current ? { updateStreamSmoke((current) => current ? {
...current, ...current,
...@@ -844,8 +869,15 @@ export function useChatStreamingController(deps: UseChatStreamingControllerDeps) ...@@ -844,8 +869,15 @@ export function useChatStreamingController(deps: UseChatStreamingControllerDeps)
} }
if (event.type === "error") { if (event.type === "error") {
console.error("[renderer] stream error event received:", event.message, "requestId:", event.requestId);
// Clear any pending empty-completed timeout since the error arrived
if (emptyCompletedTimeoutRef.current?.requestId === event.requestId) {
clearTimeout(emptyCompletedTimeoutRef.current.timer)
emptyCompletedTimeoutRef.current = null
}
stoppedRequestIdsRef.current.delete(event.requestId) stoppedRequestIdsRef.current.delete(event.requestId)
const normalizedMessage = normalizeAssistantErrorMessage(event.message, event.errorCategory) const normalizedMessage = normalizeAssistantErrorMessage(event.message, event.errorCategory)
console.error("[renderer] normalized error message:", normalizedMessage);
appendTrace(activeStream.assistantMessageId, "error", ui.replyFailed, normalizedMessage, "error") appendTrace(activeStream.assistantMessageId, "error", ui.replyFailed, normalizedMessage, "error")
updateStreamSmoke((current) => current ? { updateStreamSmoke((current) => current ? {
...current, ...current,
...@@ -863,6 +895,11 @@ export function useChatStreamingController(deps: UseChatStreamingControllerDeps) ...@@ -863,6 +895,11 @@ export function useChatStreamingController(deps: UseChatStreamingControllerDeps)
unsubscribe() unsubscribe()
cancelTypewriter() cancelTypewriter()
activeStreamRef.current = null activeStreamRef.current = null
const pendingTimeout = emptyCompletedTimeoutRef.current
if (pendingTimeout) {
clearTimeout(pendingTimeout.timer)
emptyCompletedTimeoutRef.current = null
}
} }
}, [cancelActiveStream, desktopApi.chat]) }, [cancelActiveStream, desktopApi.chat])
......
...@@ -53,7 +53,7 @@ export function SessionList({ ...@@ -53,7 +53,7 @@ export function SessionList({
disabled={projectActionPending} disabled={projectActionPending}
onClick={() => onOpenSession(session)} onClick={() => onOpenSession(session)}
> >
<strong>{sessionTitles[session.id] ?? formatSessionTitle(session.title, index)}</strong> <strong>{sessionTitles[session.id] ?? formatSessionTitle(session.title, index)}{session.isChannelSession ? <span className="sidebar-session-tag">飞书</span> : null}</strong>
</button> </button>
{sessions.length > 1 ? ( {sessions.length > 1 ? (
<button <button
......
...@@ -349,6 +349,19 @@ ...@@ -349,6 +349,19 @@
box-shadow: inset 0 0 0 1px rgba(159, 174, 215, 0.96); box-shadow: inset 0 0 0 1px rgba(159, 174, 215, 0.96);
} }
.sidebar-session-tag {
display: inline-block;
margin-left: 6px;
padding: 0 5px;
font-size: 10px;
line-height: 16px;
font-weight: 500;
color: #8b5cf6;
background: rgba(139, 92, 246, 0.08);
border-radius: 3px;
vertical-align: middle;
}
.chat-header { .chat-header {
display: flex; display: flex;
align-items: center; align-items: center;
......
...@@ -96,6 +96,7 @@ interface SessionsListResult { ...@@ -96,6 +96,7 @@ interface SessionsListResult {
sessionId?: string; sessionId?: string;
model?: string; model?: string;
lastChannel?: string; lastChannel?: string;
channel?: string;
}>; }>;
} }
...@@ -108,6 +109,25 @@ interface ChatHistoryResult { ...@@ -108,6 +109,25 @@ interface ChatHistoryResult {
}>; }>;
} }
const CHANNEL_LABELS: Record<string, string> = {
feishu: "飞书",
lark: "Lark"
};
function formatChannelSessionTitle(sessionKey: string, channel: string): string {
const label = CHANNEL_LABELS[channel] ?? channel;
const parts = sessionKey.split(":");
// agent:main:<channel>:<peerKind>:<peerId>
if (parts.length >= 5) {
const peerKind = parts[3];
const peerId = parts.slice(4).join(":");
const kindLabel = peerKind === "direct" ? "私聊" : peerKind === "group" ? "群聊" : peerKind;
const shortId = peerId.length > 12 ? `${peerId.slice(0, 6)}...${peerId.slice(-6)}` : peerId;
return `${label}${kindLabel} ${shortId}`;
}
return `${label} ${sessionKey}`;
}
export interface GatewayPromptStreamStart { export interface GatewayPromptStreamStart {
sessionId: string; sessionId: string;
runId: string; runId: string;
...@@ -432,7 +452,19 @@ export class GatewayClient { ...@@ -432,7 +452,19 @@ export class GatewayClient {
return (result.sessions ?? []).map((session) => ({ return (result.sessions ?? []).map((session) => ({
id: session.key ?? session.sessionId ?? randomUUID(), id: session.key ?? session.sessionId ?? randomUUID(),
title: session.key ?? session.model ?? "Session", title: session.key ?? session.model ?? "Session",
updatedAt: new Date(session.updatedAt ?? Date.now()).toISOString() updatedAt: new Date(session.updatedAt ?? Date.now()).toISOString(),
channelType: session.lastChannel ?? session.channel
}));
}
async listChannelSessions(channel?: string): Promise<SessionSummary[]> {
const all = await this.listSessions();
if (!channel) {
return all.filter((s) => Boolean(s.channelType));
}
return all.filter((s) => s.channelType === channel).map((s) => ({
...s,
title: formatChannelSessionTitle(s.id, s.channelType as string)
})); }));
} }
...@@ -570,7 +602,14 @@ export class GatewayClient { ...@@ -570,7 +602,14 @@ export class GatewayClient {
if (runId) { if (runId) {
this.emitChatDelta(runId, payload); this.emitChatDelta(runId, payload);
if (state === "final") { if (state === "final") {
const pending = this.pendingChatRuns.get(runId);
const reply = await this.resolveCompletedChatReply(runId, payload); const reply = await this.resolveCompletedChatReply(runId, payload);
if (!reply.content.trim() && !pending?.accumulatedText.trim()) {
// Gateway signaled final but the reply is empty and no deltas arrived.
// Keep the pending run alive — an agent error event may follow.
this.appendLog("warn", `chat final event received with empty content for run ${runId}; waiting for agent error or timeout.`);
return;
}
this.completeChatRun(runId, reply); this.completeChatRun(runId, reply);
} }
} }
...@@ -582,6 +621,7 @@ export class GatewayClient { ...@@ -582,6 +621,7 @@ export class GatewayClient {
const stream = this.findStringDeep(payload, ["stream"]); const stream = this.findStringDeep(payload, ["stream"]);
if (stream === "error") { if (stream === "error") {
const message = this.extractTextCandidate(payload.data) ?? this.extractTextCandidate(payload) ?? JSON.stringify(payload.data ?? {}); const message = this.extractTextCandidate(payload.data) ?? this.extractTextCandidate(payload) ?? JSON.stringify(payload.data ?? {});
console.error("[gateway-client] agent stream error:", message, "runId:", runId);
this.appendLog("warn", "Agent stream error: " + message); this.appendLog("warn", "Agent stream error: " + message);
if (this.isRecoverableAgentStreamError(message)) { if (this.isRecoverableAgentStreamError(message)) {
return; return;
...@@ -870,9 +910,11 @@ export class GatewayClient { ...@@ -870,9 +910,11 @@ export class GatewayClient {
private failChatRun(runId: string, error: Error): void { private failChatRun(runId: string, error: Error): void {
const pending = this.pendingChatRuns.get(runId); const pending = this.pendingChatRuns.get(runId);
if (!pending) { if (!pending) {
console.error("[gateway-client] failChatRun: no pending run for", runId, "error:", error.message);
return; return;
} }
console.error("[gateway-client] failChatRun: failing run", runId, "error:", error.message);
clearTimeout(pending.timer); clearTimeout(pending.timer);
this.pendingChatRuns.delete(runId); this.pendingChatRuns.delete(runId);
pending.onError?.({ sessionId: pending.sessionKey, runId, error }); pending.onError?.({ sessionId: pending.sessionKey, runId, error });
......
...@@ -361,6 +361,7 @@ export interface SessionSummary { ...@@ -361,6 +361,7 @@ export interface SessionSummary {
id: string; id: string;
title: string; title: string;
updatedAt: string; updatedAt: string;
channelType?: string;
} }
export type ProjectPackageEntryType = "skill" | "workspace-entry"; export type ProjectPackageEntryType = "skill" | "workspace-entry";
...@@ -422,6 +423,7 @@ export interface ProjectIntentSuggestion { ...@@ -422,6 +423,7 @@ export interface ProjectIntentSuggestion {
export interface ProjectSessionSummary extends SessionSummary { export interface ProjectSessionSummary extends SessionSummary {
projectId: string; projectId: string;
isChannelSession?: boolean;
} }
export interface ProjectContextBoundSkill { export interface ProjectContextBoundSkill {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment