Document组件文件预处理:AI投喂前的三步必做清单

2026-06-19阅读 0热度 0
ai

读完本文你将掌握


先明确几个关键判断。想让AI精准回答企业内部知识库问题,这个愿景很诱人,但存在一个现实瓶颈:AI的上下文窗口有限,无法一次性消化整本手册。

工程上如何突破?标准解法是 RAG(检索增强生成),核心逻辑并不复杂:

  1. 建库:将文档分割成小块,计算向量,存入向量数据库
  2. 检索:用户提问 → 系统召回最相关的几个片段 → AI基于这些片段生成答案

第一步“建库”正是 Document 组件的职责:加载 → 解析 → 切片。下面逐步拆解。


一、Document 的数据结构

源码位于 eino/schema/document.go,结构体精简到只有三个字段:

type Document struct {
    ID       string         // 该片段的唯一标识
    Content  string         // 实际文本内容
    MetaData map[string]any // 关联的元数据
}

Content 存储正文,MetaData 记录来源、分数、向量等附加信息。

举例说明,从HTML页面解析出的Document示例:

{
  "id": "doc-001",
  "content": "Go 是 Google 开发的编程语言,设计目标是简洁、高效...",
  "meta_data": {
    "_source": "https://example.com/go-intro.html",
    "_title": "Go 语言简介",
    "_language": "zh"
  }
}

MetaData并非普通map——它提供了一系列封装好的方法用于访问内部数据:

doc.Score()        // 获取检索相关性分数(无需手动从map中提取)
doc.DenseVector()  // 获取向量
doc.ExtraInfo()    // 获取附加说明
doc.SubIndexes()   // 获取多分区路由索引

这些值底层存储在MetaData的保留key中(_score_dense_vector 等),但对外暴露的是封装方法,避免直接操作原始map。这种设计清晰分离了内部实现与外部接口。


二、Loader:负责将原始文件搬运到系统

接口定义在 eino/components/document/interface.go

type Loader interface {
    Load(ctx context.Context, src Source, opts ...LoaderOption) ([]*schema.Document, error)
}type Source struct {
    URI string  // 文件路径或 URL
}

接口极简,职责单一。eino-ext提供了三种现成实现:

Loader适用场景URI 示例
file.FileLoader读取本地文件/path/to/file.md
url.Loader抓取网页内容https://...
s3.Loader读取 AWS S3 对象存储s3://bucket/key

这里有一个重要设计原则:Loader 不关心文件格式。它只负责读取字节流,至于格式解析工作交由下一层 Parser 完成。两者解耦带来明确优势:更换文件格式无需修改Loader,更换数据源也无需改动Parser。

// FileLoader 内部核心逻辑示意:
func (f *FileLoader) Load(ctx context.Context, src Source, opts ...LoaderOption) ([]*schema.Document, error) {
    file, _ := os.Open(src.URI)
    defer file.Close()
    // 将文件流交给 Parser,扩展名通过 URI 传递
    return f.parser.Parse(ctx, file, parser.WithURI(src.URI))
}

三、Parser:负责将不同格式转换为纯文本

接口定义在 eino/components/document/parser/interface.go

type Parser interface {
    Parse(ctx context.Context, reader io.Reader, opts ...Option) ([]*schema.Document, error)
}

接受字节流,返回 Document 列表。

TextParser:最基础的纯文本解析器

直接将整个流读为字符串,生成一个 Document。适用于 .txt.md 等无格式纯文本文件。

HTMLParser:专为网页设计

来自 eino-ext,底层基于 goquery(Go语言的jQuery)操作 DOM。

// 源码:eino-ext/components/document/parser/html/html.go
htmlParser, _ := html.NewParser(ctx, &html.Config{
    Selector: gptr.Of("body"),  // 使用 CSS 选择器仅提取 body 内容
})

解析完成后,自动从页面中提取 meta 信息并写入 MetaData:

