代码提交

This commit is contained in:
董宏凯 committed 2026-08-16 19:17:45 +08:00
1 parent 70da7c5649
commit 178572bc7f
79 files changed
+13456 -798

No files matched your search

@@ -0,0 +1,68 @@
export type StreamingArtifactLanguage = "markdown" | "html" | "mermaid";
export function normalizeStreamingArtifactPath(path: string): string {
let normalized = path.trim();
const first = normalized[0];
const last = normalized.at(-1);
if (
normalized.length >= 2 &&
((first === '"' && last === '"') ||
(first === "'" && last === "'") ||
(first === "`" && last === "`"))
) {
normalized = normalized.slice(1, -1).trim();
}
return normalized;
}
export function hasCompleteArtifactExtension(path: string): boolean {
const pathname = normalizeStreamingArtifactPath(path).split(/[?#]/, 1)[0] ?? "";
const filename = pathname.split("/").filter(Boolean).at(-1) ?? "";
// JSON tool arguments commonly stream `report.m` before `report.md`.
// Treating the partial suffix as final makes the sandbox permanently TEXT.
return /\.[a-z0-9]{2,12}$/i.test(filename);
}
export function provisionalWriteFilePath(content: string): string {
const head = content.slice(0, 256).trimStart().toLowerCase();
if (head.startsWith("<!doctype html") || head.startsWith("<html")) {
return "未命名文件.html";
}
return "未命名文件.md";
}
export function languageOfStreamingArtifact(
path: string,
): StreamingArtifactLanguage | null {
const pathname = normalizeStreamingArtifactPath(path)
.split(/[?#]/, 1)[0]
?.toLowerCase();
if (pathname?.endsWith(".md") || pathname?.endsWith(".markdown")) {
return "markdown";
}
if (pathname?.endsWith(".html") || pathname?.endsWith(".htm")) {
return "html";
}
if (pathname?.endsWith(".mmd") || pathname?.endsWith(".mermaid")) {
return "mermaid";
}
return null;
}
export function buildWriteFileVirtualUrl({
path,
messageId,
toolCallId,
}: {
path: string;
messageId: string;
toolCallId: string;
}): string {
const language = languageOfStreamingArtifact(path);
const languageQuery = language ? `&language=${language}` : "";
return new URL(
`write-file:${path}?message_id=${encodeURIComponent(
messageId,
)}&tool_call_id=${encodeURIComponent(toolCallId)}${languageQuery}`,
).toString();
}
@@ -0,0 +1,166 @@
"use client";
import { createContext, useCallback, useContext, useMemo, useState } from "react";
import type {
DocumentRewriteStage,
} from "@/open-canvas/hooks/useDocumentRewrite";
import type { DocumentRewriteSummary } from "@/open-canvas/api/rewrite";
export type DocumentRewritePresentation = {
id: string;
threadId: string;
source: {
kind: "artifact" | "deep_research_report";
id: string;
path?: string;
};
fileName: string;
instruction: string;
style: string;
modelName: string | null;
stage: DocumentRewriteStage;
originalCharCount: number;
writtenCharCount: number;
/** Short model-generated plan, streamed while it understands the request. */
requirementsPlan: string;
warnings: string[];
summary: DocumentRewriteSummary | null;
/** Kept in-memory for the line-level compare dialog; backend job hydration refills it. */
originalContent: string;
rewrittenContent: string | null;
versionId: string | null;
/** Inverse snapshot created by a safe undo; enables a one-click reapply. */
redoVersionId: string | null;
revertedAt: string | null;
error: string | null;
createdAt: string;
updatedAt: string;
};
type CreatePresentationInput = Omit<
DocumentRewritePresentation,
| "id"
| "stage"
| "originalCharCount"
| "writtenCharCount"
| "requirementsPlan"
| "warnings"
| "summary"
| "originalContent"
| "rewrittenContent"
| "versionId"
| "redoVersionId"
| "revertedAt"
| "error"
| "createdAt"
| "updatedAt"
> & {
originalContent?: string;
rewrittenContent?: string | null;
requirementsPlan?: string;
};
type DocumentRewritePresentationContextValue = {
create: (input: CreatePresentationInput) => string;
update: (
id: string,
patch: Partial<Omit<DocumentRewritePresentation, "id" | "threadId" | "createdAt">>,
) => void;
forThread: (threadId: string) => DocumentRewritePresentation[];
};
const DocumentRewritePresentationContext =
createContext<DocumentRewritePresentationContextValue | null>(null);
function createId() {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return "drw-ui-" + crypto.randomUUID();
}
return "drw-ui-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2);
}
export function DocumentRewritePresentationProvider({
children,
}: {
children: React.ReactNode;
}) {
const [presentations, setPresentations] = useState<DocumentRewritePresentation[]>([]);
const create = useCallback((input: CreatePresentationInput) => {
const id = createId();
const now = new Date().toISOString();
setPresentations((previous) => [
...previous.slice(-29),
{
...input,
id,
stage: "snapshot",
originalCharCount: 0,
writtenCharCount: 0,
requirementsPlan: input.requirementsPlan ?? "",
warnings: [],
summary: null,
originalContent: input.originalContent ?? "",
rewrittenContent: input.rewrittenContent ?? null,
versionId: null,
redoVersionId: null,
revertedAt: null,
error: null,
createdAt: now,
updatedAt: now,
},
]);
return id;
}, []);
const update = useCallback(
(
id: string,
patch: Partial<Omit<DocumentRewritePresentation, "id" | "threadId" | "createdAt">>,
) => {
setPresentations((previous) => {
const index = previous.findIndex((item) => item.id === id);
if (index < 0) return previous;
const current = previous[index];
const changed = Object.entries(patch).some(
([key, value]) => current[key as keyof DocumentRewritePresentation] !== value,
);
// Do not create a new context value for an identical update. Stream
// subscribers can legitimately receive an already-hydrated snapshot;
// treating it as a state change makes effect-based synchronisation
// vulnerable to update loops.
if (!changed) return previous;
const next = [...previous];
next[index] = { ...current, ...patch, updatedAt: new Date().toISOString() };
return next;
});
},
[],
);
const value = useMemo<DocumentRewritePresentationContextValue>(
() => ({
create,
update,
forThread: (threadId) =>
presentations.filter((item) => item.threadId === threadId),
}),
[create, presentations, update],
);
return (
<DocumentRewritePresentationContext.Provider value={value}>
{children}
</DocumentRewritePresentationContext.Provider>
);
}
export function useDocumentRewritePresentation() {
return useContext(DocumentRewritePresentationContext);
}
/** Browser event consumed by the open sandbox after an in-feed undo succeeds. */
export const DOCUMENT_REWRITE_UNDONE_EVENT = "deerflow:document-rewrite-undone";
+25 -1
View File
@@ -22,6 +22,8 @@ interface AssistantSetupAgentApprovalGroup extends GenericMessageGroup<"assistan
interface AssistantSetupWritingApprovalGroup extends GenericMessageGroup<"assistant:setup-writing-approval"> {}
interface AssistantResearchApprovalGroup extends GenericMessageGroup<"assistant:research-approval"> {}
interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {}
type MessageGroup =
@@ -32,6 +34,7 @@ type MessageGroup =
| AssistantClarificationGroup
| AssistantSetupAgentApprovalGroup
| AssistantSetupWritingApprovalGroup
| AssistantResearchApprovalGroup
| AssistantSubagentGroup;
export function groupMessages<T>(
@@ -54,7 +57,8 @@ export function groupMessages<T>(
last.type !== "assistant" &&
last.type !== "assistant:clarification" &&
last.type !== "assistant:setup-agent-approval" &&
last.type !== "assistant:setup-writing-approval"
last.type !== "assistant:setup-writing-approval" &&
last.type !== "assistant:research-approval"
) {
return last;
}
@@ -114,6 +118,18 @@ export function groupMessages<T>(
}
if (message.type === "ai") {
// Deep-research interaction card: virtual ai messages injected by the
// workbench (additional_kwargs.setup_research_approval) render through
// the host-provided slot. Writing phases use ordinary virtual tool calls
// and completed summaries remain normal assistant messages.
if (hasResearchApproval(message)) {
groups.push({
id: message.id,
type: "assistant:research-approval",
messages: [message],
});
continue;
}
if (hasPresentFiles(message)) {
groups.push({
id: message.id,
@@ -386,6 +402,14 @@ export function isSetupWritingApprovalToolMessage(message: Message) {
);
}
export function hasResearchApproval(message: Message) {
return (
message.type === "ai" &&
typeof message.additional_kwargs?.setup_research_approval === "object" &&
message.additional_kwargs.setup_research_approval !== null
);
}
export function extractPresentFilesFromMessage(message: Message) {
if (message.type !== "ai" || !hasPresentFiles(message)) {
return [];
@@ -0,0 +1,192 @@
import type { Message } from "@langchain/langgraph-sdk";
import {
buildSentimentAgentHeaders,
readSentimentAgentEndpoint,
} from "./config";
/**
* 舆情分析智能体走的是外部 AG-UI 协议网关(POST,SSE 返回):
* 请求体为 { threadId, runId, state, tools, context, forwardedProps, messages },
* 返回为 `data: {"type": ...}` 的 SSE 事件流:
* RUN_STARTED / THINKING_TEXT_MESSAGE_START|CONTENT|END /
* TEXT_MESSAGE_START|CONTENT|END / RUN_FINISHED(另有 RUN_ERROR 等异常事件)。
*/
/** AG-UI 协议消息(请求体里的 messages 数组元素)。 */
export interface AgUiMessage {
role: "user" | "assistant";
id: string;
content: string;
}
export interface SentimentAgentRunHandlers {
/** 推理(思考)文本开始:reasoning 消息流开始。 */
onReasoningStart?: (messageId: string) => void;
onReasoningDelta?: (messageId: string, delta: string) => void;
/** 正文回答开始:answer 消息流开始。 */
onAnswerStart?: (messageId: string) => void;
onAnswerDelta?: (messageId: string, delta: string) => void;
}
function textOfMessage(message: Message): string {
if (typeof message.content === "string") return message.content;
if (!Array.isArray(message.content)) return "";
return message.content
.map((part) => {
if (typeof part === "string") return part;
if (!part || typeof part !== "object") return "";
const text = (part as { text?: unknown }).text;
return typeof text === "string" ? text : "";
})
.join("");
}
/** 把本地会话消息转成 AG-UI 请求的历史消息(过滤空内容)。 */
export function toAgUiMessages(messages: Message[]): AgUiMessage[] {
return messages
.filter((message) => message.type === "human" || message.type === "ai")
.map((message) => ({
role: message.type === "human" ? ("user" as const) : ("assistant" as const),
id: message.id ?? "",
content: textOfMessage(message),
}))
.filter((message) => message.content.trim().length > 0);
}
type SseEvent = {
event?: string;
data: string;
};
/** 按 SSE 规范切帧:事件以空行分隔,`data:` 行可能跨多行、帧可能跨 chunk。 */
function parseSseFrames(buffer: string): { frames: SseEvent[]; rest: string } {
const normalized = buffer.replace(/\r\n/g, "\n");
const parts = normalized.split("\n\n");
const rest = parts.pop() ?? "";
const frames: SseEvent[] = [];
for (const part of parts) {
if (!part.trim()) continue;
const dataLines: string[] = [];
let eventName: string | undefined;
for (const line of part.split("\n")) {
if (line.startsWith("data:")) {
dataLines.push(line.slice(5).replace(/^ /, ""));
} else if (line.startsWith("event:")) {
eventName = line.slice(6).replace(/^ /, "");
}
}
if (dataLines.length > 0) {
frames.push({ event: eventName, data: dataLines.join("\n") });
}
}
return { frames, rest };
}
export interface RunSentimentAgentOptions {
threadId: string;
runId: string;
messages: AgUiMessage[];
signal: AbortSignal;
handlers: SentimentAgentRunHandlers;
}
/**
* 发起一次舆情分析流式问答。resolve 于流正常结束(RUN_FINISHED 或连接关闭);
* 网络错误 / 非 2xx / RUN_ERROR 事件都会 reject(AbortError 由调用方自行忽略)。
*/
export async function runSentimentAgent(
options: RunSentimentAgentOptions,
): Promise<void> {
const { threadId, runId, messages, signal, handlers } = options;
const { apiUrl } = readSentimentAgentEndpoint();
if (!apiUrl) {
throw new Error("未配置舆情分析接口地址(runtime-config.js 的 VITE_SENTIMENT_AGENT_API_URL)。");
}
const response = await fetch(apiUrl, {
method: "POST",
headers: buildSentimentAgentHeaders(),
signal,
body: JSON.stringify({
threadId,
runId,
state: {},
tools: [],
context: [],
forwardedProps: {},
messages,
}),
});
if (!response.ok) {
throw new Error(`舆情分析服务请求失败(HTTP ${response.status})。`);
}
if (!response.body) {
throw new Error("舆情分析服务未返回流式响应。");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
const dispatch = (payload: Record<string, unknown>) => {
// 样例里思考事件使用 message_id,而正文事件使用 messageId;两种 AG-UI
// 字段形式都兼容,避免后端升级/不同事件类型造成流式状态断开。
const messageId = String(payload.message_id ?? payload.messageId ?? "");
const delta = typeof payload.delta === "string" ? payload.delta : "";
switch (payload.type) {
case "THINKING_TEXT_MESSAGE_START":
handlers.onReasoningStart?.(messageId);
break;
case "THINKING_TEXT_MESSAGE_CONTENT":
handlers.onReasoningDelta?.(messageId, delta);
break;
case "TEXT_MESSAGE_START":
handlers.onAnswerStart?.(messageId);
break;
case "TEXT_MESSAGE_CONTENT":
handlers.onAnswerDelta?.(messageId, delta);
break;
case "RUN_ERROR": {
const message =
typeof payload.message === "string" && payload.message
? payload.message
: "舆情分析服务返回错误。";
throw new Error(message);
}
default:
// RUN_STARTED / *_END / RUN_FINISHED / TOOL_* 等事件不需要驱动 UI。
break;
}
};
const consumeFrames = (frames: SseEvent[]) => {
for (const frame of frames) {
let payload: Record<string, unknown>;
try {
payload = JSON.parse(frame.data) as Record<string, unknown>;
} catch {
continue; // 解析不了的脏帧直接跳过,不断流。
}
// RUN_ERROR 会在 dispatch 里抛出业务错误,正常往外传播。
dispatch(payload);
}
};
while (true) {
const { done, value } = await reader.read();
if (done) {
// 某些网关会在最后一帧后直接关闭连接而不补 SSE 的空行分隔符。流已
// 结束时剩余缓冲区就是完整帧,补上分隔符后仍要消费,避免丢最后几个字。
buffer += decoder.decode();
if (buffer.trim()) {
const { frames } = parseSseFrames(`${buffer}\n\n`);
consumeFrames(frames);
}
break;
}
buffer += decoder.decode(value, { stream: true });
const { frames, rest } = parseSseFrames(buffer);
buffer = rest;
consumeFrames(frames);
}
}
@@ -0,0 +1,59 @@
import { readRuntimeEnv } from "@/env";
/**
* 舆情分析智能体(前端直连的虚拟智能体)的接入配置。
* 接口地址与 Basic Auth 凭据都来自 public/runtime-config.js,部署后可直接改
* dist/runtime-config.js 切换环境,无需重新打包。
*/
/** 该虚拟智能体在路由里的固定 id(/agents/{id}/chats/...)。 */
export const SENTIMENT_AGENT_ID = "sentiment-analysis";
export interface SentimentAgentEndpoint {
apiUrl: string;
authUsername: string;
authPassword: string;
}
export function readSentimentAgentEndpoint(): SentimentAgentEndpoint {
return {
apiUrl: readRuntimeEnv("VITE_SENTIMENT_AGENT_API_URL") ?? "",
authUsername: readRuntimeEnv("VITE_SENTIMENT_AGENT_AUTH_USERNAME") ?? "",
authPassword: readRuntimeEnv("VITE_SENTIMENT_AGENT_AUTH_PASSWORD") ?? "",
};
}
export function isSentimentAgentConfigured(): boolean {
return !!readSentimentAgentEndpoint().apiUrl;
}
/**
* 是否开放舆情分析虚拟智能体。未配置时保持展示,避免旧部署升级后入口意外消失;
* 只有运行时配置显式写为 "false" 才关闭。
*/
export function isSentimentAgentVisible(): boolean {
return readRuntimeEnv("VITE_SENTIMENT_AGENT_VISIBLE") !== "false";
}
function toBase64(value: string): string {
// 凭据按 UTF-8 字节做 Basic Auth base64,避免非 ASCII 字符导致 btoa 抛错。
const bytes = new TextEncoder().encode(value);
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary);
}
/** 组装请求头:Basic Auth(用户名/密码都配置了才带 Authorization)。 */
export function buildSentimentAgentHeaders(): Record<string, string> {
const { authUsername, authPassword } = readSentimentAgentEndpoint();
const headers: Record<string, string> = {
"Content-Type": "application/json",
Accept: "text/event-stream",
};
if (authUsername && authPassword) {
headers.Authorization = `Basic ${toBase64(`${authUsername}:${authPassword}`)}`;
}
return headers;
}
@@ -0,0 +1,55 @@
/**
* 舆情分析智能体的展示信息(名称 / 简介)。
* 这是一个前端虚拟智能体(不落库后端 /api/agents),名称与简介保存在
* localStorage,可在智能体管理页的编辑弹窗里随时修改。
*/
export interface SentimentAgentProfile {
name: string;
description: string;
}
const STORAGE_KEY = "deerflow.sentiment-agent.profile";
/** 名称 / 简介 未自定义时的默认文案。 */
export const DEFAULT_SENTIMENT_AGENT_PROFILE: SentimentAgentProfile = {
name: "舆情分析",
description:
"面向舆情领域的态势问答助手:围绕指定话题或事件,自动梳理舆情脉络、研判发展态势、归纳各方观点与热点走向,输出结构化的分析结论,为舆情应对与决策提供参考。",
};
export function readSentimentAgentProfile(): SentimentAgentProfile {
if (typeof window === "undefined") return { ...DEFAULT_SENTIMENT_AGENT_PROFILE };
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return { ...DEFAULT_SENTIMENT_AGENT_PROFILE };
const parsed: unknown = JSON.parse(raw);
if (!parsed || typeof parsed !== "object") {
return { ...DEFAULT_SENTIMENT_AGENT_PROFILE };
}
const record = parsed as Record<string, unknown>;
const name =
typeof record.name === "string" && record.name.trim()
? record.name.trim()
: DEFAULT_SENTIMENT_AGENT_PROFILE.name;
const description =
typeof record.description === "string"
? record.description.trim()
: DEFAULT_SENTIMENT_AGENT_PROFILE.description;
return { name, description };
} catch {
return { ...DEFAULT_SENTIMENT_AGENT_PROFILE };
}
}
export function saveSentimentAgentProfile(profile: SentimentAgentProfile): void {
if (typeof window === "undefined") return;
const name = profile.name.trim() || DEFAULT_SENTIMENT_AGENT_PROFILE.name;
const description =
profile.description.trim() || DEFAULT_SENTIMENT_AGENT_PROFILE.description;
window.localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ name, description } satisfies SentimentAgentProfile),
);
// 编辑弹窗可能挂在管理页 / 对话页两处,广播一下让同页签的其他挂载点即时刷新。
window.dispatchEvent(new CustomEvent("sentiment-agent-profile-changed"));
}
@@ -0,0 +1,187 @@
import type { Message } from "@langchain/langgraph-sdk";
import { useCallback, useEffect, useRef, useState } from "react";
import { runSentimentAgent, toAgUiMessages } from "./api";
const STORAGE_PREFIX = "workspace.sentiment-agent.";
/**
* 舆情分析智能体的前端会话状态:消息只存在当前标签页的 sessionStorage,
* 不创建后端 thread、不进 LangGraph —— 所有问答都直连外部 AG-UI 网关。
* 整体结构参照 use-single-round-rag 的「合成 LangGraph 消息 + 本地持久化」模式。
*/
export function useSentimentAgentChat(threadId: string) {
const [messages, setMessages] = useState<Message[]>(() =>
readStoredMessages(threadId),
);
const messagesRef = useRef(messages);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const controllerRef = useRef<AbortController | null>(null);
useEffect(() => {
const stored = readStoredMessages(threadId);
messagesRef.current = stored;
setMessages(stored);
setError(null);
}, [threadId]);
useEffect(() => {
messagesRef.current = messages;
}, [messages]);
// 卸载 / 切会话时中断进行中的流。
useEffect(() => () => controllerRef.current?.abort(), []);
const stop = useCallback(() => controllerRef.current?.abort(), []);
const commitMessages = useCallback(
(updater: (current: Message[]) => Message[]) => {
setMessages((current) => {
const next = updater(current);
messagesRef.current = next;
writeStoredMessages(threadId, next);
return next;
});
},
[threadId],
);
const submit = useCallback(
async ({ query }: { query: string }) => {
if (isLoading) return;
const question = query.trim();
if (!question) return;
const assistantId = `sentiment-ai-${crypto.randomUUID()}`;
const userMessage: Message = {
id: `sentiment-human-${crypto.randomUUID()}`,
type: "human",
content: [{ type: "text", text: question }],
additional_kwargs: {
client_ts: new Date().toISOString(),
sentiment_agent: true,
},
};
const initialAssistant: Message = {
id: assistantId,
type: "ai",
content: "",
additional_kwargs: { sentiment_agent: true },
};
// AG-UI 历史 = 之前已完成的轮次 + 本轮提问。
const history = [
...toAgUiMessages(messagesRef.current),
{ role: "user" as const, id: userMessage.id!, content: question },
];
commitMessages((current) => [...current, userMessage, initialAssistant]);
setError(null);
setIsLoading(true);
const controller = new AbortController();
controllerRef.current = controller;
// 思考 / 正文两个缓冲区 + rAF 合帧刷新:SSE 一轮几千个 delta,
// 直接逐条 setState 会造成大量无效渲染。
let reasoning = "";
let answer = "";
let flushScheduled = false;
const flush = () => {
flushScheduled = false;
commitMessages((current) =>
current.map((message) =>
message.id === assistantId
? ({
...message,
content: answer,
additional_kwargs: {
...message.additional_kwargs,
sentiment_agent: true,
reasoning_content: reasoning || null,
},
} as Message)
: message,
),
);
};
const scheduleFlush = () => {
if (flushScheduled) return;
flushScheduled = true;
requestAnimationFrame(flush);
};
try {
await runSentimentAgent({
threadId,
runId: `run-${crypto.randomUUID()}`,
messages: history,
signal: controller.signal,
handlers: {
onReasoningDelta: (_messageId, delta) => {
reasoning += delta;
scheduleFlush();
},
onAnswerDelta: (_messageId, delta) => {
answer += delta;
scheduleFlush();
},
},
});
flush();
} catch (caught) {
flush();
if (!isAbortError(caught)) {
const message =
caught instanceof Error ? caught.message : "舆情分析问答失败。";
setError(message);
// 一个字都没回答出来时,把错误写进气泡,用户能看到失败原因。
if (!answer) {
commitMessages((current) =>
current.map((item) =>
item.id === assistantId
? ({ ...item, content: message } as Message)
: item,
),
);
}
}
} finally {
if (controllerRef.current === controller) controllerRef.current = null;
setIsLoading(false);
}
},
[commitMessages, isLoading, threadId],
);
return { messages, isLoading, error, submit, stop };
}
function storageKey(threadId: string) {
return `${STORAGE_PREFIX}${threadId}`;
}
function readStoredMessages(threadId: string): Message[] {
if (typeof window === "undefined" || !threadId) return [];
try {
const raw = window.sessionStorage.getItem(storageKey(threadId));
const parsed: unknown = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed) ? (parsed as Message[]) : [];
} catch {
return [];
}
}
function writeStoredMessages(threadId: string, messages: Message[]) {
if (typeof window === "undefined" || !threadId) return;
try {
window.sessionStorage.setItem(storageKey(threadId), JSON.stringify(messages));
} catch {
// 配额异常不能打断进行中的回答。
}
}
function isAbortError(error: unknown): boolean {
return error instanceof DOMException && error.name === "AbortError";
}