refactor: repartition server-side and client-side code

This commit is contained in:
LIlGG
2025-10-11 18:26:07 +08:00
parent 7acc4949fb
commit e9b573a276
309 changed files with 631 additions and 962 deletions

View File

@@ -0,0 +1,156 @@
import {
streamText as _streamText,
type CallSettings,
convertToModelMessages,
type LanguageModel,
type LanguageModelUsage,
type StreamTextOnFinishCallback,
stepCountIs,
} from 'ai';
import { getSystemPrompt } from '~/.server/prompts/prompts';
import { approximatePromptTokenCount, encode } from '~/.server/utils/token';
import type { ElementInfo } from '~/routes/api.chat/chat.server';
import type { UPageUIMessage } from '~/types/message';
import { MAX_TOKENS } from './constants';
import type { SelectContextResult } from './select-context';
import { tools } from './tools';
export type ChatStreamTextProps = CallSettings & {
messages: UPageUIMessage[];
summary: string;
pageSummary: string;
context?: Record<string, SelectContextResult>;
model: LanguageModel;
maxTokens?: number;
elementInfo?: ElementInfo;
onFinish?: StreamTextOnFinishCallback<any>;
onAbort?: (params: { event: any; totalUsage: LanguageModelUsage }) => void;
};
export async function chatStreamText({
messages,
summary,
pageSummary,
context,
model,
maxTokens,
elementInfo,
abortSignal,
onFinish,
onAbort,
}: ChatStreamTextProps) {
let systemPrompt = getSystemPrompt();
if (pageSummary) {
systemPrompt = `${systemPrompt}
以下是截止目前为止的页面摘要:
PAGE SUMMARY:
---
${pageSummary}
---
`;
}
if (summary) {
systemPrompt = `${systemPrompt}
以下是截至目前为止的聊天记录摘要:
CHAT SUMMARY:
---
${summary}
---
`;
}
if (context) {
systemPrompt = `${systemPrompt}
以下是根据用户的聊天记录和任务分析出的可能对此次任务有帮助的页面及其代码片段,按页面名称区分,多个页面使用 ------ 分割
CONTEXT:
---
${Object.entries(context)
.map(
([key, value]) => `
- 页面名称: ${value.pageName}
- 页面标题: ${value.pageTitle}
- 页面内容: ${value.sections.join('\n')}
`,
)
.join('------')}
---
`;
}
if (elementInfo) {
systemPrompt = `${systemPrompt}
${createElementEditPrompt(elementInfo)}
`;
}
return _streamText({
model,
tools: tools(),
system: systemPrompt,
maxOutputTokens: maxTokens || MAX_TOKENS,
messages: convertToModelMessages(messages),
stopWhen: stepCountIs(3),
prepareStep: async ({ messages }) => {
if (messages.length > 20) {
return {
messages: messages.slice(-10),
};
}
return {};
},
abortSignal,
onFinish,
onAbort(event) {
// 由于 AI SDK 没有提供在 onAbort 中计算 Token 消耗的方法。所以这里手动计算。
let inoutTokens = 0;
inoutTokens += approximatePromptTokenCount(messages);
inoutTokens += encode(systemPrompt).length;
onAbort?.({
event,
totalUsage: {
inputTokens: inoutTokens,
outputTokens: 0,
totalTokens: inoutTokens,
reasoningTokens: 0,
cachedInputTokens: 0,
},
});
},
});
}
/**
* 根据元素编辑信息创建相应的系统提示
* @param elementEdit 元素编辑信息
* @returns 系统提示字符串
*/
function createElementEditPrompt({ tagName, className, id }: ElementInfo): string {
// 构建元素选择器描述
const elementSelector = [tagName.toLowerCase(), id ? `#${id}` : '', className ? `.${className.split(' ')[0]}` : '']
.filter(Boolean)
.join('');
return `
<element_edit_context>
用户当前正在编辑特定元素。请将您的响应限制在此元素的范围内。
当前编辑的元素: ${elementSelector}
请严格遵循以下规则:
1. 仅修改用户当前选中的元素或其子元素
2. 不要修改页面上的其他元素
3. 如果是添加操作,仅在当前选中元素内添加内容
4. 如果是更新操作,确保使用最小化更新,并保留元素的 domId
5. 如果是删除操作,仅删除当前选中元素或其子元素
6. 保持页面的整体风格和一致性
7. 确保所有生成的 HTML 元素都有唯一的 domId不要使用相同的 domId
元素详细信息:
- 标签名: ${tagName.toLowerCase()}
${id ? `- ID: ${id}` : ''}
${className ? `- 类名: ${className}` : ''}
</element_edit_context>
`;
}