_title       <-  标签内容
_description <- <meta <span class="hljs-attr">name</span>=<span class="hljs-string">"description"</span>> 内容
_language    <- <html <span class="hljs-attr">lang</span>=<span class="hljs-string">"..."</span>> 属性
_charset     <- 字符编码
_source      <- 来源 URL
</code></pre>
<p>安全方面,采用 <strong>bluemonday UGC 策略</strong>过滤危险HTML标签,有效防范XSS攻击,避免恶意脚本被存入知识库。</p>
<h4>ExtParser:按扩展名自动路由</h4>
<p>当需要处理多种格式文件时:</p>
<pre><code class="hljs language-go" lang="go"><span class="hljs-comment">// 源码:eino/components/document/parser/ext_parser.go</span>
extParser, _ := parser.NewExtParser(ctx, &parser.ExtParserConfig{
    Parsers: <span class="hljs-keyword">map</span>[<span class="hljs-type">string</span>]parser.Parser{
        <span class="hljs-string">".html"</span>: htmlParser,
        <span class="hljs-string">".pdf"</span>:  pdfParser,
        <span class="hljs-string">".docx"</span>: docxParser,
    },
    FallbackParser: parser.TextParser{},  <span class="hljs-comment">// 未匹配格式时采用默认解析</span>
})<span class="hljs-comment">// 注意:必须传入 URI,否则 ExtParser 无法确定采用哪个 Parser</span>
docs, _ := extParser.Parse(ctx, file, parser.WithURI(<span class="hljs-string">"./report.html"</span>))
</code></pre>
<p>eino-ext 目前支持的格式:HTML、PDF(支持逐页或合并)、Word(docx,可按节切分)、Excel(xlsx,逐行转为Document)。</p>
<hr>
<h3>四、<code>Transformer</code>:负责将大块文档切分为小块</h3>
<p>一篇几万字的文章,不切片根本无法喂入向量数据库。<code>Transformer</code> 就是完成这个切割任务的组件:</p>
<pre><code class="hljs language-go" lang="go"><span class="hljs-comment">// 源码:eino/components/document/interface.go</span>
<span class="hljs-keyword">type</span> Transformer <span class="hljs-keyword">interface</span> {
    Transform(ctx context.Context, src []*schema.Document, opts ...TransformerOption) ([]*schema.Document, <span class="hljs-type">error</span>)
}
</code></pre>
<p>输入一批Document,输出更多更小的Document。eino-ext 提供了四种切片策略,适用不同场景。</p>
<h4>策略 1:RecursiveSplitter(通用首选)</h4>
<p>源码:<code>eino-ext/components/document/transformer/splitter/recursive/recursive.go</code></p>
<p>按分隔符<strong>递归</strong>切分。先尝试 <code>\n</code>,块过大则换 <code>.</code>,再过大则换 <code>?</code>……直到每个块都符合大小要求为止。</p>
<pre><code class="hljs language-go" lang="go">splitter, _ := recursive.NewSplitter(ctx, &recursive.Config{
    ChunkSize:   <span class="hljs-number">1500</span>,   <span class="hljs-comment">// 每块最多 1500 字符</span>
    OverlapSize: <span class="hljs-number">300</span>,    <span class="hljs-comment">// 相邻块重叠 300 字符,保留边界上下文</span>
    Separators:  []<span class="hljs-type">string</span>{<span class="hljs-string">"n"</span>, <span class="hljs-string">"."</span>, <span class="hljs-string">"?"</span>, <span class="hljs-string">"!"</span>},
    KeepType:    recursive.KeepTypeNone,  <span class="hljs-comment">// 分隔符本身不保留</span>
})
</code></pre>
<p><code>OverlapSize</code> 是重要参数:切块边界处的内容会在相邻两块中重复,有效避免句子被切断后语义丢失。</p>
<pre><code class="hljs language-go" lang="go"><span class="hljs-comment">// 示例(源码:recursive/examples/main.go)</span>
data, _ := os.ReadFile(<span class="hljs-string">"./document.md"</span>)
docs, _ := splitter.Transform(ctx, []*schema.Document{{Content: <span class="hljs-type">string</span>(data)}})
fmt.Printf(<span class="hljs-string">"切成了 %d 块n"</span>, <span class="hljs-built_in">len</span>(docs))
</code></pre>
<h4>策略 2:MarkdownHeaderSplitter(结构化文档利器)</h4>
<p>源码:<code>eino-ext/components/document/transformer/splitter/markdown/header.go</code></p>
<p>按照Markdown标题层级进行切分,每块<strong>自动继承父级标题</strong>信息写入MetaData:</p>
<pre><code class="hljs language-go" lang="go">splitter, _ := markdown.NewHeaderSplitter(ctx, &markdown.HeaderConfig{
    Headers: <span class="hljs-keyword">map</span>[<span class="hljs-type">string</span>]<span class="hljs-type">string</span>{
        <span class="hljs-string">"#"</span>:  <span class="hljs-string">"chapter"</span>,   <span class="hljs-comment">// 一级标题映射到 metadata.key "chapter"</span>
        <span class="hljs-string">"##"</span>: <span class="hljs-string">"section"</span>,   <span class="hljs-comment">// 二级标题映射到 metadata.key "section"</span>
    },
    TrimHeaders: <span class="hljs-literal">true</span>,  <span class="hljs-comment">// 切分后块中不包含标题行本身</span>
})
</code></pre>
<p>切分后的Document携带结构化MetaData:</p>
<pre><code class="hljs language-json" lang="json"><span class="hljs-punctuation">{</span>
  <span class="hljs-attr">"content"</span><span class="hljs-punctuation">:</span> <span class="hljs-string">"Go 的并发模型基于 CSP..."</span><span class="hljs-punctuation">,</span>
  <span class="hljs-attr">"meta_data"</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">{</span>
    <span class="hljs-attr">"chapter"</span><span class="hljs-punctuation">:</span> <span class="hljs-string">"第三章 并发编程"</span><span class="hljs-punctuation">,</span>
    <span class="hljs-attr">"section"</span><span class="hljs-punctuation">:</span> <span class="hljs-string">"3.1 Goroutine 基础"</span>
  <span class="hljs-punctuation">}</span>
