Skip to content

feat(export-to-word): add full chat export with URLs, stats, and session info#93

Open
Fu-Jie wants to merge 1 commit into
mainfrom
feat/issue-84-full-chat-export
Open

feat(export-to-word): add full chat export with URLs, stats, and session info#93
Fu-Jie wants to merge 1 commit into
mainfrom
feat/issue-84-full-chat-export

Conversation

@Fu-Jie

@Fu-Jie Fu-Jie commented Jun 21, 2026

Copy link
Copy Markdown
Owner

Summary

Implements the complete-chat export feature requested in #84, extending the Export to Word Enhanced plugin so it can export the entire conversation (all user prompts and assistant replies) instead of only the last assistant message.

Background

The current plugin only exports body["messages"][-1] (the last assistant reply). Issue #84 asks for the ability to export the complete chat including:

  • All user prompts and assistant replies
  • References of search URLs and URLs used by the LLM in each response
  • Per chat run statistics
  • Session data at the end

Changes

plugins/actions/export_to_docx/export_to_word.py (+375/-13)

New valves (also exposed via UserValves for per-user overrides):

Valve Default Description
EXPORT_FULL_CHAT False Toggle full chat export mode
FULL_CHAT_SKIP_SYSTEM True Skip system-role messages
FULL_CHAT_INCLUDE_STATS True Per-message statistics table (model, tokens)
FULL_CHAT_INCLUDE_SESSION_INFO True Session summary (title, counts, export time)
FULL_CHAT_INCLUDE_URLS True Collected URLs section with clickable hyperlinks

New i18n keys (en + zh): user_label, assistant_label, session_info, collected_urls, message_statistics, export_time, total_messages, user_messages, assistant_messages, chat_title_label, model_label, prompt_tokens, completion_tokens, total_tokens, message_label, no_urls_found, full_chat_converting.

