Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions docs/chat-export.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# 对话导出

对话右上角菜单新增“导出对话”。可选择 Markdown 或文本格式,下载文件或复制到剪贴板。中英文界面均可使用;附件保留文件名,待处理和撤回消息保留状态。

导出逐页读取整个对话,并通过现有公开消息接口取回完整正文,不受当前聊天窗口页数或正文预览长度影响。只有全部读取成功才允许复制或下载;读取失败可重试,关闭窗口会取消读取。系统提示、工具内部事件与尚未保存的流式文本不进入导出。公开 commentary 和分支继承回复的完整正文沿用公开投影与脱敏检查。

Markdown 和文本导出保留原始正文的首尾换行、空格、制表符与代码缩进。相邻助手消息仍按聊天投影合并,重复结束回复不额外导出;各段有效正文之间保留两个换行作为分隔。聊天页面的显示格式不变。

## 真实页面验证

2026-10-22,Windows Edge,生产前端与生产 API/SQLite。基线为 `10311148`;修改前后的截图使用同一份已保存的对话。通过 `cmd/workbenchjourney` 的离线模型提供者实际读取 README,再由界面导出;对话、消息读取、剪贴板和下载都使用真实执行路径。

| 修改前:对话菜单 | 修改后:对话菜单 |
| --- | --- |
| ![修改前菜单](images/chat-export/menu-before.png) | ![修改后菜单](images/chat-export/menu-after.png) |

1280 × 900 导出窗口:

![导出窗口](images/chat-export/export-after.png)

390 × 844 窄窗口:

![窄窗口导出](images/chat-export/export-narrow-after.png)

浏览器实际下载了 `.md` 和 `.txt`,两份文件与系统剪贴板均包含原始用户请求及 `Read README.md: JOURNEY_FILE_PROOF_2026` 助手回复。以上为生产网页验证;未作为原生打包窗口验证。

## 自动检查

- `npm test -- src/v2/thread-export.test.ts src/v2/components/thread-export.test.tsx src/v2/components/conversation.test.tsx`:65 个测试通过,覆盖历史分页、多 run 顺序、长正文、公开消息过滤、失败禁止部分导出、重试、取消、剪贴板错误和切换聊天关闭窗口。
- `go test ./internal/store -run 'TestSavedPublicCommentary|TestThreadSearch' -count=1`:通过,覆盖公开 commentary、分支继承正文、跨聊天隔离、脱敏及现有搜索行为。
- `go test ./internal/httpapi -run 'TestSavedMessageSearch' -count=1`:通过。
- `npm run build`:通过。格式控件样式与英文单数文案调整后,导出组件测试及生产构建再次通过。
Binary file added docs/images/chat-export/export-after.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/chat-export/menu-after.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/chat-export/menu-before.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
23 changes: 23 additions & 0 deletions internal/store/thread_search.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ package store