<span class="hljs-punctuation">}</span>
</code></pre>
<p>检索时可按章节过滤,而非全量搜索,精准度大幅提升。</p>
<h4>策略 3:HTMLHeaderSplitter</h4>
<p>源码:<code>eino-ext/components/document/transformer/splitter/html/header.go</code></p>
<p>与MarkdownHeaderSplitter思路一致,但处理HTML的 <code><h1></code>~<code><h6></code> 标签。适用于爬取的结构化网页文档,通过DFS递归遍历DOM树,追踪标题层级进行切分。</p>
<h4>策略 4:SemanticSplitter(质量最高但速度最慢)</h4>
<p>源码:<code>eino-ext/components/document/transformer/splitter/semantic/semantic.go</code></p>
<p>前三种策略按字符或结构切分,不关心语义完整性。SemanticSplitter 采用更高级的做法:先将文本embed成向量,计算相邻段落的余弦距离,在<strong>语义发生跳跃的位置切断</strong>:</p>
<pre><code class="hljs language-go" lang="go">splitter, _ := semantic.NewSplitter(ctx, &semantic.Config{
    Embedding:  myEmbedder,   <span class="hljs-comment">// 必须接入 Embedding 模型</span>
    Percentile: <span class="hljs-number">0.9</span>,          <span class="hljs-comment">// 距离超过第 90 百分位时才切断</span>
    BufferSize: <span class="hljs-number">1</span>,            <span class="hljs-comment">// 对比时考虑前后各 1 句话的上下文</span>
    MinChunkSize: <span class="hljs-number">100</span>,        <span class="hljs-comment">// 小于该值的块直接丢弃</span>
})
</code></pre>
<p>工作流程如下:</p>
<ol>
<li>先用分隔符粗切成句子</li>
<li>每个句子附带前后BufferSize句话的上下文拼接在一起</li>
<li>整体embed成向量</li>
<li>计算相邻向量的余弦距离</li>
<li>距离超过Percentile阈值的位置,表明语义出现转折,在此处真正切断</li>
</ol>
<p>代价明显:每次切片都需要调用Embedding API,速度远低于前三种策略。仅在质量要求极高、且能承受API调用开销时使用。</p>
<hr>
<h3>五、将三步串联为一条完整流水线</h3>
<p>单独使用每个组件固然可行,但 eino 真正的价值在于通过 <code>compose.Graph</code> 将它们编排成一条端到端流水线。</p>
<p>下面是 eino-examples 中 <code>quickstart/eino_assistant</code> 的知识入库流水线,注释已做优化:</p>
<pre><code class="hljs language-go" lang="go"><span class="hljs-comment">// 源码:eino-examples/quickstart/eino_assistant/eino/knowledgeindexing/orchestration.go</span>
<span class="hljs-function"><span class="hljs-keyword">func</span> <span class="hljs-title">BuildKnowledgeIndexing</span><span class="hljs-params">(ctx context.Context)</span></span> (compose.Runnable[document.Source, []<span class="hljs-type">string</span>], <span class="hljs-type">error</span>) {
    g := compose.NewGraph[document.Source, []<span class="hljs-type">string</span>]()    <span class="hljs-comment">// 节点 1:读取文件(本地 Markdown)</span>
    fileLoader, _ := file.NewFileLoader(ctx, &file.FileLoaderConfig{})
    g.AddLoaderNode(<span class="hljs-string">"Loader"</span>, fileLoader)    <span class="hljs-comment">// 节点 2:按 Markdown 标题切片</span>
    splitter, _ := markdown.NewHeaderSplitter(ctx, &markdown.HeaderConfig{
        Headers: <span class="hljs-keyword">map</span>[<span class="hljs-type">string</span>]<span class="hljs-type">string</span>{<span class="hljs-string">"#"</span>: <span class="hljs-string">"title"</span>, <span class="hljs-string">"##"</span>: <span class="hljs-string">"section"</span>},
    })
    g.AddDocumentTransformerNode(<span class="hljs-string">"Splitter"</span>, splitter)    <span class="hljs-comment">// 节点 3:存入向量数据库(返回存储 ID 列表)</span>
    indexer, _ := newVectorIndexer(ctx)
    g.AddIndexerNode(<span class="hljs-string">"Indexer"</span>, indexer)    <span class="hljs-comment">// 连线:START -> Loader -> Splitter -> Indexer -> END</span>
    g.AddEdge(compose.START, <span class="hljs-string">"Loader"</span>)
    g.AddEdge(<span class="hljs-string">"Loader"</span>, <span class="hljs-string">"Splitter"</span>)
    g.AddEdge(<span class="hljs-string">"Splitter"</span>, <span class="hljs-string">"Indexer"</span>)
    g.AddEdge(<span class="hljs-string">"Indexer"</span>, compose.END)    <span class="hljs-keyword">return</span> g.Compile(ctx, compose.WithGraphName(<span class="hljs-string">"KnowledgeIndexing"</span>))
}
</code></pre>
<p>运行方式简洁直接:</p>
<pre><code class="hljs language-go" lang="go">pipeline, _ := BuildKnowledgeIndexing(ctx)
ids, _ := pipeline.Invoke(ctx, document.Source{URI: <span class="hljs-string">"/docs/manual.md"</span>})
fmt.Printf(<span class="hljs-string">"已存入 %d 个知识块n"</span>, <span class="hljs-built_in">len</span>(ids))
</code></pre>
<p>这种流水线设计带来的好处:</p>
<ul>
<li><strong>节点可独立测试</strong>:例如单独测试Splitter的切片效果,无需依赖Loader</li>
<li><strong>可观测性</strong>:通过插入callback,监控每一步的耗时和输出块数</li>
<li><strong>可替换性</strong>:将 <code>RecursiveSplitter</code> 替换为 <code>MarkdownHeaderSplitter</code>,其他节点完全无需改动</li>
</ul>
<hr>
<h3>六、一个重要原则:MetaData 只能增加不能删除</h3>
<p>这一点值得特别强调。<code>Transformer</code> 切片时,<strong>必须将原 Document 的 MetaData 完整复制给每个切片</strong>,只能追加新key,绝不能删除已有key。</p>
<p>原因很清晰:Document 的溯源信息(来源文件、章节、时间戳)在流水线最前端由 Loader/Parser 打下。如果 Splitter 丢弃了这些信息,下游将无法追溯“这条知识从哪儿来”——出问题时无法排错,用户问“你的依据出自哪里?”也无法回答。</p>
<p>eino-ext 的各 Splitter 实现均遵守此规则,切片时执行 <code>deep copy(原 MetaData) + 追加新key</code>。</p>
<hr>
<h3>小结</h3>
<pre><code class="hljs language-css" lang="css">原始文件 (PDF / <span class="hljs-selector-tag">HTML</span> / MD / Word)
    ↓  Loader(搬运工)
