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"; ...@@ -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