New methods:

  • _export_full_chat() — iterates over body["messages"], labels each by role (## User / ## Assistant), collects sources/URLs/stats, calls markdown_to_docx with the combined markdown, then appends supplementary sections.
  • _extract_message_text() — handles string and multimodal (list-of-parts) content.
  • _collect_urls_from_text() — ordered, de-duplicated URL extraction via the existing _AUTO_URL_RE.
  • _collect_urls_from_sources() — extracts URLs from Open WebUI RAG source dicts (metadata.url, source.urls, source.id).
  • _add_session_info_section() — 2-column table (title, message counts, export time).
  • _add_collected_urls_section() — numbered list of clickable hyperlinks.
  • _add_message_stats_section() — per-message table (#, role, model, prompt/completion/total tokens).

action() branching: when EXPORT_FULL_CHAT is True, calls _export_full_chat() instead of the single-message markdown_to_docx() path. The existing single-message behavior is fully preserved as the default.

plugins/actions/export_to_docx/README.md & README_CN.md

Documented the new feature in "What's New", "Key Features", and the valves configuration table (both EN and CN).

How It Works

When EXPORT_FULL_CHAT is enabled, the document structure becomes:

[Title] Chat Title
## User
   <user prompt>
## Assistant
   <assistant reply + inline citations>
## User
   ...
## Assistant
   ...
[References]            ← auto-generated from all assistant sources
## Message Statistics   ← per-message table (model, tokens)
## Collected URLs       ← every URL found across all messages + sources
## Session Information  ← title, message counts, export time

Test Plan

  • Python syntax check passes (py_compile)
  • Unit-level logic tests for _extract_message_text, _collect_urls_from_text, _collect_urls_from_sources (string / multimodal / dedup / edge cases)
  • Default behavior (single-message export) unchanged when EXPORT_FULL_CHAT=False
  • Full chat export renders all user/assistant messages with role labels
  • Sources from all assistant messages are merged into the References section
  • Collected URLs section lists every URL found in messages and sources
  • Message Statistics table shows model and token counts when available
  • Session Information table shows title, counts, and export time
  • Works with both en and zh UI languages

Backward Compatibility

The feature is off by default (EXPORT_FULL_CHAT=False). Existing users see no behavior change until they explicitly enable the new valve.

Closes #84

…ion info

Extend the Export to Word Enhanced plugin to optionally export the complete
conversation (all user prompts and assistant replies) instead of only the
last assistant message, as requested in #84.

New valves:
- EXPORT_FULL_CHAT: toggle full chat export mode
- FULL_CHAT_SKIP_SYSTEM: skip system-role messages
- FULL_CHAT_INCLUDE_STATS: per-message statistics table (model, tokens)
- FULL_CHAT_INCLUDE_SESSION_INFO: session summary (title, counts, export time)
- FULL_CHAT_INCLUDE_URLS: collected URLs section with clickable hyperlinks

The full chat export iterates over body["messages"], labels each message
by role (User/Assistant), collects sources/URLs from all assistant messages
and message text, and appends supplementary sections after the main content.
All new valves are also exposed via UserValves for per-user overrides.

Closes #84
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@github-actions

Copy link
Copy Markdown
Contributor

❌ Plugin Version Check / 插件版本检查

未检测到版本更新!修改插件文件时必须更新版本号。

No version update detected! You must update the version number when modifying plugin files.



This comment was generated automatically. / 此评论由自动生成。

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

本拉取请求为 Word 导出插件引入了“完整对话导出”功能(EXPORT_FULL_CHAT),支持导出包含所有用户和助手消息的完整对话,并附带消息统计、收集的链接及会话信息摘要。审查意见均非常中肯,主要提出了以下优化建议:移除 _export_full_chat 中的 user_id_add_session_info_section 中的 user_name 等未使用参数;将链接提取中的 re.match 替换为更高效的 startswith;将 $O(N^2)$ 的循环去重优化为 $O(N)$list(dict.fromkeys(...));以及在应用表格样式时,使用 try-except KeyError 替代遍历所有样式的列表推导式以提升性能。

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +759 to +767
doc = await self._export_full_chat(
body=body,
event_emitter=__event_emitter__,
user_name=user_name,
user_id=user_id,
chat_title=chat_title,
top_heading=top_heading,
title=title,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The user_id parameter is passed to _export_full_chat but is never used within that method. It should be removed from the call site to keep the code clean.

                    doc = await self._export_full_chat(
                        body=body,
                        event_emitter=__event_emitter__,
                        user_name=user_name,
                        chat_title=chat_title,
                        top_heading=top_heading,
                        title=title,
                    )

Comment on lines +1692 to +1717
def _collect_urls_from_sources(self, sources: List[dict]) -> List[str]:
"""Extract URLs from Open WebUI RAG source dicts."""
urls: List[str] = []
for source in sources or []:
if not isinstance(source, dict):
continue
src_info = source.get("source") or {}
metadatas = source.get("metadata") or []
if not isinstance(metadatas, list):
metadatas = []
for meta in metadatas:
if not isinstance(meta, dict):
continue
url = meta.get("url") or meta.get("source")
if isinstance(url, str) and re.match(r"^https?://", url) and url not in urls:
urls.append(url)
if isinstance(src_info, dict):
src_urls = src_info.get("urls")
if isinstance(src_urls, list):
for u in src_urls:
if isinstance(u, str) and re.match(r"^https?://", u) and u not in urls:
urls.append(u)
src_id = src_info.get("id")
if isinstance(src_id, str) and re.match(r"^https?://", src_id) and src_id not in urls:
urls.append(src_id)
return urls

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using re.match(r"^https?://", ...) introduces unnecessary regex compilation and execution overhead. Since you are only checking if the string starts with http:// or https://, using the built-in .startswith(("http://", "https://")) is much faster and more readable.

    def _collect_urls_from_sources(self, sources: List[dict]) -> List[str]:
        """Extract URLs from Open WebUI RAG source dicts."""
        urls: List[str] = []
        for source in sources or []:
            if not isinstance(source, dict):
                continue
            src_info = source.get("source") or {}
            metadatas = source.get("metadata") or []
            if not isinstance(metadatas, list):
                metadatas = []
            for meta in metadatas:
                if not isinstance(meta, dict):
                    continue
                url = meta.get("url") or meta.get("source")
                if isinstance(url, str) and url.startswith(("http://", "https://")) and url not in urls:
                    urls.append(url)
            if isinstance(src_info, dict):
                src_urls = src_info.get("urls")
                if isinstance(src_urls, list):
                    for u in src_urls:
                        if isinstance(u, str) and u.startswith(("http://", "https://")) and u not in urls:
                            urls.append(u)
                src_id = src_info.get("id")
                if isinstance(src_id, str) and src_id.startswith(("http://", "https://")) and src_id not in urls:
                    urls.append(src_id)
        return urls

Comment on lines +1719 to +1728
async def _export_full_chat(
self,
body: dict,
event_emitter: Optional[Callable],
user_name: str,
user_id: str,
chat_title: str,
top_heading: str,
title: str,
) -> Document:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The user_id parameter is unused in _export_full_chat. Removing it simplifies the method signature and improves maintainability.

    async def _export_full_chat(
        self,
        body: dict,
        event_emitter: Optional[Callable],
        user_name: str,
        chat_title: str,
        top_heading: str,
        title: str,
    ) -> Document:

Comment on lines +1801 to +1804
deduped_urls: List[str] = []
for u in all_urls:
if u not in deduped_urls:
deduped_urls.append(u)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

De-duplicating a list using a loop with if u not in deduped_urls has $O(N^2)$ time complexity. In Python 3.7+, dictionaries preserve insertion order, so you can use list(dict.fromkeys(all_urls)) to de-duplicate the list in $O(N)$ time while keeping the code concise and idiomatic.

Suggested change
deduped_urls: List[str] = []
for u in all_urls:
if u not in deduped_urls:
deduped_urls.append(u)
deduped_urls = list(dict.fromkeys(all_urls))

Comment on lines +1827 to +1834
self._add_session_info_section(
doc,
chat_title=chat_title,
user_name=user_name,
total_messages=user_count + assistant_count,
user_count=user_count,
assistant_count=assistant_count,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The user_name parameter is passed to _add_session_info_section but is never used within that method. It should be removed from the call site.

            self._add_session_info_section(
                doc,
                chat_title=chat_title,
                total_messages=user_count + assistant_count,
                user_count=user_count,
                assistant_count=assistant_count,
            )

Comment on lines +1838 to +1861
def _add_session_info_section(
self,
doc: Document,
chat_title: str,
user_name: str,
total_messages: int,
user_count: int,
assistant_count: int,
):
"""Append a session-information summary at the end of the document."""
self.add_heading(doc, self._get_msg("session_info"), 2)

export_time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")

rows = [
(self._get_msg("chat_title_label"), chat_title or "—"),
(self._get_msg("total_messages"), str(total_messages)),
(self._get_msg("user_messages"), str(user_count)),
(self._get_msg("assistant_messages"), str(assistant_count)),
(self._get_msg("export_time"), export_time),
]

table = doc.add_table(rows=len(rows), cols=2)
table.style = "Light Grid Accent 1" if "Light Grid Accent 1" in [s.name for s in doc.styles] else None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This refactoring addresses two issues:

  1. The user_name parameter is unused and has been removed from the signature.
  2. Checking "Light Grid Accent 1" in [s.name for s in doc.styles] iterates over all styles and builds a list every time, which is inefficient. Using a try-except KeyError block is much faster, more idiomatic, and consistent with how styles are applied in _add_message_stats_section.
    def _add_session_info_section(
        self,
        doc: Document,
        chat_title: str,
        total_messages: int,
        user_count: int,
        assistant_count: int,
    ):
        """Append a session-information summary at the end of the document."""
        self.add_heading(doc, self._get_msg("session_info"), 2)

        export_time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")

        rows = [
            (self._get_msg("chat_title_label"), chat_title or "—"),
            (self._get_msg("total_messages"), str(total_messages)),
            (self._get_msg("user_messages"), str(user_count)),
            (self._get_msg("assistant_messages"), str(assistant_count)),
            (self._get_msg("export_time"), export_time),
        ]

        table = doc.add_table(rows=len(rows), cols=2)
        try:
            table.style = "Light Grid Accent 1"
        except KeyError:
            pass

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Extend plugin to export complete chat with all user prompts and assistant replies

2 participants