字节流
    ↓  Parser(翻译官,TextParser / HTMLParser / ExtParser)
<span class="hljs-selector-attr">[Document]</span>          ← 完整文档,可能数万字
    ↓  Transformer(切割机)
<span class="hljs-selector-attr">[Doc, Doc, Doc...]</span>  ← 每块 <span class="hljs-number">1000</span>~<span class="hljs-number">2000</span> 字
    ↓  Indexer
向量数据库
</code></pre>
<p><strong>如何选择 Splitter?一张表格帮你决策:</strong></p><table><thead><tr><th>场景</th><th>推荐选择</th></tr></thead><tbody><tr><td>通用文本,不关心层级结构</td><td><code>RecursiveSplitter</code></td></tr><tr><td>带标题层级的 Markdown 文档</td><td><code>MarkdownHeaderSplitter</code></td></tr><tr><td>爬取的结构化网页</td><td><code>HTMLHeaderSplitter</code></td></tr><tr><td>质量优先,不介意 API 调用成本</td><td><code>SemanticSplitter</code></td></tr></tbody></table>
<p>Document 组件是 RAG 体系的地基。地基质量直接影响检索精度:块切得太大,会超出上下文窗口限制;切得太小,可能导致上下文丢失;切错位置,语义断裂。这个环节值得投入时间仔细选型与调优。</p>    </section>
  </article>
            <section class="mobilepromptdetail_section">
    <div class="mobilepromptdetail_prevnext">
            <a href="https://m.cn486.com/news/4194256/" title="上一篇 技能终局Agent化:6个Harness编译器孵化独立Agent"><span>上一篇</span><strong>技能终局Agent化:6个Harness编译器孵化独立Agent</strong></a>
                  <a href="https://m.cn486.com/news/4194263/" title="下一篇 从零开发Agent CLI第二期:框架搭建与子命令路由实战完整指南教程"><span>下一篇</span><strong>从零开发Agent CLI第二期:框架搭建与子命令路由实战完整指南教程</strong></a>
          </div>
  </section>
    <section class="mobilepromptdetail_section">
    <div class="mobiletutorialdetail_note">
      <strong>免责声明</strong>
      <p>本网站新闻资讯均来自公开渠道,力求准确但不保证绝对无误,内容观点仅代表作者本人,与本站无关。若涉及侵权,请联系我们处理。本站保留对声明的修改权,最终解释权归本站所有。</p>
    </div>
  </section>
  <section class="mobilepromptdetail_section">
    <div class="mobilepromptdetail_sectionhead"><h2>相关阅读</h2><a href="/moxingjishu/1.html">更多</a></div>
    <div class="mobilepromptdetail_related">
                      </div>
  </section>
  <section class="mobilepromptdetail_section">
    <div class="mobilepromptdetail_linktabs">
      <button class="active" type="button" data-detail-link-tab="tutorial">最新教程</button>
      <button type="button" data-detail-link-tab="news">最新资讯</button>
    </div>
    <div class="mobilepromptdetail_links">
      <div class="mobilepromptdetail_linkcol mobilepromptdetail_linkpanel active" data-detail-link-panel="tutorial">
        <h3>最新教程</h3>
        <a href="https://m.cn486.com/news/4163727/" title="Stable Diffusion WebUI整合包下载与模型放置全指南">Stable Diffusion WebUI整合包下载与模型放置全指南</a><a href="https://m.cn486.com/news/4163728/" title="HunyuanVideo安装失败排查指南:依赖、显存与工作流问题解决">HunyuanVideo安装失败排查指南:依赖、显存与工作流问题解决</a><a href="https://m.cn486.com/news/4163729/" title="Runway官网入口与使用指南:下载注册及常见问题全解析">Runway官网入口与使用指南:下载注册及常见问题全解析</a><a href="https://m.cn486.com/news/4163730/" title="Notion AI新手入门指南:从下载到模板设置的完整教程">Notion AI新手入门指南:从下载到模板设置的完整教程</a><a href="https://m.cn486.com/news/4163740/" title="GitHub Copilot安装指南:JetBrains插件市场一键配置与激活全流程">GitHub Copilot安装指南:JetBrains插件市场一键配置与激活全流程</a><a href="https://m.cn486.com/news/4163741/" title="2026年ComfyUI安装与配置终极指南:从零部署到高效出图全流程解析">2026年ComfyUI安装与配置终极指南:从零部署到高效出图全流程解析</a><a href="https://m.cn486.com/news/4163742/" title="CogVideoX安装包获取与部署指南:从下载到剪辑机配置的完整教程">CogVideoX安装包获取与部署指南:从下载到剪辑机配置的完整教程</a><a href="https://m.cn486.com/news/4163769/" title="2024图像识别实战精选:基于EasyDL的完整案例解析与测评">2024图像识别实战精选:基于EasyDL的完整案例解析与测评</a>      </div>
      <div class="mobilepromptdetail_linkcol mobilepromptdetail_linkpanel" data-detail-link-panel="news">
        <h3>最新资讯</h3>
        <a href="https://m.cn486.com/news/4194652/" title="AI规范驱动编程 Harness实战指南">AI规范驱动编程 Harness实战指南</a><a href="https://m.cn486.com/news/4194649/" title="AI Loop全面揭秘:让AI像人一样自主完成任务的核心机制与最佳实践">AI Loop全面揭秘:让AI像人一样自主完成任务的核心机制与最佳实践</a><a href="https://m.cn486.com/news/4194631/" title="域名双栖价值分析:永久茶到永久查的阿里云品牌托底策略">域名双栖价值分析:永久茶到永久查的阿里云品牌托底策略</a><a href="https://m.cn486.com/news/4194644/" title="企业低成本算力自救指南:从硬件选型到IDC托管完整对比方案">企业低成本算力自救指南:从硬件选型到IDC托管完整对比方案</a><a href="https://m.cn486.com/news/4194645/" title="多AI聚合误区排行榜:交叉验证五大重复错误解析">多AI聚合误区排行榜:交叉验证五大重复错误解析</a><a href="https://m.cn486.com/news/4194646/" title="物流提单智能解析工具推荐:海运空运自动化对比评测">物流提单智能解析工具推荐:海运空运自动化对比评测</a><a href="https://m.cn486.com/news/4194647/" title="LLM Token优化:上下文压缩降低Agent成本关键">LLM Token优化:上下文压缩降低Agent成本关键</a><a href="https://m.cn486.com/news/4194632/" title="GitHub项目README.md编写:2024年十大最佳实践指南">GitHub项目README.md编写:2024年十大最佳实践指南</a>      </div>
    </div>
  </section>