import (
"context"
"database/sql"
"errors"
"regexp"
"strings"
"time"

"cyberagent-workbench/internal/apperror"
"cyberagent-workbench/internal/domain"
"cyberagent-workbench/internal/events"
"cyberagent-workbench/internal/redact"
"cyberagent-workbench/internal/threadtranscript"
)
Expand Down Expand Up @@ -178,6 +180,27 @@ func (s *SQLiteStore) GetThreadSearchMessage(ctx context.Context, threadID, mess
if err != nil {
return domain.ThreadSearchMatch{}, err
}
// Public progress and replies inherited by a branch are commentary events,
// not session messages. Resolve their full body through the same public
// projection and redaction below without broadening search candidates.
if len(items) == 0 {
item := domain.ThreadSearchMatch{ID: messageID, MessageID: messageID, ThreadID: threadID, Role: "assistant"}
var created string
err := s.db.QueryRowContext(ctx, `SELECT thread.title, thread.status, thread.workspace_id,
COALESCE(json_extract(event.payload_json,'$.text'),''), event.created_at, binding.ordinal, event.sequence
FROM threads thread JOIN thread_runs binding ON binding.thread_id=thread.id
JOIN run_events event ON event.run_id=binding.run_id
WHERE thread.id=? AND thread.status IN ('active','archived') AND event.event_id=? AND event.type=?`,
threadID, messageID, events.ModelPublicCommentaryEvent).Scan(&item.ThreadTitle, &item.ThreadStatus,
&item.WorkspaceID, &item.Content, &created, &item.RunOrdinal, &item.Sequence)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return domain.ThreadSearchMatch{}, err
}
if err == nil {
item.CreatedAt = parseTS(created)
items = append(items, item)
}
}
if len(items) > 0 {
item := items[0]
visible, err := s.publicSearchMessage(ctx, item)
Expand Down
54 changes: 54 additions & 0 deletions internal/store/thread_search_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package store

import (
"encoding/json"
"path/filepath"
"strconv"
"strings"
Expand All @@ -9,9 +10,62 @@ import (

"cyberagent-workbench/internal/application"
"cyberagent-workbench/internal/domain"
"cyberagent-workbench/internal/events"
"cyberagent-workbench/internal/session"
)

func TestSavedPublicCommentaryRetainsFullBodyAndVisibility(t *testing.T) {
st, err := Open(filepath.Join(t.TempDir(), "public-commentary.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = st.Close() })
_, run, err := application.NewRunService(st).Create(t.Context(), application.CreateRunRequest{
Goal: "public commentary export", Profile: "review", Interactive: true, Budget: domain.Budget{MaxTurns: 8}})
if err != nil {
t.Fatal(err)
}
threadID := domain.InitialThreadID(run.ID)
full := strings.Repeat("完整公开说明。", 1000) + " commentary needle"
for _, source := range []string{"model", "thread_branch"} {
message, err := st.SaveSessionMessage(t.Context(), session.NewMessage(run.SessionID, "assistant", "placeholder"))
if err != nil {
t.Fatal(err)
}
var eventID string
if err := st.db.QueryRow(`SELECT event_id FROM run_events WHERE run_id=? AND type='session.message_created' AND subject_id=?`,
run.ID, strconv.FormatInt(message.ID, 10)).Scan(&eventID); err != nil {
t.Fatal(err)
}
payload, _ := json.Marshal(map[string]string{"text": full, "source_event_id": "original-reply"})
if _, err := st.db.Exec(`UPDATE run_events SET type=?,source=?,payload_json=? WHERE event_id=?`,
events.ModelPublicCommentaryEvent, source, string(payload), eventID); err != nil {
t.Fatal(err)
}
got, err := st.GetThreadSearchMessage(t.Context(), threadID, eventID)
if err != nil || got.Content != full || got.Role != "assistant" {
t.Fatalf("full public reply: %+v %v", got, err)
}
if _, err := st.GetThreadSearchMessage(t.Context(), threadID+"-other", eventID); err == nil {
t.Fatal("cross-thread commentary was readable")
}
payload, _ = json.Marshal(map[string]string{"text": "api_key=sk-CommentarySecret12345678901234567890"})
if _, err := st.db.Exec(`UPDATE run_events SET payload_json=? WHERE event_id=?`, string(payload), eventID); err != nil {
t.Fatal(err)
}
got, err = st.GetThreadSearchMessage(t.Context(), threadID, eventID)
if err != nil || strings.Contains(got.Content, "CommentarySecret") {
t.Fatalf("commentary redaction: %+v %v", got, err)
}
if _, err := st.db.Exec(`UPDATE run_events SET type='model.started' WHERE event_id=?`, eventID); err != nil {
t.Fatal(err)
}
if _, err := st.GetThreadSearchMessage(t.Context(), threadID, eventID); err == nil {
t.Fatal("internal event was readable as a public message")
}
}
}

func TestThreadSearchRedactsHistoricalCandidatesBeforeMatchingAndSurvivesRestart(t *testing.T) {
path := filepath.Join(t.TempDir(), "saved-search.db")
st, err := Open(path)
Expand Down
14 changes: 14 additions & 0 deletions web/src/v2/components/conversation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ vi.mock("../../hooks/use-public-model-stream", () => ({
}));

vi.mock("../projection/narrative", () => ({
projectThreadNarrative: () => [],
prepareThreadNarrative: (items: ThreadTranscriptItemView[]) => ({ entries: items.map((item) => ({
id: item.id,
kind: item.source === "operator" ? "user" : "assistant",
Expand Down Expand Up @@ -216,6 +217,19 @@ function queueSnapshot(runID = "run-thread-a", sessionID = "sess-thread-a") {
}

describe("V2Conversation", () => {
it("opens export from the conversation menu with read access and closes it when changing chats", async () => {
const ui = renderConversation(baseClient({ hasThreadControl: false }));
const trigger = await screen.findByRole("button", { name: "对话操作" });
fireEvent.click(trigger);
fireEvent.click(screen.getByRole("menuitem", { name: "导出对话" }));
expect(await screen.findByRole("dialog", { name: "导出对话" })).toBeVisible();
ui.rerenderThread("thread-b");
await waitFor(() => expect(screen.queryByRole("dialog", { name: "导出对话" })).not.toBeInTheDocument());
ui.rerenderThread("thread-a");
await screen.findByText("Title thread-a");
expect(screen.queryByRole("dialog", { name: "导出对话" })).not.toBeInTheDocument();
});

it("loads and focuses the complete saved message window in an archived chat without submitting or restoring it", async () => {
const content = "An old full answer beyond the transcript preview.";
const getPage = vi.fn((_path: string, _query: unknown, cursor = "") => Promise.resolve(cursor
Expand Down
9 changes: 8 additions & 1 deletion web/src/v2/components/conversation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { localUIMessage } from "../../lib/local-ui-message";
import { lazy, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type RefObject } from "react";
import { createPortal } from "react-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Archive, BookOpen, ChevronDown, CircleEllipsis, FileDiff, Folder, FolderTree, LoaderCircle, MessagesSquare, Microscope, RefreshCw, ShieldCheck, PanelTop, Square, SquareTerminal, X } from "lucide-react";
import { Archive, BookOpen, ChevronDown, CircleEllipsis, Download, FileDiff, Folder, FolderTree, LoaderCircle, MessagesSquare, Microscope, RefreshCw, ShieldCheck, PanelTop, Square, SquareTerminal, X } from "lucide-react";
import { APIRequestError, type APIClient } from "../../api/client";
import { useLocale } from "../../lib/locale";
import type { ThreadDetailView, ThreadView, WorkspaceView } from "../../api/types";
Expand Down Expand Up @@ -56,6 +56,7 @@ import { UserTerminalPanel } from "../../components/user-terminal-panel";
import { desktopUserTerminalEnabled, closeDesktopUserTerminal } from "../../lib/desktop-bridge";
import { useModalFocusTrap } from "../../hooks/use-modal-focus-trap";
import { V2IdentityDisclosure } from "./identity-disclosure";
import { V2ThreadExport } from "./thread-export";

export { parseProjectFileLink, type FileLinkTarget } from "./narrative";

Expand Down Expand Up @@ -378,6 +379,8 @@ export function V2Conversation({ client, threadID, workspaces, onArchive, onMana
const [checking, setChecking] = useState<string[]>([]);
const [observations, setObservations] = useState<Record<string, string>>({});
const [menuOpen, setMenuOpen] = useState(false);
const [exportThreadID, setExportThreadID] = useState<string | null>(null);
useEffect(() => setExportThreadID(null), [threadID]);
const [activePane, setSelectedPane] = useState<"review" | "preview" | "context" | "files" | "terminal" | null>(null);
const [roomForDock, setRoomForDock] = useState(false);
const [compactPaneOpen, setCompactPaneOpen] = useState(false);
Expand Down Expand Up @@ -983,6 +986,8 @@ export function V2Conversation({ client, threadID, workspaces, onArchive, onMana
onArchive(detail.thread);
}} ref={firstMenuItemRef} role="menuitem" type="button">
<Archive aria-hidden="true" size={15} />{t("归档对话", "Archive conversation")}</button>
<button onClick={() => { setMenuOpen(false); setExportThreadID(threadID); }} role="menuitem" type="button">
<Download aria-hidden="true" size={15} />{t("导出对话", "Export conversation")}</button>
<button className="v2-task-compact-action" onClick={() => {
setMenuOpen(false); setReviewFileTarget(undefined); setReviewToolTarget(undefined);
reviewReturnFocus.current = menuTriggerRef.current; setReviewOpen(true);
Expand Down Expand Up @@ -1232,5 +1237,7 @@ export function V2Conversation({ client, threadID, workspaces, onArchive, onMana
{companion}
</div>}
</div>
{exportThreadID === threadID && <V2ThreadExport key={threadID} client={client} threadID={threadID} title={detail.thread.title}
returnFocusRef={menuTriggerRef} onClose={() => setExportThreadID(null)} />}
</section>;
}
9 changes: 9 additions & 0 deletions web/src/v2/components/thread-export.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.v2-thread-export-body { display: grid; gap: 16px; padding: 20px; }
.v2-thread-export-body p { color: var(--v2-muted); font-size: 13px; line-height: 1.6; margin: 0; overflow-wrap: anywhere; }
.v2-thread-export-body p[role="alert"] { color: var(--v2-danger); }
.v2-thread-export-body label { display: grid; gap: 8px; }
.v2-thread-export-body select { background: var(--v2-control); border: 1px solid var(--v2-border); border-radius: 9px; color: var(--v2-ink); font: inherit; min-height: 36px; min-width: 0; padding: 7px 10px; width: 100%; }
.v2-thread-export-body button { justify-self: start; }
.v2-thread-export footer { flex-wrap: wrap; }
.v2-thread-export button { align-items: center; display: inline-flex; gap: 6px; justify-content: center; }
.v2-thread-export { max-height: calc(100dvh - 32px); overflow-y: auto; }
81 changes: 81 additions & 0 deletions web/src/v2/components/thread-export.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { useRef, useState } from "react";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { APIClient } from "../../api/client";
import type { ThreadTranscriptItemView } from "../../api/types";
import { LocaleProvider } from "../../lib/locale";
import { downloadTextFile } from "../../lib/download";
import { V2ThreadExport } from "./thread-export";

vi.mock("../../lib/download", () => ({ downloadTextFile: vi.fn() }));
const message = { id: "public-1", run_id: "run-1", run_ordinal: 1, sequence: 1, activity_type: "message", kind: "operator_input",
source: "operator", title: "用户消息", detail: "preview", durable: true, provisional: false, created_at: "2026-10-22T00:00:01Z" } as ThreadTranscriptItemView;
const page = { items: [message], page: { limit: 100 }, requestID: "request-1" };
function fixture(get = vi.fn(async () => ({ message_id: "public-1", content: "完整正文\n```js\nrun();\n```" }))) {
return { get, getPage: vi.fn(async () => page) } as unknown as APIClient;
}
function Fixture({ client }: { client: APIClient }) {
const trigger = useRef<HTMLButtonElement>(null);
const [open, setOpen] = useState(false);
return <><button ref={trigger} onClick={() => setOpen(true)}>Export</button>
{open && <V2ThreadExport client={client} threadID="thread-1" title="导出验证" onClose={() => setOpen(false)} returnFocusRef={trigger} />}</>;
}
afterEach(() => { cleanup(); vi.clearAllMocks(); localStorage.clear(); });

it("downloads Markdown and text, copies the complete content and restores focus on Escape", async () => {
const user = userEvent.setup();
const copy = vi.spyOn(navigator.clipboard, "writeText");
render(<LocaleProvider><Fixture client={fixture()} /></LocaleProvider>);
await user.click(screen.getByRole("button", { name: "Export" }));
await screen.findByText("已准备 1 条消息。");
expect(screen.getByRole("button", { name: "关闭导出窗口" })).toHaveFocus();
await user.click(screen.getByRole("button", { name: "下载文件" }));
expect(downloadTextFile).toHaveBeenLastCalledWith("conversation-导出验证.md", "text/markdown;charset=utf-8", expect.stringContaining("完整正文\n```js\nrun();\n```"));
await user.selectOptions(screen.getByRole("combobox", { name: "文件格式" }), "text");
await user.click(screen.getByRole("button", { name: "下载文件" }));
expect(downloadTextFile).toHaveBeenLastCalledWith("conversation-导出验证.txt", "text/plain;charset=utf-8", expect.stringContaining("导出验证\n\n用户"));
await user.click(screen.getByRole("button", { name: "复制文本" }));
expect(copy).toHaveBeenCalledWith(expect.stringContaining("完整正文"));
expect(screen.getByText("已复制对话。")).toBeVisible();
await user.keyboard("{Escape}");
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Export" })).toHaveFocus();
});

it("keeps save controls disabled after a failed body read and supports a complete retry", async () => {
const get = vi.fn().mockRejectedValueOnce(new Error("body unavailable")).mockResolvedValue({ message_id: "public-1", content: "Recovered full body" });
render(<Fixture client={fixture(get)} />);
fireEvent.click(screen.getByRole("button", { name: "Export" }));
expect(await screen.findByRole("alert")).toHaveTextContent("body unavailable");
expect(screen.getByRole("button", { name: "Download file" })).toBeDisabled();
fireEvent.click(screen.getByRole("button", { name: "Download file" }));
expect(downloadTextFile).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Read again" }));
await screen.findByText("1 message ready.");
fireEvent.click(screen.getByRole("button", { name: "Download file" }));
expect(downloadTextFile).toHaveBeenCalledExactlyOnceWith(expect.any(String), expect.any(String), expect.stringContaining("Recovered full body"));
});

it("reports clipboard failure while retaining the downloadable content", async () => {
const user = userEvent.setup();
vi.spyOn(navigator.clipboard, "writeText").mockRejectedValueOnce(new Error("Clipboard denied"));
render(<Fixture client={fixture()} />);
await user.click(screen.getByRole("button", { name: "Export" }));
await screen.findByText("1 message ready.");
await user.click(screen.getByRole("button", { name: "Copy text" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Clipboard denied");
expect(screen.getByRole("button", { name: "Download file" })).toBeEnabled();
});

it("aborts unfinished reads when closed without writing a file", async () => {
const getPage = vi.fn((_path: string, _query: unknown, _cursor: string, signal: AbortSignal) => new Promise((_resolve, reject) => {
signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")));
}));
render(<Fixture client={{ getPage, get: vi.fn() } as unknown as APIClient} />);
fireEvent.click(screen.getByRole("button", { name: "Export" }));
await waitFor(() => expect(getPage).toHaveBeenCalledTimes(1));
expect(screen.getByRole("button", { name: "Download file" })).toBeDisabled();
fireEvent.click(screen.getByRole("button", { name: "Close export" }));
expect(getPage.mock.calls[0][3].aborted).toBe(true);
expect(downloadTextFile).not.toHaveBeenCalled();
});
Loading
Loading