18|如何实现波波熊学伴核心工作流(二)

你好,我是月影。

在上一节课,我们梳理了波波熊学伴核心工作流,并实现了它的上半部分,即如何改写问题、搜索资料和快速回答。

这一节课我们将继续实现波波熊学伴核心工作流的下半部分,让我们先来回顾一下整体流程:

图片

我们来看下半部分,当我们完成搜索和资料整理后,要开始撰写大纲,然后对大纲进一步进行内容拆解,最后撰写具体文章详情。

我们这就一一往下看。

如何撰写内容大纲

首先是大纲的撰写。大纲撰写的提示词如下:

# Overall Rules to follow
1. Do response in 简体中文 and output **correct JSON Format ONLY**.
2. Do NOT explain your response.
3. DO NOT mention the student' Information when you generate the content.

## Student Information
- gender: {{gender}}
- age: {{age}}
- student location: 中国

## Study Style
The article must always adhere to the following elements:
- Communication-Style: Simple and Clear
- Tone-Style: Interesting and Vivid
- Reasoning-Framework: Intuitive
- Language: 简体中文

# Role and Goals
你正在模拟一个教育家,专门制作针对 {{age}} 岁学生的教育内容大纲,采用<Communication-Style>的行文风格,<Tone-Style>的沟通语气,<Reasoning-Framework>的结构化思维方式,遵循以下准则:
1. 学生会给你一个好奇心问题,你需要结合学生已有的知识和认知,比如身边常见的的事物,给出回答。
2. 使用PBL 方法(Problem-Based Learning)和建构主义学习理论,通过提出实际问题激发学生的学习兴趣和探究欲望,用一系列的问题(topic)逐步引导学生理解和解决这个问题。提出的topic需要抽象递进,由浅入深,直到达至本质。
3. [IMPORTANT!]该学生年龄是 {{age}} 岁,务必用适合学生年龄的能理解的问题来引导学生。
{% if(age < 8) %}
4. 由于该学生年龄小于 8 岁,你最多输出 3 个 topic。
{% else %}
4. 由于该学生年龄大于 8 岁,你可以输出 3 到 7 个 topic。
{% endif %}
5. Generate prompts for the a cover image, written in English, store in 'image_prompt'。

# Output Format(JSON)
你输出的 JSON 格式如下,这里有一个“木头为什么会燃烧”的示例:

\`\`\`
{"question":"木头为什么会燃烧?","topics":[{"topic":"燃烧是一种什么物理现象?"},{"topic":"是什么使得物质能够燃烧?"},{"topic":"为什么木头能燃烧而铁块不能?"},{"topic":"木头燃烧时产生了哪些物质?"},{"topic":"燃烧反应的能量从哪里来?",},{"topic":"如果没有空气,我们能不能用其他气体让木头燃烧?",}],"image_prompt":"A cozy campfire scene with children gathered around, roasting marshmallows and telling stories. The fire is crackling, and the logs are glowing, casting a warm, golden light on the faces of the kids. The image conveys a sense of warmth, camaraderie, and the joy of shared experiences around the fire.","introduction":"想象一下,当你在寒冷的冬夜点燃一堆篝火,温暖的火光跳跃着,照亮了周围。木头是如何燃烧的呢?为什么石头就不会像木头那样燃烧呢?让我们一起探索燃烧的秘密,了解为什么一些物体可以燃烧,而另一些则不能。通过这个问题,我们不仅会学习到燃烧的科学原理,还会发现更多关于火的有趣事实。"}
\`\`\`

同样为了聚焦重点,我简化了Study Style的配置变量,只需要传age和gender进去即可。

在这里我们关注一个细节,我们已经知道,Ling框架的提示词默认支持nunjucks模板,所以我们可以通过if分支动态生成提示词,来针对不同年龄的孩子输出不同深度的内容:

{% if(age < 8) %}
4. 由于该学生年龄小于 8 岁,你最多输出 3 个 topic。
{% else %}
4. 由于该学生年龄大于 8 岁,你可以输出 3 到 7 个 topic。
{% endif %}

现在我们在Trae中打开Bearbobo Discovery项目,创建 lib/prompts/outline.tpl.ts ,内容如下:

export default `
# Overall Rules to follow
1. Do response in 简体中文 and output **correct JSON Format ONLY**.
2. Do NOT explain your response.
3. DO NOT mention the student' Information when you generate the content.