</main>
<footer class="mobilehome_footer">
  <div class="mobilehome_footerbrand">
    <img src="/style/style2026/mobile/image/logo.png" alt="菜鸟AI" />
    <div class="mobilehome_footerbrandtext">
      <strong>菜鸟AI</strong>
      <span>www.cn486.com</span>
    </div>
  </div>
  <p class="mobilehome_footerslogan">菜鸟AI,聚合 AI 提示词、教程、资讯和实用工具内容。</p>
  <div class="mobilehome_footerlinks">
    <a href="/aitsc/1.html" title="提示词模板">提示词模板</a>
    <a href="/aijiaocheng/1.html" title="AI教程">AI教程</a>
    <a href="/zixun/1.html" title="最新资讯">最新资讯</a>
    <a href="/aiapp/1.html" title="热门应用">热门应用</a>
    <a href="/tag/" title="标签聚合">标签聚合</a>
    <a href="/newlist/1" title="最新更新">最新更新</a>
  </div>
  <div class="mobilehome_footerdivider"></div>
  <div class="mobilehome_footercopyright">Copyright © 2019-2020 菜鸟AI All Reserved</div>
</footer>
<div class="mobilehome_authmask"></div>
<div class="mobilehome_authmodal" id="mobilehomeAuthModal">
  <div class="mobilehome_authinner">
    <div class="mobilehome_authhead">
      <div>
        <strong>欢迎回来</strong>
        <span>登录或注册后,可保存提示词和历史记录</span>
      </div>
      <button class="mobilehome_authclose" type="button" data-auth-close>×</button>
    </div>
    <div class="mobilehome_authtabs">
      <button class="mobilehome_authtab active" type="button" data-auth-tab="login">登录</button>
      <button class="mobilehome_authtab" type="button" data-auth-tab="signup">注册</button>
    </div>
    <div class="mobilehome_authpanel active" data-auth-panel="login">
      <div class="mobilehome_authfield">
        <label>用户</label>
        <input type="text" placeholder="请输入用户" data-auth-login-account autocomplete="username" />
      </div>
      <div class="mobilehome_authfield">
        <label>密码</label>
        <input type="password" placeholder="请输入密码" data-auth-login-password autocomplete="current-password" />
      </div>
      <button class="mobilehome_authsubmit" type="button" data-auth-submit="login">立即登录</button>
      <div class="mobilehome_authtips" data-auth-message="login">登录后可同步收藏、历史记录和常用模板</div>
    </div>
    <div class="mobilehome_authpanel" data-auth-panel="signup">
      <div class="mobilehome_authfield">
        <label>用户</label>
        <input type="text" placeholder="请输入用户" data-auth-signup-account autocomplete="username" />
      </div>
      <div class="mobilehome_authfield">
        <label>设置密码</label>
        <input type="password" placeholder="请设置登录密码" data-auth-signup-password autocomplete="new-password" />
      </div>
      <div class="mobilehome_authfield">
        <label>确认密码</label>
        <input type="password" placeholder="请再次输入密码" data-auth-signup-repassword autocomplete="new-password" />
      </div>
      <button class="mobilehome_authsubmit" type="button" data-auth-submit="signup">创建账号</button>
      <div class="mobilehome_authtips" data-auth-message="signup">注册即表示同意服务条款与隐私政策</div>
    </div>
  </div>
