Sfoglia il codice sorgente

feat: AI 助手一键许愿功能

- AI system prompt 增加许愿引导,输出 [WISH]JSON[/WISH] 格式
- ChatWidget 解析 AI 回复中的许愿建议,渲染愿望确认卡
- 确认后调用 submitWish 一键创建愿望
- 距离过远时直接拒绝并礼貌提示
- 未登录时提示先登录

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
tanlie 2 settimane fa
parent
commit
82155a38c2

+ 12 - 0
wishing-platform/platform-service/platform-service-mobile/src/main/java/cn/qinys/platform/config/ChatClientConfig.java

@@ -20,6 +20,18 @@ public class ChatClientConfig {
                         4. 用温暖、友好的语气与用户交流
                         5. 适当使用 emoji 让对话更生动
 
+                        【重要】一键许愿功能:
+                        当用户表达"帮我许愿""替我许愿""想许愿""许个愿""我要许愿"等意图时,你必须执行以下步骤:
+                        1. 如果用户还没说愿望内容,引导用户说出具体愿望
+                        2. 用户说出愿望后,整理成一句20-50字精炼的愿望文字
+                        3. 从以下类别选2-3个标签:健康、平安、爱情、事业、学业、财富、梦想、好运、祈福、家庭
+                        4. 你的回复末尾必须严格包含以下格式(必须一字不差,这是系统指令):
+
+                        [WISH]{"content":"整理后的愿望文字","tags":["标签1","标签2"]}[/WISH]
+
+                        示例:用户说"帮我许愿,希望家人健康",你应该回复:
+                        我帮你整理好了愿望,确认一下哦~ [WISH]{"content":"希望家人身体健康,平安喜乐","tags":["健康","平安","家庭"]}[/WISH]
+
                         回复控制在 100-200 字以内,简洁明了。
                         如果用户问的问题与许愿无关,礼貌地引导回许愿相关话题。
                         """)

+ 160 - 1
wishing-tree-h5/src/components/ChatWidget.vue

@@ -29,6 +29,22 @@
           :class="['chat-msg', msg.role]"
         >
           <div class="chat-bubble">{{ msg.content }}</div>
+          <!-- AI 许愿建议卡 -->
+          <div v-if="msg.wish" class="wish-card">
+            <div class="wish-card-header">✨ 愿望概览</div>
+            <p class="wish-card-content">{{ msg.wish.content }}</p>
+            <div class="wish-card-tags" v-if="msg.wish.tags?.length">
+              <van-tag v-for="t in msg.wish.tags" :key="t" plain type="primary" size="medium">
+                {{ t }}
+              </van-tag>
+            </div>
+            <div class="wish-card-actions">
+              <van-button size="small" round plain @click="cancelWish(i)">再想想</van-button>
+              <van-button size="small" round type="primary" :loading="msg.wishSubmitting" @click="confirmWish(i)">
+                确认许愿 🙏
+              </van-button>
+            </div>
+          </div>
         </div>
         <div v-if="sending" class="chat-msg ai">
           <div class="chat-bubble typing">...</div>
@@ -58,17 +74,35 @@
 
 <script setup lang="ts">
 import { ref, nextTick, watch } from 'vue'
+import { showToast, showSuccessToast } from 'vant'
 import { sendMessage, fetchHistory } from '@/api/chat'
+import { submitWish } from '@/api/wish'
+import { useUserStore } from '@/stores/user'
+import { useLocationStore } from '@/stores/location'
 
 const props = defineProps<{
   treeId?: number
+  treeName?: string
+  treeAddress?: string
+  isInRange?: boolean
+  distance?: number
 }>()
 
+interface WishSuggestion {
+  content: string
+  tags: string[]
+}
+
 interface ChatMsg {
   role: 'user' | 'ai'
   content: string
+  wish?: WishSuggestion
+  wishSubmitting?: boolean
 }
 
+const userStore = useUserStore()
+const locationStore = useLocationStore()
+
 const showChat = ref(false)
 const input = ref('')
 const sending = ref(false)
@@ -76,6 +110,31 @@ const messages = ref<ChatMsg[]>([])
 const msgBody = ref<HTMLElement | null>(null)
 const historyLoaded = ref(false)
 
+/** 从 AI 回复中提取 [WISH]...[/WISH] 标记,兼容各种格式变体 */
+function parseWish(content: string): { text: string; wish: WishSuggestion | null } {
+  // 尝试多种可能的标记格式
+  const patterns = [
+    /\[WISH\]\s*(\{[\s\S]*?\})\s*\[\/WISH\]/i,
+    /\[WISH\]\s*([\s\S]*?)\s*\[\/WISH\]/i,
+    /\【WISH\】\s*(\{[\s\S]*?\})\s*\【\/WISH\】/i,
+  ]
+  for (const regex of patterns) {
+    const match = content.match(regex)
+    if (match) {
+      try {
+        const wish = JSON.parse(match[1])
+        const text = content.replace(regex, '').trim()
+        if (wish.content) {
+          return { text, wish: { content: wish.content, tags: wish.tags || [] } }
+        }
+      } catch {
+        // 继续尝试下一个模式
+      }
+    }
+  }
+  return { text: content, wish: null }
+}
+
 async function loadHistory() {
   try {
     const list = await fetchHistory(props.treeId)
@@ -112,7 +171,20 @@ async function send() {
   sending.value = true
   try {
     const reply = await sendMessage(text, props.treeId)
-    messages.value.push({ role: 'ai', content: reply })
+    const { text: cleanText, wish } = parseWish(reply)
+
+    // 如果 AI 生成了一键许愿建议,但用户不在许愿范围内,直接拒绝
+    if (wish && props.isInRange === false) {
+      const distText = props.distance
+        ? (props.distance >= 1000 ? (props.distance / 1000).toFixed(1) + 'km' : props.distance + 'm')
+        : '较远'
+      messages.value.push({
+        role: 'ai',
+        content: `抱歉哦~ 你当前距离许愿树还有 ${distText},超出了许愿范围,暂时无法帮你一键许愿。请先靠近许愿树后再来找我帮忙吧!📿`,
+      })
+    } else {
+      messages.value.push({ role: 'ai', content: cleanText || reply, wish: wish || undefined })
+    }
   } catch {
     messages.value.push({ role: 'ai', content: '抱歉,网络出了问题,请稍后再试~' })
   } finally {
@@ -121,6 +193,55 @@ async function send() {
   }
 }
 
+function cancelWish(index: number) {
+  delete messages.value[index].wish
+}
+
+async function confirmWish(index: number) {
+  const msg = messages.value[index]
+  if (!msg.wish) return
+
+  if (!userStore.isLoggedIn) {
+    showToast('请先登录再许愿')
+    showChat.value = false
+    return
+  }
+
+  // 检查是否在许愿范围内
+  if (props.isInRange === false) {
+    const distText = props.distance
+      ? (props.distance >= 1000 ? (props.distance / 1000).toFixed(1) + 'km' : props.distance + 'm')
+      : '太远'
+    delete msg.wish
+    messages.value.push({
+      role: 'ai',
+      content: `抱歉哦~ 你当前距离许愿树还有 ${distText},超出了许愿范围。请靠近许愿树后再来找我许愿吧!📿`,
+    })
+    scrollToBottom()
+    return
+  }
+
+  msg.wishSubmitting = true
+  try {
+    await submitWish({
+      treeId: props.treeId || 0,
+      content: msg.wish.content,
+      images: [],
+      lng: locationStore.lng || 0,
+      lat: locationStore.lat || 0,
+      address: props.treeAddress || '',
+      isPublic: true,
+      tags: msg.wish.tags,
+    })
+    showSuccessToast('愿望已挂在树上!祝你心想事成 ✨')
+    delete msg.wish
+  } catch {
+    showToast('许愿失败,请稍后再试')
+  } finally {
+    msg.wishSubmitting = false
+  }
+}
+
 watch(showChat, (val) => {
   if (val) {
     if (!historyLoaded.value) {
@@ -200,6 +321,9 @@ watch(showChat, (val) => {
 .chat-msg.ai {
   align-self: flex-start;
 }
+.chat-msg.ai:has(.wish-card) {
+  max-width: 92%;
+}
 
 .chat-bubble {
   padding: 10px 14px;
@@ -239,4 +363,39 @@ watch(showChat, (val) => {
   border-radius: 20px;
   padding: 6px 14px;
 }
+
+/* 许愿建议卡 */
+.wish-card {
+  margin-top: 8px;
+  background: #fff;
+  border-radius: 12px;
+  padding: 16px;
+  border: 1px solid #07c160;
+  box-shadow: 0 2px 8px rgba(7, 193, 96, 0.15);
+  width: 280px;
+  max-width: 82vw;
+}
+.wish-card-header {
+  font-size: 13px;
+  font-weight: 600;
+  color: #07c160;
+  margin-bottom: 8px;
+}
+.wish-card-content {
+  font-size: 14px;
+  line-height: 1.6;
+  color: #333;
+  margin-bottom: 8px;
+}
+.wish-card-tags {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 4px;
+  margin-bottom: 10px;
+}
+.wish-card-actions {
+  display: flex;
+  justify-content: flex-end;
+  gap: 8px;
+}
 </style>

+ 1 - 1
wishing-tree-h5/src/views/TreeDetailView.vue

@@ -48,7 +48,7 @@
 
     <van-loading v-else class="loading" />
 
-    <ChatWidget :tree-id="tree?.id" />
+    <ChatWidget :tree-id="tree?.id" :tree-name="tree?.name" :tree-address="tree?.address" :is-in-range="tree?.isInRange" :distance="tree?.distance" />
   </div>
 </template>
 

BIN
wishing-tree-h5/wish.zip