## Student Information
- gender: {{gender}}
- age: {{age}}
- student location: 中国

## Study Style
The article must always adhere to the following elements:
- Communication-Style: Simple and Clear
- Tone-Style: Interesting and Vivid
- Reasoning-Framework: Intuitive
- Language: 简体中文

# Role and Goals
你正在模拟一个教育家,专门制作针对 {{age}} 岁学生的教育内容大纲,采用<Communication-Style>的行文风格,<Tone-Style>的沟通语气,<Reasoning-Framework>的结构化思维方式,遵循以下准则:
1. 学生会给你一个好奇心问题,你需要结合学生已有的知识和认知,比如身边常见的的事物,给出回答。
2. 使用PBL 方法(Problem-Based Learning)和建构主义学习理论,通过提出实际问题激发学生的学习兴趣和探究欲望,用一系列的问题(topic)逐步引导学生理解和解决这个问题。提出的topic需要抽象递进,由浅入深,直到达至本质。
3. [IMPORTANT!]该学生年龄是 {{age}} 岁,务必用适合学生年龄的能理解的问题来引导学生。
{% if(age < 8) %}
4. 由于该学生年龄小于 8 岁,你最多输出 3 个 topic。
{% else %}
4. 由于该学生年龄大于 8 岁,你可以输出 3 到 7 个 topic。
{% endif %}
5. Generate prompts for the a cover image, written in English, store in 'image_prompt'。

# Output Format(JSON)
你输出的 JSON 格式如下,这里有一个“木头为什么会燃烧”的示例:
\`\`\`
{"question":"木头为什么会燃烧?","topics":[{"topic":"燃烧是一种什么物理现象?"},{"topic":"是什么使得物质能够燃烧?"},{"topic":"为什么木头能燃烧而铁块不能?"},{"topic":"木头燃烧时产生了哪些物质?"},{"topic":"燃烧反应的能量从哪里来?",},{"topic":"如果没有空气,我们能不能用其他气体让木头燃烧?",}],"image_prompt":"A cozy campfire scene with children gathered around, roasting marshmallows and telling stories. The fire is crackling, and the logs are glowing, casting a warm, golden light on the faces of the kids. The image conveys a sense of warmth, camaraderie, and the joy of shared experiences around the fire.","introduction":"想象一下,当你在寒冷的冬夜点燃一堆篝火,温暖的火光跳跃着,照亮了周围。木头是如何燃烧的呢?为什么石头就不会像木头那样燃烧呢?让我们一起探索燃烧的秘密,了解为什么一些物体可以燃烧,而另一些则不能。通过这个问题,我们不仅会学习到燃烧的科学原理,还会发现更多关于火的有趣事实。"}
\`\`\`
`;

接着,我们改写server.ts,我们直接把上一节课写的quick-answer改成generate,因为有Ling框架的管理,我们完全可以把完整流程放在一个接口中:

app.get('/generate', async (req, res) => {
    const userConfig = {
        gender: 'female',
        age: '6',
    };
    const question = req.query.question as string;
    const query = req.query.query as string;
    let searchResults = '';
    if (query) {
        const queries = query.split(';');
        const promises = queries.map((query) => search(query));

        searchResults = JSON.stringify(await Promise.all(promises));
    }
    // ------- The work flow start --------
    const ling = new Ling(config);
    const quickAnswerBot = ling.createBot('quick-answer', {}, {
        response_format: { type: 'text' }
    });
    quickAnswerBot.addPrompt(quickAnswerPrompt, userConfig);

    const outlineBot = ling.createBot('outline');
    outlineBot.addPrompt(outlinePrompt, userConfig);

    if (searchResults) {
        quickAnswerBot.addPrompt(`参考资料:\n${searchResults}`);
        outlineBot.addPrompt(`参考资料:\n${searchResults}`);
    }

    quickAnswerBot.chat(question);
    outlineBot.chat(question);

    ling.close();

    // setting below headers for Streaming the data
    res.writeHead(200, {
        'Content-Type': "text/event-stream",
        'Cache-Control': "no-cache",
        'Connection': "keep-alive"
    });

    pipeline((ling.stream as any), res);
});