</div>
<script src="/style/style2026/mobile/js/common.js"></script>
<script>
var _hmt = _hmt || [];
(function() {
  var hm = document.createElement("script");
  hm.src = "https://hm.baidu.com/hm.js?b1da9d0df3e9fa6302d4a5bfdb96b4fa";
  var s = document.getElementsByTagName("script")[0]; 
  s.parentNode.insertBefore(hm, s);
})();
</script>

<script>
(function () {
  var fallback = "/style/style2026/mobile/image/logo.png";
  function markLoaded(img) {
    img.classList.add("is-loaded");
  }
  function bindImage(img) {
    if (!img || img.dataset.safeImageBound === "1") return;
    img.dataset.safeImageBound = "1";
    img.addEventListener("load", function () {
      markLoaded(img);
    });
    img.addEventListener("error", function () {
      if (img.dataset.fallbackApplied === "1") {
        markLoaded(img);
        return;
      }
      img.dataset.fallbackApplied = "1";
      img.src = fallback;
    });
    if (img.complete) {
      if (img.naturalWidth > 0) {
        markLoaded(img);
      } else if (img.src !== fallback) {
        img.dataset.fallbackApplied = "1";
        img.src = fallback;
      }
    }
  }
  function scanImages() {
    var images = document.querySelectorAll(".mobilehome_page img");
    for (var i = 0; i < images.length; i++) {
      bindImage(images[i]);
    }
  }
  function observeImages() {
    if (!window.MutationObserver || !document.body) return;
    var observer = new MutationObserver(function (mutations) {
      for (var i = 0; i < mutations.length; i++) {
        var nodes = mutations[i].addedNodes;
        for (var j = 0; j < nodes.length; j++) {
          var node = nodes[j];
          if (!node || node.nodeType !== 1) continue;
          if (node.matches && node.matches("img")) {
            bindImage(node);
          }
          if (node.querySelectorAll) {
            var nestedImages = node.querySelectorAll("img");
            for (var k = 0; k < nestedImages.length; k++) {
              bindImage(nestedImages[k]);
            }
          }
        }
      }
    });
    observer.observe(document.body, { childList: true, subtree: true });
  }
  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", function () {
      scanImages();
      observeImages();
    });
  } else {
    scanImages();
    observeImages();
  }
})();
</script>
<script>
(function () {
  var apiBase = "/index.php?m=member&c=mini_ai&a=";
  var mask = document.querySelector(".mobilehome_authmask");
  var modal = document.querySelector(".mobilehome_authmodal");
  var loginButton = document.querySelector('[data-auth-open="login"]');
  var signupButton = document.querySelector('[data-auth-open="signup"]');
  var tabs = document.querySelectorAll("[data-auth-tab]");
  var panels = document.querySelectorAll("[data-auth-panel]");

  function setMessage(type, text) {
    var node = document.querySelector('[data-auth-message="' + type + '"]');
    if (node) node.textContent = text;
  }

  function switchAuthTab(target) {
    for (var i = 0; i < tabs.length; i++) {
      tabs[i].classList.toggle("active", tabs[i].getAttribute("data-auth-tab") === target);
    }
    for (var j = 0; j < panels.length; j++) {
      panels[j].classList.toggle("active", panels[j].getAttribute("data-auth-panel") === target);
    }
  }

  function openAuth(target) {
    if (!mask || !modal) return;
    switchAuthTab(target || "login");
    mask.classList.add("active");
    modal.classList.add("active");
    document.body.style.overflow = "hidden";
  }

  function closeAuth() {
    if (!mask || !modal) return;
    mask.classList.remove("active");
    modal.classList.remove("active");
    document.body.style.overflow = "";
  }

  function renderUser(data) {
    var isLogin = data && parseInt(data.is_login || 0, 10) === 1;
    if (!loginButton || !signupButton) return;
    if (isLogin) {
      var name = data.nickname || data.username || data.email || "已登录";
      loginButton.textContent = name;
      loginButton.removeAttribute("data-auth-open");
      loginButton.classList.add("is-logined");
      signupButton.textContent = "退出";
      signupButton.setAttribute("data-auth-open", "logout");
      signupButton.classList.add("ghost");
    } else {
      loginButton.textContent = "登录";
      loginButton.setAttribute("data-auth-open", "login");
      loginButton.classList.remove("is-logined");
      signupButton.textContent = "注册";
      signupButton.setAttribute("data-auth-open", "signup");
      signupButton.classList.remove("ghost");
    }
  }

  function ajaxPost(action, data, done) {
    if (!window.jQuery) return;
    $.post(apiBase + action, data, function (res) {
      done(res || {});
    }, "json").fail(function () {
      done({status: 0, msg: "请求失败,请稍后重试"});
    });
  }

  function fetchUser() {
    if (!window.jQuery) return;
    $.ajax({
      url: apiBase + "public_quota&_t=" + new Date().getTime(),
      dataType: "json",
      timeout: 5000,
      cache: false
    }).done(function (res) {
      if (res && res.status == 1) renderUser(res.data || {});
    });
  }

  function submitLogin(button) {
    var account = document.querySelector("[data-auth-login-account]");
    var password = document.querySelector("[data-auth-login-password]");
    var username = account ? account.value.trim() : "";
    var pass = password ? password.value : "";
    if (!username || !pass) {
      setMessage("login", "请输入账号和密码");
      return;
    }
    button.disabled = true;
    setMessage("login", "正在登录...");
    ajaxPost("public_login", {username: username, password: pass}, function (res) {
      button.disabled = false;
      if (res.status == 1) {
        setMessage("login", "登录成功");
        window.location.reload();
      } else {
        setMessage("login", res.msg || "登录失败,请检查账号密码");
      }
    });
  }

  function submitSignup(button) {
    var account = document.querySelector("[data-auth-signup-account]");
    var password = document.querySelector("[data-auth-signup-password]");
    var repassword = document.querySelector("[data-auth-signup-repassword]");
    var username = account ? account.value.trim() : "";
    var pass = password ? password.value : "";
    var pass2 = repassword ? repassword.value : "";
    if (!username || !pass) {
      setMessage("signup", "请输入账号和密码");
      return;
    }
    if (pass !== pass2) {
      setMessage("signup", "两次输入的密码不一致");
      return;
    }
    button.disabled = true;
    setMessage("signup", "正在创建账号...");
    ajaxPost("public_register", {username: username, password: pass}, function (res) {
      button.disabled = false;
      if (res.status == 1) {
        setMessage("signup", "注册成功");
        window.location.reload();
      } else {
        setMessage("signup", res.msg || "注册失败,请稍后重试");
      }
    });
  }

  document.addEventListener("click", function (event) {
    var open = event.target.closest ? event.target.closest("[data-auth-open]") : null;
    if (open) {
      var target = open.getAttribute("data-auth-open");
      if (target === "logout") {
        ajaxPost("public_logout", {}, function () {
          renderUser({is_login: 0});
        });
      } else {
        openAuth(target);
      }
    }
    var close = event.target.closest ? event.target.closest("[data-auth-close]") : null;
    if (close) closeAuth();
    var tab = event.target.closest ? event.target.closest("[data-auth-tab]") : null;
    if (tab) switchAuthTab(tab.getAttribute("data-auth-tab"));
    var submit = event.target.closest ? event.target.closest("[data-auth-submit]") : null;
    if (submit) {
      var type = submit.getAttribute("data-auth-submit");
      if (type === "login") submitLogin(submit);
      if (type === "signup") submitSignup(submit);
    }
  });
  if (mask) mask.addEventListener("click", closeAuth);
  fetchUser();
})();
</script>
<script>
(function(){var tabs=document.querySelectorAll("[data-detail-link-tab]"),panels=document.querySelectorAll("[data-detail-link-panel]");for(var i=0;i<tabs.length;i++){tabs[i].addEventListener("click",function(){var target=this.getAttribute("data-detail-link-tab");for(var j=0;j<tabs.length;j++)tabs[j].classList.toggle("active",tabs[j]===this);for(var k=0;k<panels.length;k++)panels[k].classList.toggle("active",panels[k].getAttribute("data-detail-link-panel")===target);});}})();
</script>
</div>
</body>
</html>