mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
* feat: add QQ WebSocket channel * fix: address QQ channel review feedback * docs: trim inherited agent guidance below CI limit * fix: align QQ gateway and passive reply limits with protocol * docs: keep merged channel guidance within CI budget
154 lines
4.6 KiB
TypeScript
154 lines
4.6 KiB
TypeScript
import type { Message } from "@langchain/langgraph-sdk";
|
|
|
|
import type { AgentThread, AgentThreadContext } from "./types";
|
|
|
|
// Namespaced to match other internal metadata keys (``deerflow_sidecar``,
|
|
// ``deerflow_branch``) so it cannot collide with a future feature or a
|
|
// client-supplied key. Keep in sync with the backend thread_meta constant and
|
|
// the E2E mock-api constant.
|
|
export const THREAD_PINNED_METADATA_KEY = "deerflow_pinned";
|
|
export const THREAD_ARCHIVED_METADATA_KEY = "deerflow_archived";
|
|
|
|
export function isThreadArchived(thread: Pick<AgentThread, "metadata">) {
|
|
return thread.metadata?.[THREAD_ARCHIVED_METADATA_KEY] === true;
|
|
}
|
|
|
|
// Reserved metadata key recording a thread's project membership
|
|
// (``metadata.deerflow_project_id``). Keep in sync with the backend
|
|
// thread_meta constant and the E2E mock-api constant.
|
|
export const THREAD_PROJECT_METADATA_KEY = "deerflow_project_id";
|
|
|
|
export type ChannelThreadSource = {
|
|
type: "im_channel";
|
|
provider: string;
|
|
label: string;
|
|
};
|
|
|
|
type ThreadRouteTarget =
|
|
| string
|
|
| {
|
|
thread_id: string;
|
|
context?: Pick<AgentThreadContext, "agent_name"> | null;
|
|
metadata?: Record<string, unknown> | null;
|
|
};
|
|
|
|
/**
|
|
* The custom agent owning a thread, from its run context first and then its
|
|
* stored metadata; undefined for default-agent conversations.
|
|
*/
|
|
export function agentNameOfThread(thread: {
|
|
context?: Pick<AgentThreadContext, "agent_name"> | null;
|
|
metadata?: Record<string, unknown> | null;
|
|
}): string | undefined {
|
|
const contextAgent = thread.context?.agent_name;
|
|
if (contextAgent) {
|
|
return contextAgent;
|
|
}
|
|
const metaAgent = thread.metadata?.agent_name;
|
|
return typeof metaAgent === "string" && metaAgent ? metaAgent : undefined;
|
|
}
|
|
|
|
export function pathOfThread(
|
|
thread: ThreadRouteTarget,
|
|
context?: Pick<AgentThreadContext, "agent_name"> | null,
|
|
) {
|
|
const threadId = typeof thread === "string" ? thread : thread.thread_id;
|
|
const encodedThreadId = encodeURIComponent(threadId);
|
|
const agentName =
|
|
typeof thread === "string"
|
|
? context?.agent_name
|
|
: agentNameOfThread(thread);
|
|
|
|
return agentName
|
|
? `/workspace/agents/${encodeURIComponent(agentName)}/chats/${encodedThreadId}`
|
|
: `/workspace/chats/${encodedThreadId}`;
|
|
}
|
|
|
|
export function textOfMessage(message: Message) {
|
|
if (typeof message.content === "string") {
|
|
return message.content;
|
|
} else if (Array.isArray(message.content)) {
|
|
// Flat join ("") for single-line consumers (input box, titles); the rendered
|
|
// body uses extractContentFromMessage, which joins multi-part content with "\n".
|
|
const text = message.content
|
|
.map((part) =>
|
|
typeof part === "string" ? part : part.type === "text" ? part.text : "",
|
|
)
|
|
.join("");
|
|
return text.length > 0 ? text : null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function titleOfThread(thread: AgentThread) {
|
|
return thread.values?.title ?? "Untitled";
|
|
}
|
|
|
|
export function isThreadPinned(thread: Pick<AgentThread, "metadata">) {
|
|
return thread.metadata?.[THREAD_PINNED_METADATA_KEY] === true;
|
|
}
|
|
|
|
export function projectIdOfThread(
|
|
thread: Pick<AgentThread, "metadata">,
|
|
): string | null {
|
|
const projectId = thread.metadata?.[THREAD_PROJECT_METADATA_KEY];
|
|
return typeof projectId === "string" && projectId.length > 0
|
|
? projectId
|
|
: null;
|
|
}
|
|
|
|
export function sortPinnedThreads<T extends Pick<AgentThread, "metadata">>(
|
|
threads: readonly T[],
|
|
) {
|
|
return threads
|
|
.map((thread, index) => ({ thread, index }))
|
|
.sort((left, right) => {
|
|
const pinnedDiff =
|
|
Number(isThreadPinned(right.thread)) -
|
|
Number(isThreadPinned(left.thread));
|
|
return pinnedDiff || left.index - right.index;
|
|
})
|
|
.map(({ thread }) => thread);
|
|
}
|
|
|
|
const CHANNEL_PROVIDER_LABELS: Record<string, string> = {
|
|
buzz: "Buzz",
|
|
dingtalk: "DingTalk",
|
|
discord: "Discord",
|
|
feishu: "Feishu",
|
|
qq: "QQ",
|
|
slack: "Slack",
|
|
telegram: "Telegram",
|
|
wechat: "WeChat",
|
|
wecom: "WeCom",
|
|
};
|
|
|
|
function labelOfChannelProvider(provider: string) {
|
|
return CHANNEL_PROVIDER_LABELS[provider] ?? provider;
|
|
}
|
|
|
|
export function channelSourceOfThread(
|
|
thread: Pick<AgentThread, "metadata">,
|
|
): ChannelThreadSource | null {
|
|
const source = thread.metadata?.channel_source;
|
|
if (!source || typeof source !== "object" || Array.isArray(source)) {
|
|
return null;
|
|
}
|
|
|
|
if (Reflect.get(source, "type") !== "im_channel") {
|
|
return null;
|
|
}
|
|
|
|
const provider = Reflect.get(source, "provider");
|
|
if (typeof provider !== "string" || provider.trim().length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const normalizedProvider = provider.trim().toLowerCase();
|
|
return {
|
|
type: "im_channel",
|
|
provider: normalizedProvider,
|
|
label: labelOfChannelProvider(normalizedProvider),
|
|
};
|
|
}
|