上面的代码是基于上一节课的quick-answer接口修改的,我们新创建了一个outlineBot,然后将内容输出。

生成封面图片

考虑到我们的提示词生成的内容包含封面图片的英文提示,我们要对它进行处理。

首先我们添加 /lib/service/generate-image.ts ,它是一个用flux.ai生成图片的模块。

代码如下:

export async function generateImage(prompt: string): Promise<{ error: string, url: string }> {
    const endpoint = process.env.VITE_FLUX_END_POINT;
    const modelName = process.env.VITE_FLUX_MODEL_NAME;

    const payload = {
        prompt,
        width: 1024,
        height: 1024,
        steps: 40,
        prompt_upsampling: true,
        seed: 42,
        guidance: 3,
        sampler: 'dpmpp_2m',
        safety_tolerance: 2,
    };

    const headers: any = {
        'Content-Type': 'application/json',
        'x-key': process.env.VITE_FLUX_API_KEY,
    };

    const res = await fetch(`${endpoint}/${modelName}`, {
        headers,
        method: 'POST',
        body: JSON.stringify(payload),
    });
    const id = (await res.json()).id;
    const resultUrl = `${endpoint}/get_result?id=${id}`;

    do {
        await new Promise((resolve) => setTimeout(resolve, 100));
        const result = await fetch(resultUrl);
        const resultJson = await result.json();
        if (resultJson.status === 'Pending') {
            continue;
        }
        const sample = resultJson.result?.sample;
        if (sample) {
            return {
                error: '',
                url: sample,
            }
        } else {
            return {
                error: 'No result',
                url: 'https://res.bearbobo.com/resource/upload/vNg4ALJv/6659895-ox36cbkajrr.png',
            }
        }
    } while (1);
    return { error: '', url: '' };
}

这部分代码,我们之前的课程中有介绍过,虽然当时调用是放在客户端,但是和放在服务端没什么本质区别,这里就不再重复了。

改写sever逻辑

接着我们将server的接口从quick-answer改写为generate,代码如下:

...
import outlinePrompt from './lib/prompts/outline.tpl.ts';
import { generateImage } from './lib/service/generate-image.ts';
...
app.get('/generate', async (req, res) => {
    const userConfig = {
        gender: 'female',
        age: '6',
    };
    const question = req.query.question as string;
    const query = req.query.query as string;
    let searchResults = '';
    if (query) {
        const queries = query.split(';');
        const promises = queries.map((query) => search(query));

        searchResults = JSON.stringify(await Promise.all(promises));
    }
    // ------- The work flow start --------
    const ling = new Ling(config);
    const quickAnswerBot = ling.createBot('quick-answer', {}, {
        response_format: { type: 'text' }
    });
    quickAnswerBot.addPrompt(quickAnswerPrompt, userConfig);

    const outlineBot = ling.createBot('outline');
    outlineBot.addPrompt(outlinePrompt, userConfig);

    outlineBot.addFilter('image_prompt');
    outlineBot.addListener('string-response', ({ uri, delta }) => {
        ling.handleTask(async () => {
            if (uri.includes('image_prompt')) {
                // generate image
                const { url } = await generateImage(`A full-size picture suitable as a cover for children's picture books that depicts ${delta}. DO NOT use any text or symbols.`);
                ling.sendEvent({ uri: 'cover_image', delta: url });
            }
        });
    });

    if (searchResults) {
        quickAnswerBot.addPrompt(`参考资料:\n${searchResults}`);
        outlineBot.addPrompt(`参考资料:\n${searchResults}`);
    }

    quickAnswerBot.chat(question);
    outlineBot.chat(question);

    ling.close();

    // setting below headers for Streaming the data
    res.writeHead(200, {
        'Content-Type': "text/event-stream",
        'Cache-Control': "no-cache",
        'Connection': "keep-alive"
    });

    pipeline((ling.stream as any), res);
});

在这里,我们创建一个新的outlingBot,并添加提示词。注意,由于AI输出的image_prompt属性不需要发给前端,我们可以通过 outlineBot.addFilter 将它过滤掉,这样可以减少发送给前端的数据量和等待时间。

const outlineBot = ling.createBot('outline');
outlineBot.addPrompt(outlinePrompt, userConfig);

