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
This diff is collapsed.
This diff is collapsed.
......@@ -2,7 +2,7 @@ import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import type { ExpertDefinition, ExpertEntryMode, SystemSummary } from "@qjclaw/shared-types";
interface ExpertManifestRecord {
export interface ExpertManifestRecord {
id: string;
name: string;
entryMode: ExpertEntryMode;
......
......@@ -17,6 +17,8 @@ export interface ProjectModelRuntimeSecrets {
xhsFeishuAppSecret?: string;
xhsFeishuAppToken?: string;
xhsFeishuTableId?: string;
feishuMobileAppId?: string;
feishuMobileAppSecret?: string;
}
export interface ProjectModelRuntimePreparation {
......@@ -246,6 +248,8 @@ export function buildProjectModelRuntime(
const xhsFeishuAppSecret = normalizeValue(secrets.xhsFeishuAppSecret);
const xhsFeishuAppToken = normalizeValue(secrets.xhsFeishuAppToken);
const xhsFeishuTableId = normalizeValue(secrets.xhsFeishuTableId);
const feishuMobileAppId = normalizeValue(secrets.feishuMobileAppId);
const feishuMobileAppSecret = normalizeValue(secrets.feishuMobileAppSecret);
const env: Record<string, string> = {};
if (XHS_PROJECT_IDS.has(normalizedProjectId)) {
......@@ -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)) {
const writerBaseUrl = normalizeChatCompletionsBaseUrl(copywritingBaseUrl);
const seedreamBaseUrl = normalizeArkBaseUrl(imageBaseUrl);
......
......@@ -663,6 +663,19 @@ export default function App() {
setVectcutFileBaseUrlDraft(drafts.vectcutFileBaseUrl);
setVectcutApiKeyDraft(drafts.vectcutApiKey);
}, [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 [visualStartupProgress, setVisualStartupProgress] = useState(startupProgressTarget);
useEffect(() => {
......@@ -727,6 +740,10 @@ export default function App() {
() => scopedSessions.some((session) => session.id === activeSessionId) ? activeSessionId : preferredSessionId,
[activeSessionId, preferredSessionId, scopedSessions]
);
const visibleSessionIsChannel = useMemo(
() => scopedSessions.some((s) => s.id === visibleSessionId && s.isChannelSession),
[scopedSessions, visibleSessionId]
);
const {
messagesBySession,
messages,
......@@ -1498,6 +1515,7 @@ export default function App() {
skillMenuRef,
prompt,
isBound,
isChannelSession: visibleSessionIsChannel,
canSend,
isComposerDragOver,
isComposerResizeActive,
......
......@@ -18,6 +18,7 @@ interface ComposerSkill {
interface ChatComposerProps {
prompt: string
isBound: boolean
isChannelSession: boolean
sending: boolean
canSend: boolean
isDragOver: boolean
......@@ -61,6 +62,7 @@ interface ChatComposerProps {
export function ChatComposer({
prompt,
isBound,
isChannelSession,
sending,
canSend,
isDragOver,
......@@ -135,11 +137,11 @@ export function ChatComposer({
<label className="composer-field">
<textarea
value={prompt}
disabled={!isBound}
disabled={!isBound || isChannelSession}
onChange={(event) => onPromptChange(event.target.value)}
onKeyDown={(event) => void onTextareaKeyDown(event)}
placeholder={placeholder}
className="composer-textarea"
placeholder={isChannelSession ? "飞书会话请在飞书中回复" : placeholder}
className={"composer-textarea" + (isChannelSession ? " readonly" : "")}
/>
</label>
{attachments.length ? (
......@@ -156,7 +158,7 @@ export function ChatComposer({
) : null}
<div className="composer-footer">
<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}
</button>
</div>
......@@ -172,7 +174,7 @@ export function ChatComposer({
<span className="visually-hidden">{sendButtonLabel}</span>
</button>
</div>
<p className="composer-hint">按 Enter 发送,Shift + Enter 换行</p>
<p className="composer-hint">{isChannelSession ? "飞书会话仅支持查看消息,回复请在飞书中操作" : "按 Enter 发送,Shift + Enter 换行"}</p>
</div>
</form>
)
......
......@@ -101,6 +101,7 @@ interface ConversationWorkspaceComposerProps {
skillMenuRef: RefObject<HTMLDivElement | null>
prompt: string
isBound: boolean
isChannelSession: boolean
canSend: boolean
isComposerDragOver: boolean
isComposerResizeActive: boolean
......@@ -320,6 +321,7 @@ export function ConversationWorkspaceView({
<ChatComposer
prompt={composer.prompt}
isBound={composer.isBound}
isChannelSession={composer.isChannelSession}
sending={messages.sending}
canSend={composer.canSend}
isDragOver={composer.isComposerDragOver}
......
......@@ -264,6 +264,20 @@ export function useChatSessionsController(deps: UseChatSessionsControllerDeps) {
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)
setErrorText("")
try {
......
......@@ -126,6 +126,7 @@ export function useChatStreamingController(deps: UseChatStreamingControllerDeps)
const [sendPhase, setSendPhase] = useState<SendPhase>("idle")
const [streamSmoke, setStreamSmoke] = useState<SmokeStreamSnapshot | null>(null)
const activeStreamRef = useRef<ActiveStreamState | null>(null)
const emptyCompletedTimeoutRef = useRef<{ timer: ReturnType<typeof setTimeout>; requestId: string } | null>(null)
const workspaceRef = useRef(workspace)
const runtimeStatusRef = useRef(runtimeStatus)
const gatewayStatusRef = useRef(gatewayStatus)
......@@ -305,15 +306,20 @@ export function useChatStreamingController(deps: UseChatStreamingControllerDeps)
const failActiveStream = useCallback((message: string) => {
const activeStream = activeStreamRef.current
console.error("[renderer] failActiveStream called, message:", message, "hasActiveStream:", !!activeStream);
if (activeStream) {
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,
content: activeStream.renderedText || activeStream.targetText || current.content,
content: nextContent,
streamState: "error",
statusLabel: undefined,
statusDetail: undefined
}))
};
})
updateStreamSmoke((current) => current ? {
...current,
phase: "error",
......@@ -818,6 +824,25 @@ export function useChatStreamingController(deps: UseChatStreamingControllerDeps)
if (event.reply.content.length >= activeStream.targetText.length) {
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")
updateStreamSmoke((current) => current ? {
...current,
......@@ -844,8 +869,15 @@ export function useChatStreamingController(deps: UseChatStreamingControllerDeps)
}
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)
const normalizedMessage = normalizeAssistantErrorMessage(event.message, event.errorCategory)
console.error("[renderer] normalized error message:", normalizedMessage);
appendTrace(activeStream.assistantMessageId, "error", ui.replyFailed, normalizedMessage, "error")
updateStreamSmoke((current) => current ? {
...current,
......@@ -863,6 +895,11 @@ export function useChatStreamingController(deps: UseChatStreamingControllerDeps)
unsubscribe()
cancelTypewriter()
activeStreamRef.current = null
const pendingTimeout = emptyCompletedTimeoutRef.current
if (pendingTimeout) {
clearTimeout(pendingTimeout.timer)
emptyCompletedTimeoutRef.current = null
}
}
}, [cancelActiveStream, desktopApi.chat])
......
......@@ -53,7 +53,7 @@ export function SessionList({
disabled={projectActionPending}
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>
{sessions.length > 1 ? (
<button
......
......@@ -349,6 +349,19 @@
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 {
display: flex;
align-items: center;
......
......@@ -96,6 +96,7 @@ interface SessionsListResult {
sessionId?: string;
model?: string;
lastChannel?: string;
channel?: string;
}>;
}
......@@ -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 {
sessionId: string;
runId: string;
......@@ -432,7 +452,19 @@ export class GatewayClient {
return (result.sessions ?? []).map((session) => ({
id: session.key ?? session.sessionId ?? randomUUID(),
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 {
if (runId) {
this.emitChatDelta(runId, payload);
if (state === "final") {
const pending = this.pendingChatRuns.get(runId);
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);
}
}
......@@ -582,6 +621,7 @@ export class GatewayClient {
const stream = this.findStringDeep(payload, ["stream"]);
if (stream === "error") {
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);
if (this.isRecoverableAgentStreamError(message)) {
return;
......@@ -870,9 +910,11 @@ export class GatewayClient {
private failChatRun(runId: string, error: Error): void {
const pending = this.pendingChatRuns.get(runId);
if (!pending) {
console.error("[gateway-client] failChatRun: no pending run for", runId, "error:", error.message);
return;
}
console.error("[gateway-client] failChatRun: failing run", runId, "error:", error.message);
clearTimeout(pending.timer);
this.pendingChatRuns.delete(runId);
pending.onError?.({ sessionId: pending.sessionKey, runId, error });
......
......@@ -361,6 +361,7 @@ export interface SessionSummary {
id: string;
title: string;
updatedAt: string;
channelType?: string;
}
export type ProjectPackageEntryType = "skill" | "workspace-entry";
......@@ -422,6 +423,7 @@ export interface ProjectIntentSuggestion {
export interface ProjectSessionSummary extends SessionSummary {
projectId: string;
isChannelSession?: boolean;
}
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