View File

@@ -0,0 +1,7 @@
// see https://docs.anthropic.com/en/docs/about-claude/models
export const MAX_TOKENS = process.env.MAX_TOKENS ? parseInt(process.env.MAX_TOKENS) : 8000;
// limits the number of model responses that can be returned in a single request
export const MAX_RESPONSE_SEGMENTS = process.env.MAX_RESPONSE_SEGMENTS
? parseInt(process.env.MAX_RESPONSE_SEGMENTS)
: 5;

View File

@@ -0,0 +1,148 @@
import { type CallSettings, generateText, type LanguageModel } from 'ai';
import type { UPageUIMessage } from '~/types/message';
import { createScopedLogger } from '~/utils/logger';
import { extractCurrentContext, getUserMessageContent, simplifyUPageActions } from './utils';
const logger = createScopedLogger('create-summary');
export async function createSummary({
messages,
model,
abortSignal,
}: {
messages: UPageUIMessage[];
model: LanguageModel;
} & CallSettings) {
const processedMessages = messages.map((message) => {
if (message.role === 'user') {
const content = getUserMessageContent(message);
return { ...message, content };
}
if (message.role == 'assistant') {
for (const part of message.parts) {
if (part.type === 'text') {
part.text = simplifyUPageActions(part.text);
part.text = part.text.replace(/<div class=\\"__uPageThought__\\">.*?<\/div>/s, '');
}
if (part.type === 'reasoning') {
part.text = part.text.replace(/<think>.*?<\/think>/s, '');
}
}
return message;
}
return message;
});
let slicedMessages = processedMessages;
const { summary } = extractCurrentContext(processedMessages);
let summaryText: string | undefined = undefined;
let chatId: string | undefined = undefined;
if (summary) {
chatId = summary.chatId;
summaryText = `以下是截至目前为止的聊天摘要,将其作为历史消息参考使用。
${summary.summary}`;
if (chatId) {
let index = 0;
for (let i = 0; i < processedMessages.length; i++) {
if (processedMessages[i].id === chatId) {
index = i;
break;
}
}
slicedMessages = processedMessages.slice(index + 1);
}
}
logger.debug('切片消息长度:', slicedMessages.length);
const extractTextContent = (message: UPageUIMessage) =>
message.parts
.map((part) => {
if (part.type === 'text') {
return part.text;
}
return '';
})
.join('\n');
return await generateText({
system: `
你是一名软件工程师。你正在参与一个项目。你需要总结目前的工作内容,并提供截至目前对话的摘要。
请仅使用以下格式生成摘要:
---
# 项目概览
- **项目名称**: {project_name} - {brief_description}
- **当前阶段**: {phase}
# 对话上下文
- **最近讨论点**: {main_discussion_point}
- **重要决策**: {important_decisions_made}
# 实现状态
## 当前状态
- **活跃功能**: {feature_in_development}
- **进展**: {what_works_and_what_doesn't}
- **障碍**: {current_challenges}
## 代码演化
- **最近修改**: {latest_modifications}
# 需求
- **已实现**: {completed_features}
- **进行中**: {current_focus}
- **待定**: {upcoming_features}
# 关键记忆
- **必须保留**: {crucial_technical_context}
- **用户需求**: {specific_user_needs}
- **已知问题**: {documented_problems}
# 下一步行动
- **立即行动**: {next_steps}
- **待解决的问题**: {unresolved_issues}
---
Note:
4. 保持条目简洁,重点记录确保工作连续性所需的信息。
---
RULES:
* 仅提供截至目前为止的聊天摘要。
* 不要提供任何新信息。
* 不需要过多思考,立即开始写作
* 不要写任何与提供的结构不同的摘要
`,
prompt: `
以下是之前的聊天摘要:
<old_summary>
${summaryText}
</old_summary>
以下是之后的聊天记录:
---
<new_chats>
${slicedMessages
.map((x) => {
return `---\n[${x.role}] ${extractTextContent(x)}\n---`;
})
.join('\n')}
</new_chats>
---
请提供截至目前聊天的摘要,包括聊天的历史记录摘要。
`,
model,
abortSignal,
});
}