outlineBot.addFilter('image_prompt');

然后是处理image_prompt,我们可以通过监听outlineBot的string-response事件获得完整的image_prompt内容,然后把它发给generateImage方法处理成图像。

因为整个过程是异步过程,所以我们通过 ling.handleTask 处理,这样能确保流式输出不会在异步事件处理完成之前被关闭

outlineBot.addListener('string-response', ({ uri, delta }) => {
    ling.handleTask(async () => {
        if (uri.includes('image_prompt')) {
            // generate image
            const { url } = await generateImage(`A full-size picture suitable as a cover for children's picture books that depicts ${delta}. DO NOT use any text or symbols.`);
            ling.sendEvent({ uri: 'cover_image', delta: url });
        }
    });
});

最后,当我们拿到图片URL后,还要通过 ling.sendEvent 将它发送给前端。

这样我们就实现了server逻辑。

实现前端UI

接着我们创建一个Vue组件BookCard,代码如下:

<script setup lang="ts">
import { marked } from 'marked';
defineProps({
    image: {
        type: String,
        default: '',
    },
    question: {
        type: String,
        default: '',
    },
    description: {
        type: String,
        default: '',
    }
});

const emit = defineEmits(['expand']);
const expand = () => {
    emit('expand');
}
</script>

<template>
    <div v-if="description" class="card" @click="expand">
        <div v-if="image" class="cover">
            <img :src="image" alt="book cover" class="img-fluid" />
        </div>
        <div v-else class="cover animated-border">
            <img src="https://res.bearbobo.com/resource/upload/hR5b3aZt/10wwhys-aszp2n7g6wp.jpeg" alt="book cover"
                class="img-fluid" />
        </div>
        <div class="description">
            <h3>{{ question }}</h3>
            <div v-html="marked.parse(description)"></div>
        </div>
    </div>
</template>

<style scoped>
.card {
    display: flex;
    flex-direction: row;
    flex-wrap: wrap;
    justify-content: space-between;
    align-items: center;
    min-width: 600px;
    border: solid 2px #ccc;
    border-radius: 24px;
    padding: 0;
    margin: 40px 0px;
    cursor: pointer;
    position: relative;
}

.description {
    flex-grow: 2;
    padding: 20px;
    max-width: 600px;
}

.description h3 {
    position: absolute;
    top: 5px;
}

.cover {
    width: 160px;
    height: 160px;
    font-size: 0;
}

