Jelajahi Sumber

fix(studio): prevent session list reorder on click

mergeSessionIds 原来把 incoming 放在 existing 前面,导致每次点击
session 触发 loadSessionDetail 时,被点的 session 被移到列表第一个。
改为只在末尾追加不存在的 id,已有的保持原序。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
fanghanjun 6 bulan lalu
induk
melakukan
e224009ae9
1 mengubah file dengan 214 tambahan dan 0 penghapusan
  1. 214 0
      packages/studio/src/store/chat/slices/message/runtime.ts

+ 214 - 0
packages/studio/src/store/chat/slices/message/runtime.ts

@@ -0,0 +1,214 @@
+import type {
+  Message,
+  MessagePart,
+  PipelineStage,
+  SessionMessage,
+  SessionRuntime,
+  SessionSummary,
+  ToolExecution,
+} from "../../types";
+
+const NULL_BOOK_KEY = "__null__";
+
+const AGENT_LABELS: Record<string, string> = {
+  architect: "建书",
+  writer: "写作",
+  auditor: "审计",
+  reviser: "修订",
+  exporter: "导出",
+};
+
+const TOOL_LABELS: Record<string, string> = {
+  read: "读取文件",
+  edit: "编辑文件",
+  grep: "搜索",
+  ls: "列目录",
+};
+
+export function bookKey(bookId: string | null | undefined): string {
+  return bookId ?? NULL_BOOK_KEY;
+}
+
+export function extractErrorMessage(error: string | { code?: string; message?: string }): string {
+  if (typeof error === "string") return error;
+  return error.message ?? "Unknown error";
+}
+
+export function resolveToolLabel(tool: string, agent?: string): string {
+  if (tool === "sub_agent" && agent) return AGENT_LABELS[agent] ?? agent;
+  return TOOL_LABELS[tool] ?? tool;
+}
+
+export function summarizeResult(result: unknown): string {
+  if (typeof result === "string") return result.slice(0, 200);
+  if (result && typeof result === "object") {
+    const record = result as Record<string, unknown>;
+    if (typeof record.content === "string") return record.content.slice(0, 200);
+  }
+  return String(result).slice(0, 200);
+}
+
+export function extractToolError(result: unknown): string {
+  if (typeof result === "string") return result.slice(0, 500);
+  if (result && typeof result === "object") {
+    const record = result as Record<string, unknown>;
+    if (typeof record.content === "string") return record.content.slice(0, 500);
+    if (record.content && Array.isArray(record.content)) {
+      const textPart = record.content.find((content: any) => content.type === "text");
+      if (textPart) return (textPart as any).text?.slice(0, 500) ?? "";
+    }
+  }
+  return String(result).slice(0, 500);
+}
+
+export function getOrCreateStream(
+  messages: ReadonlyArray<Message>,
+  streamTs: number,
+): [ReadonlyArray<Message>, Message] {
+  const last = messages[messages.length - 1];
+  if (last?.timestamp === streamTs && last.role === "assistant") {
+    return [messages, last];
+  }
+  const message: Message = { role: "assistant", content: "", timestamp: streamTs, parts: [] };
+  return [[...messages, message], message];
+}
+
+export function replaceLast(
+  messages: ReadonlyArray<Message>,
+  updated: Message,
+): ReadonlyArray<Message> {
+  return [...messages.slice(0, -1), updated];
+}
+
+export function findRunningToolPart(
+  parts: MessagePart[],
+): (MessagePart & { type: "tool" }) | undefined {
+  for (let i = parts.length - 1; i >= 0; i -= 1) {
+    const part = parts[i];
+    if (part.type === "tool" && part.execution.status === "running") {
+      return part as MessagePart & { type: "tool" };
+    }
+  }
+  return undefined;
+}
+
+export function deriveFlat(
+  parts: MessagePart[],
+): { content: string; thinking?: string; thinkingStreaming?: boolean; toolExecutions?: ToolExecution[] } {
+  let content = "";
+  let thinking = "";
+  let thinkingStreaming = false;
+  const toolExecutions: ToolExecution[] = [];
+
+  for (const part of parts) {
+    if (part.type === "thinking") {
+      if (thinking) thinking += "\n\n---\n\n";
+      thinking += part.content;
+      if (part.streaming) thinkingStreaming = true;
+      continue;
+    }
+
+    if (part.type === "text") {
+      content += part.content;
+      continue;
+    }
+
+    toolExecutions.push(part.execution);
+  }
+
+  return {
+    content,
+    ...(thinking ? { thinking } : {}),
+    ...(thinkingStreaming ? { thinkingStreaming: true } : {}),
+    ...(toolExecutions.length > 0 ? { toolExecutions } : {}),
+  };
+}
+
+export function createSessionRuntime(input: {
+  sessionId: string;
+  bookId: string | null;
+  title: string | null;
+  messages?: ReadonlyArray<Message>;
+}): SessionRuntime {
+  return {
+    sessionId: input.sessionId,
+    bookId: input.bookId,
+    title: input.title,
+    messages: input.messages ?? [],
+    stream: null,
+    isStreaming: false,
+    lastError: null,
+    pendingBookArgs: null,
+  };
+}
+
+export function deserializeMessages(
+  msgs: ReadonlyArray<SessionMessage>,
+): ReadonlyArray<Message> {
+  return msgs
+    .filter((message) => message.role === "user" || message.role === "assistant")
+    .map((message) => {
+      const toolExecutions = (message as any).toolExecutions as ToolExecution[] | undefined;
+      const parts: MessagePart[] = [];
+      if (message.thinking) parts.push({ type: "thinking", content: message.thinking, streaming: false });
+      if (toolExecutions) {
+        for (const execution of toolExecutions) {
+          parts.push({ type: "tool", execution });
+        }
+      }
+      if (message.content) parts.push({ type: "text", content: message.content });
+      return {
+        role: message.role as "user" | "assistant",
+        content: message.content,
+        thinking: message.thinking,
+        toolExecutions,
+        timestamp: message.timestamp,
+        parts: parts.length > 0 ? parts : undefined,
+      };
+    });
+}
+
+export function updateSession(
+  sessions: Record<string, SessionRuntime>,
+  sessionId: string,
+  updater: (session: SessionRuntime) => Partial<SessionRuntime>,
+): Record<string, SessionRuntime> {
+  const existing = sessions[sessionId];
+  if (!existing) return sessions;
+  return {
+    ...sessions,
+    [sessionId]: {
+      ...existing,
+      ...updater(existing),
+    },
+  };
+}
+
+export function upsertSessionSummary(
+  sessions: Record<string, SessionRuntime>,
+  summary: Pick<SessionSummary, "sessionId" | "bookId" | "title">,
+): Record<string, SessionRuntime> {
+  const existing = sessions[summary.sessionId];
+  return {
+    ...sessions,
+    [summary.sessionId]: existing
+      ? { ...existing, bookId: summary.bookId, title: summary.title }
+      : createSessionRuntime(summary),
+  };
+}
+
+export function mergeSessionIds(
+  existing: ReadonlyArray<string> | undefined,
+  incoming: ReadonlyArray<string>,
+): ReadonlyArray<string> {
+  if (!existing?.length) return [...incoming];
+  const seen = new Set(existing);
+  const appended = incoming.filter((id) => !seen.has(id));
+  if (appended.length === 0) return existing as string[];
+  return [...existing, ...appended];
+}
+
+export function sessionMatchesEvent(sessionId: string, data: unknown): boolean {
+  if (!data || typeof data !== "object") return false;
+  return (data as { sessionId?: unknown }).sessionId === sessionId;
+}