View File

@@ -0,0 +1,137 @@
import { type CallSettings, generateText, type LanguageModel } from 'ai';
import type { Page } from '~/types/actions';
import type { UPageUIMessage } from '~/types/message';
import { createScopedLogger } from '~/utils/logger';
const logger = createScopedLogger('select-context');
export type SelectContextResult = {
sections: string[];
pageName: string;
pageTitle: string;
};
export async function selectContext({
messages,
pages,
summary,
model,
abortSignal,
}: {
messages: UPageUIMessage[];
pages: Page[];
summary: string;
model: LanguageModel;
} & CallSettings) {
const extractTextContent = (message: UPageUIMessage) =>
message.parts.find((part) => part.type === 'text')?.text || '';
const lastUserMessage = messages.filter((x) => x.role == 'user').pop();
if (!lastUserMessage) {
throw new Error('未找到用户消息');
}
const pagesContent = pages.map((page) => {
return `
---
页面名称:${page.name}
---
页面标题:${page.title}
---
页面内容:${page.content}
`;
});
const resp = await generateText({
system: `
你是一名软件工程师。你正在从事一个 HTML 项目,该项目包含多个页面,每个页面内容中包含 HTML 片段,可能是 HTML、style、JavaScript 片段。
${pagesContent.join('\n')}
---
现在,你将获得一个任务。你需要从上述页面列表中选择与任务相关的页面与用户任务相关的 HTML 片段。请务必保证:
- 如果涉及到脚本,则需要选择可能相关的所有脚本的完整内容。
- 如果涉及到样式,则需要选择可能相关的所有样式,包括内联样式、外部样式表和 style 标签中的样式。
RESPONSE FORMAT:
你的回复应严格遵循以下格式:
---
<updateContextBuffer>
<selectPage pageName="pageName" pageTitle="pageTitle">
<selectSection>
...section content...
</selectSection>
...
</selectPage>
...
</updateContextBuffer>
---
* 你应该从 <updateContextBuffer> 开始,以 </updateContextBuffer> 结束。
* 你可以在回复中包含多个 <selectPage> 标签,每个 <selectPage> 标签中也可以包含多个 <selectSection> 标签。
* 你需要在 <selectPage> 标签中包含页面名称和页面标题,但每个页面只能出现一次。
* 你需要在 <selectSection> 标签中包含完整的 HTML 内容,只做选择,但不要对 HTML 内容进行任何修改。
* 如果不需要任何更改,你可以留下空的 updateContextBuffer 标签。
`,
prompt: `
以下是截至目前聊天的摘要: ${summary}
用户当前任务: ${extractTextContent(lastUserMessage)}
请根据当前页面、页面属性以及内容,选择与任务相关的页面以及 HTML 片段。
`,
model,
abortSignal,
});
const response = resp.text;
const updateContextBuffer = response.match(/<updateContextBuffer>([\s\S]*?)<\/updateContextBuffer>/);
if (!updateContextBuffer) {
throw new Error('无效响应。请遵循响应格式');
}
const updateContextBufferContent = updateContextBuffer[1];
const selectedPages: Record<string, SelectContextResult> = {};
const selectPageRegex = /<selectPage\s+pageName="([^"]+)"\s+pageTitle="([^"]+)">([\s\S]*?)<\/selectPage>/g;
let selectPageMatch;
while ((selectPageMatch = selectPageRegex.exec(updateContextBufferContent)) !== null) {
const pageName = selectPageMatch[1];
const pageTitle = selectPageMatch[2];
const pageContent = selectPageMatch[3];
if (!pageName) {
logger.warn('页面名称为空');
continue;
}
const selectSectionRegex = /<selectSection>([\s\S]*?)<\/selectSection>/g;
const sections: string[] = [];
let selectSectionMatch;
while ((selectSectionMatch = selectSectionRegex.exec(pageContent)) !== null) {
const sectionContent = selectSectionMatch[1];
if (sectionContent.trim()) {
sections.push(sectionContent.trim());
}
}
if (sections.length > 0) {
selectedPages[pageName] = {
sections,
pageName,
pageTitle,
};
}
}
const { text, content, totalUsage } = resp;
return {
text,
content,
totalUsage,
context: selectedPages,
};
}

View File

@@ -0,0 +1,45 @@
import { convertToModelMessages, type LanguageModel, streamText, type UIMessage } from 'ai';
import { DEFAULT_PROVIDER } from '~/.server/modules/constants';
import { createScopedLogger } from '~/utils/logger';
import { stripIndents } from '~/utils/strip-indent';
const logger = createScopedLogger('stream-enhancer');
export async function streamEnhancer(props: { messages: UIMessage[]; model: LanguageModel; maxTokens?: number }) {
const { messages, model, maxTokens } = props;
logger.info(`发送 llm 调用至 ${DEFAULT_PROVIDER.name} 使用模型 ${model}`);
const systemMessage = stripIndents`
你是一位专业提示工程师,专注于制作精确、有效的提示。
你的任务是增强提示,使其更加具体、可操作且有效。
对于有效的提示:
- 使指令明确且无歧义
- 添加相关上下文和约束
- 删除冗余信息
- 保持核心意图
- 确保提示自包含
- 使用专业语言
对于无效或不明确的提示:
- 提供清晰、专业的指导
- 保持响应简洁且可操作
- 保持有帮助、建设性的语气
- 专注于用户应该提供的内容
- 使用标准模板保持一致
<output_format>
1. 响应必须仅包含增强后的提示文本。
2. 不要包含任何解释、元数据或包装标签。
</output_format>
`;
const result = streamText({
model,
system: systemMessage,
maxOutputTokens: maxTokens,
messages: convertToModelMessages(messages),
});
return result.toUIMessageStreamResponse();
}

View File

@@ -0,0 +1,149 @@
import { type CallSettings, generateText, type LanguageModel } from 'ai';
import type { Page } from '~/types/actions';
export async function structuredPageSnapshot({
pages,
model,
abortSignal,
}: {
pages: Page[];
model: LanguageModel;
} & CallSettings) {
return await generateText({
system: `
你是一名严谨的前端页面审阅与摘要助手。你将收到多个页面的 Body 片段,可能包含 script 与 style。你的任务是
1) 从这些页面内容中提取结构化信息;
2) 以严格的 XML 风格(非 JSON、非 Markdown、无额外说明文本输出一个“现有页面摘要快照”
3) 仅基于给定内容进行总结,不得臆测未出现的信息;不回显原始全文;
4) 若某项信息无法确定,请输出空元素,不要编造。
输出必须严格遵循以下 XML 模板(标签名与层级必须一致;可重复的节点可按需要重复;所有一级大纲必须保留):
<snapshot>
<generated_at></generated_at>
<pages_count></pages_count>
<global_overview>
<shared_components></shared_components>
<shared_styles></shared_styles>
<shared_scripts></shared_scripts>
<navigation_overview></navigation_overview>
<notable_assets></notable_assets>
</global_overview>
<pages>
<page>
<name></name>
<summary></summary>
<layout>
<structure></structure>
<sections></sections>
<components>
<component>
<name></name>
<role></role>
<props></props>
<events></events>
</component>
</components>
</layout>
<content>
<headings>
<h1></h1>
<h2_list>
<h2></h2>
</h2_list>
</headings>
<text_stats>
<char_count></char_count>
<word_count></word_count>
<language_guess></language_guess>
</text_stats>
<links>
<a href="" text=""></a>
</links>
<forms>
<form id="" action="" method="">
<fields>
<field name="" type="" required=""></field>
</fields>
<validation></validation>
<submit_targets></submit_targets>
</form>
</forms>
<media>
<img src="" alt=""></img>
<video src="" title=""></video>
</media>
<tables>
<table summary=""></table>
</tables>
</content>
<interactions>
<events>
<event type="" target="" handler_summary=""></event>
</events>
<state>
<variables>
<variable name="" initial=""></variable>
</variables>
<persistence></persistence>
</state>
</interactions>
<data_flow>
<inputs></inputs>
<outputs></outputs>
<api_calls>
<api url="" method="" when=""></api>
</api_calls>
</data_flow>
<style_summary>
<inline_styles></inline_styles>
<classes></classes>
<themes></themes>
</style_summary>
<script_summary>
<libraries></libraries>
<modules></modules>
<security_notes></security_notes>
</script_summary>
<seo>
<title></title>
<meta_description></meta_description>
<canonical></canonical>
<h_tags></h_tags>
</seo>
<i18n>
<locales_detected></locales_detected>
<hardcoded_texts></hardcoded_texts>
</i18n>
<issues>
<problem severity="">
<desc></desc>
<evidence></evidence>
<suggestion></suggestion>
</problem>
</issues>
<complexity score=""></complexity>
<confidence score=""></confidence>
</page>
</pages>
</snapshot>
严格输出规则:
- 仅输出上述 XML不要输出任何解释性文字、代码块符号或 Markdown
- 标签名、层级和顺序必须与模板保持一致;
- 允许重复的子节点按需要重复;
- 内容以中文撰写;
- 不得包含未在输入中出现的臆测信息;
- 无法确定的信息保留为空元素。
`,
prompt: `
以下是页面内容:
---
<pages>
${pages.map((page) => `<page_name>${page.name}</page_name><page_content>${page.content}</page_content>`).join('\n --- \n')}
</pages>
---
`,
model,
abortSignal,
});
}

View File

@@ -0,0 +1,66 @@
export default class SwitchableStream extends TransformStream {
private _controller: TransformStreamDefaultController | null = null;
private _currentReader: ReadableStreamDefaultReader | null = null;
private _switches = 0;
constructor() {
let controllerRef: TransformStreamDefaultController | undefined;
super({
start(controller) {
controllerRef = controller;
},
});
if (controllerRef === undefined) {
throw new Error('Controller not properly initialized');
}
this._controller = controllerRef;
}
async switchSource(newStream: ReadableStream) {
if (this._currentReader) {
await this._currentReader.cancel();
}
this._currentReader = newStream.getReader();
this._pumpStream();
this._switches++;
}
private async _pumpStream() {
if (!this._currentReader || !this._controller) {
throw new Error('Stream is not properly initialized');
}
try {
while (true) {
const { done, value } = await this._currentReader.read();
if (done) {
break;
}
this._controller.enqueue(value);
}
} catch (error) {
console.log(error);
this._controller.error(error);
}
}
close() {
if (this._currentReader) {
this._currentReader.cancel();
}
this._controller?.terminate();
}
get switches() {
return this._switches;
}
}

View File

@@ -0,0 +1,17 @@
import type { Tool, ToolSet } from 'ai';
import { serperTool } from './serper';
import { weatherTool } from './weather';
export const tools: () => ToolSet = () => {
const tools: Record<string, Tool> = {};
if (process.env.SERPER_API_KEY) {
tools.serper = serperTool;
}
if (process.env.WEATHER_API_KEY) {
tools.weather = weatherTool;
}
return tools;
};

View File

@@ -0,0 +1,54 @@
/**
* 由于 agentic 暂时不支持 AI SDK v5因此使用自定义的 Serper 工具。
* @see https://docs.agentic.so/marketplace/ts-sdks/ai-sdk
*/
import { tool } from 'ai';
import { z } from 'zod';
const API_BASE_URL = 'https://google.serper.dev';
const searchParamsSchema = z.object({
q: z.string().describe('搜索查询词'),
autocorrect: z.boolean().optional().default(true).describe('是否自动纠正拼写错误'),
gl: z.string().optional().default('us').describe('地理位置代码,如"us"表示美国'),
hl: z.string().optional().default('en').describe('语言代码,如"en"表示英语'),
page: z.number().optional().default(1).describe('页码'),
num: z.number().optional().default(10).describe('结果数量'),
type: z
.enum(['search', 'images', 'videos', 'places', 'news', 'shopping'])
.optional()
.default('search')
.describe('搜索类型'),
});
export const serperTool = tool({
description: '使用Google搜索获取最新信息。适用于查找新闻、事实、数据和当前事件等实时信息。',
inputSchema: searchParamsSchema,
execute: async ({ q, ...params }) => {
const apiKey = process.env.SERPER_API_KEY || '';
if (!apiKey) {
throw new Error('Missing SERPER_API_KEY');
}
try {
const response = await fetch(`${API_BASE_URL}/search`, {
method: 'POST',
headers: {
'X-API-KEY': apiKey,
'Content-Type': 'application/json',
},
body: JSON.stringify({ q, ...params }),
});
if (!response.ok) {
throw new Error(`Serper API responded with status: ${response.status}`);
}
return await response.json();
} catch (error: unknown) {
console.error('Serper API error:', error);
const errorMessage = error instanceof Error ? error.message : '未知错误';
throw new Error(`搜索失败: ${errorMessage}`);
}
},
});

View File

@@ -0,0 +1,43 @@
/**
* 由于 agentic 暂时不支持 AI SDK v5因此使用自定义的 Weather 工具。
* @see https://docs.agentic.so/marketplace/ts-sdks/ai-sdk
*/
import { tool } from 'ai';
import { z } from 'zod';
const API_BASE_URL = 'https://api.weatherapi.com/v1';
const weatherParamsSchema = z.object({
q: z
.string()
.describe('位置查询可以是城市名称、邮政编码、IP地址或经纬度坐标。必须使用英语或拼音。例如"London"、"Beijing"'),
});
export const weatherTool = tool({
description: '获取指定位置的天气信息',
inputSchema: weatherParamsSchema,
execute: async ({ q }) => {
const apiKey = process.env.WEATHER_API_KEY || '';
if (!apiKey) {
throw new Error('Missing WEATHER_API_KEY');
}
try {
const url = new URL(`${API_BASE_URL}/current.json`);
url.searchParams.append('key', apiKey);
url.searchParams.append('q', q);
const response = await fetch(url.toString());
if (!response.ok) {
throw new Error(`Weather API responded with status: ${response.status}`);
}
return await response.json();
} catch (error: unknown) {
console.error('Weather API error:', error);
const errorMessage = error instanceof Error ? error.message : '未知错误';
throw new Error(`获取天气信息失败: ${errorMessage}`);
}
},
});

92
app/.server/llm/utils.ts Normal file
View File

@@ -0,0 +1,92 @@
import type { Section } from '~/types/actions';
import type { SummaryAnnotation, UPageUIMessage } from '~/types/message';
import type { PageMap, SectionMap } from '~/types/pages';
export function getUserMessageContent(message: Omit<UPageUIMessage, 'id'>): string {
if (message.role !== 'user') {
throw new Error('Message is not a user message');
}
return message.parts
.map((part) => {
if (part.type === 'text') {
return part.text;
}
return '';
})
.join('\n');
}
export function simplifyUPageActions(input: string): string {
// Using regex to match uPageAction tags that have type="page"
const regex = /(<uPageAction[^>]*type="page"[^>]*>)([\s\S]*?)(<\/uPageAction>)/g;
// Replace each matching occurrence
return input.replace(regex, (_0, openingTag, _2, closingTag) => {
return `${openingTag}\n ...\n ${closingTag}`;
});
}
export function getSectionByPageName(sections: SectionMap) {
return Object.values(sections).reduce(
(acc, section) => {
if (section) {
const pageName = section.pageName;
acc[pageName] = [...(acc[pageName] || []), section];
}
return acc;
},
{} as Record<string, Section[]>,
);
}
export function createPagesContext(pages: PageMap, sections: SectionMap) {
const pagePaths = Object.keys(pages);
const sectionGroupByPageName = Object.values(sections).reduce(
(acc, section) => {
if (section) {
const pageName = section.pageName;
if (pagePaths.includes(pageName)) {
acc[section.pageName] = [...(acc[section.pageName] || []), section];
}
}
return acc;
},
{} as Record<string, Section[]>,
);
const pageContexts = Object.entries(sectionGroupByPageName).map(([pageName, sections]) => {
return `<uPageAction id="${pageName}" title="Code Content">${sections
.map((section) => {
return `<uPageAction id="${section.domId}" type="page" pageName="${pageName}" action="${section.action}" domId="${section.domId}">${section.content}</uPageAction>`;
})
.join('\n')}</uPageAction>`;
});
return pageContexts.join('\n');
}
export function extractCurrentContext(messages: UPageUIMessage[]) {
const lastAssistantMessage = messages.filter((x) => x.role == 'assistant').slice(-1)[0];
if (!lastAssistantMessage) {
return { summary: undefined };
}
let summary: SummaryAnnotation | undefined;
if (!lastAssistantMessage.parts?.length) {
return { summary: undefined };
}
const parts = lastAssistantMessage.parts;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (part.type === 'data-summary') {
summary = part.data;
break;
}
}
return { summary };
}