diff --git a/docs/chat-export.md b/docs/chat-export.md new file mode 100644 index 00000000..fdfa704c --- /dev/null +++ b/docs/chat-export.md @@ -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`:通过。格式控件样式与英文单数文案调整后,导出组件测试及生产构建再次通过。 diff --git a/docs/images/chat-export/export-after.png b/docs/images/chat-export/export-after.png new file mode 100644 index 00000000..7dd58d6d Binary files /dev/null and b/docs/images/chat-export/export-after.png differ diff --git a/docs/images/chat-export/export-narrow-after.png b/docs/images/chat-export/export-narrow-after.png new file mode 100644 index 00000000..6ceafcc4 Binary files /dev/null and b/docs/images/chat-export/export-narrow-after.png differ diff --git a/docs/images/chat-export/menu-after.png b/docs/images/chat-export/menu-after.png new file mode 100644 index 00000000..f885cb2f Binary files /dev/null and b/docs/images/chat-export/menu-after.png differ diff --git a/docs/images/chat-export/menu-before.png b/docs/images/chat-export/menu-before.png new file mode 100644 index 00000000..324a35a7 Binary files /dev/null and b/docs/images/chat-export/menu-before.png differ diff --git a/internal/store/thread_search.go b/internal/store/thread_search.go index e5bf1bfb..0367815d 100644 --- a/internal/store/thread_search.go +++ b/internal/store/thread_search.go @@ -2,6 +2,7 @@ package store import ( "context" + "database/sql" "errors" "regexp" "strings" @@ -9,6 +10,7 @@ import ( "cyberagent-workbench/internal/apperror" "cyberagent-workbench/internal/domain" + "cyberagent-workbench/internal/events" "cyberagent-workbench/internal/redact" "cyberagent-workbench/internal/threadtranscript" ) @@ -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) diff --git a/internal/store/thread_search_test.go b/internal/store/thread_search_test.go index 1ca0d498..dc21e385 100644 --- a/internal/store/thread_search_test.go +++ b/internal/store/thread_search_test.go @@ -1,6 +1,7 @@ package store import ( + "encoding/json" "path/filepath" "strconv" "strings" @@ -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) diff --git a/web/src/v2/components/conversation.test.tsx b/web/src/v2/components/conversation.test.tsx index 81ce5f4e..33019098 100644 --- a/web/src/v2/components/conversation.test.tsx +++ b/web/src/v2/components/conversation.test.tsx @@ -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", @@ -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 diff --git a/web/src/v2/components/conversation.tsx b/web/src/v2/components/conversation.tsx index 8119b1e4..c52cc86a 100644 --- a/web/src/v2/components/conversation.tsx +++ b/web/src/v2/components/conversation.tsx @@ -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"; @@ -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"; @@ -378,6 +379,8 @@ export function V2Conversation({ client, threadID, workspaces, onArchive, onMana const [checking, setChecking] = useState([]); const [observations, setObservations] = useState>({}); const [menuOpen, setMenuOpen] = useState(false); + const [exportThreadID, setExportThreadID] = useState(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); @@ -983,6 +986,8 @@ export function V2Conversation({ client, threadID, workspaces, onArchive, onMana onArchive(detail.thread); }} ref={firstMenuItemRef} role="menuitem" type="button">