.animated-border {
    box-sizing: border-box;
    display: inline-block;
    border: 4px solid transparent;
    border-radius: 20px 0 0 20px;

    /* 两层背景:第一层填充内容区(白色),第二层绘制渐变边框 */
    background-image:
        linear-gradient(#fff, #fff),
        linear-gradient(90deg, #f00, #0f0, #00f);
    background-origin: border-box;
    background-clip: padding-box, border-box;

    background-size: 200% 200%;
    animation: border-slide 4s linear infinite;
}

@keyframes border-slide {
    0% {
        background-position: 0% 50%;
    }

    50% {
        background-position: 100% 50%;
    }

    100% {
        background-position: 0% 50%;
    }
}

.img-fluid {
    border-radius: 16px 0 0 16px;
    width: 100%;
}
</style>

这个组件没有复杂的逻辑,主要是用来展示卡片样式的。

我们修改App.vue:

<script setup lang="ts">
import { ref, type Ref } from 'vue';
import MakeQuestion from './components/MakeQuestion.vue';
import { marked } from 'marked';
import BookCard from './components/BookCard.vue';
import BookDetails from './components/BookDetails.vue';

const question = ref('天空为什么是蓝色的?');

const rewritedQuestions: Ref<Array<string>> = ref([]);

let queries: string[][] = [];

const update = async () => {
  if (!question) return;
  rewritedQuestions.value = [];
  quickAnswer.value = '';
  description.value = '';
  queries = [];

  const endpoint = '/api/make-question';
  const eventSource = new EventSource(`${endpoint}?question=${question.value}`);

  eventSource.addEventListener("message", function (e: any) {
    let { uri, delta } = JSON.parse(e.data);
    let matches = uri.match(/questions\/(\d+)\/question$/);
    if (matches) {
      const index = parseInt(matches[1]);
      rewritedQuestions.value[index] = rewritedQuestions.value[index] || '';
      rewritedQuestions.value[index] += delta;
    }
    matches = uri.match(/questions\/(\d+)\/query\/(\d+)$/);
    if (matches) {
      const index = parseInt(matches[1]);
      const queryIndex = parseInt(matches[2]);
      queries[index] = queries[index] || [];
      queries[index][queryIndex] = queries[index][queryIndex] || '';
      queries[index][queryIndex] += delta;
    }
  });
  eventSource.addEventListener('finished', () => {
    console.log('传输完成');
    eventSource.close();
  });
}

const coverUrl = ref('');
const quickAnswer = ref('');
const description = ref('');
const questionSelected = (question: string, index: number) => {
  quickAnswer.value = '';
  description.value = '';
  const query = queries[index].join(';');
  const endpoint = '/api/generate';
  const eventSource = new EventSource(`${endpoint}?question=${question}&query=${query}`);
  eventSource.addEventListener("message", function (e: any) {
    let { uri, delta } = JSON.parse(e.data);
    if (uri.endsWith('quick-answer')) {
      quickAnswer.value += delta;
    }
    if (uri.endsWith('introduction')) {
      description.value += delta;
    }
    if (uri.endsWith('cover_image')) {
      coverUrl.value = delta;
    }
  });
  eventSource.addEventListener('finished', () => {
    console.log('传输完成');
    eventSource.close();
  });
}
</script>

<template>
  <div class="container>
    <div>
      <label>输入:</label><input class="input" v-model="question" />
      <button @click="update">提交</button>
    </div>
    <div class="output">
      <MakeQuestion :questions="rewritedQuestions" @selection="questionSelected" />
      <div v-html="marked.parse(quickAnswer)"></div>
      <BookCard :image="coverUrl" :description="description" :question="question" @expand="showDetails()" />
    </div>
    <BookDetails :image="coverUrl" :expand="expand" :introduction="description" :question="question" />
  </div>
</template>

<style scoped>
.container {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: start;
  width: 100%;
  height: 100vh;
  font-size: .85rem;
}

.input {
  width: 200px;
}

.output {
  margin-top: 30px;
  min-height: 300px;
  width: 100%;
  text-align: left;
}

button {
  padding: 0 10px;
  margin-left: 6px;
}
</style>

上面这段代码中,最核心的逻辑就是从sever获取数据后更新数据到Vue组件:

const coverUrl = ref('');
const quickAnswer = ref('');
const description = ref('');
const questionSelected = (question: string, index: number) => {
  quickAnswer.value = '';
  description.value = '';
  const query = queries[index].join(';');
  const endpoint = '/api/generate';
  const eventSource = new EventSource(`${endpoint}?question=${question}&query=${query}`);
  eventSource.addEventListener("message", function (e: any) {
    let { uri, delta } = JSON.parse(e.data);
    if (uri.endsWith('quick-answer')) {
      quickAnswer.value += delta;
    }
    if (uri.endsWith('introduction')) {
      description.value += delta;
    }
    if (uri.endsWith('cover_image')) {
      coverUrl.value = delta;
    }
  });
  eventSource.addEventListener('finished', () => {
    console.log('传输完成');
    eventSource.close();
  });
}

这里我们将introduction和cover_image赋给Ref变量,然后传给BookCard组件进行展示就可以了,实现起来非常简单。最终效果如下:

图片

这样我们就实现了大纲生成和卡片展示。

要点总结

这一节课,我们重点讲了大纲的撰写,其中最核心的是生成大纲的工作流节点和封面图的生成,代码虽然看起来不少,但理解起来并不复杂。它们都是异步过程,通过Ling框架,能够很好地将流程整合到一起,并通过数据流的方式统一发送给前端处理。

在下一节课,我们将继续讲解大纲生成后,子主题的拆解和最终的正文生成逻辑。

课后练习

在上面的实战中,如果你仔细看代码,会发现我们对生成的封面图的风格进行了限定。思考我们为什么要这么做,如果想做的更好,让封面图的风格多样化,可以怎么做?将你的想法或做法分享到评论区。

你可以修改代码来实践,完整代码位于代码仓库

精选留言