feat(ai): W1-AI-CLOSURE 超级 Sprint — 9 APP 全链路收口 + chat 上下文真激活
Phase 2.3 chat 上下文捕获链路从未真正激活到完整工作: - 14 处 ai-float-button 补 sourcePage,chat.ts 三分支同步设 pageFilters.contextId - 后端 page_context 4 层 BUG 修(列名错位 + RLS site_id 未重设) - xcx_chat filters.pop 破坏 body.page_context 引用 — dict() 浅拷贝隔离 - chat 流式 markdown 实时解析(表格/标题/列表/加粗 + KPI 富卡) - reference_card KPI 富卡接入 SSE 路径,db 真写入 - 维客线索 source 显示规则:AI 来源用机器人 icon 替代长文字 数据库: - public.member_retention_clue 加 emoji + runtime_mode + sandbox_instance_id - biz.ai_run_logs 加 assistant_id + 复合索引 - chk_ai_cache_type CHECK 约束 8 类应用名 - cache_type / app_type 命名统一(app6_note / app7_customer / app8_consolidation) - 历史 emoji 抽取脚本 44/44 成功 后端 silent failure 修: - cleanup_service WHERE app_type → cache_type(90 天清理 + 20K 上限重新生效) - _build_ai_insight 字段错位修复(app4 → app7 + 字段对齐 prompt schema) - task_manager talkingPoints 改 app5_tactics + tactics 字段 - task_manager aiSuggestion 改取 one_line_summary - cache_service.CACHE_EXPIRY_DAYS 加 app2a_finance_area - WS /ws/ai-cache 加 token + JWT + site_id 校验(P0 信息泄露漏洞) - internal_ai token 改 hmac.compare_digest 工具/文档: - main.py 加 RotatingFileHandler logs/backend.log + uvicorn /health 过滤 - 新建 utils/clue_category.py(VI 6 类配色 + emoji fallback + source 显示规则) - 新建 utils/markdown.ts(轻量 md 转 rich-text 解析 + streaming 容错) - audit + 数据库变更说明 + backlog §七 #14 收口 + #15-#38 残余子任务 - backlog 追加 §十一 App1 参数/MCP/沙箱审计 + §十二 百炼/SQL MCP 主任务线 实地 MCP 走查:14 入口数据层 + 5 代表入口 sourcePage 注入 + customer-detail 全模块 + chat md 渲染 + reference_card 富卡 都已验证。9 项预先 BUG/UX 登记 §七 #29-#38 后续修复。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,17 @@
|
||||
<view class="clue-content">
|
||||
<view class="clue-text-container">
|
||||
<text class="clue-text"><text class="clue-emoji">{{emoji}}</text> {{title}}</text>
|
||||
<text class="clue-source">{{source}}</text>
|
||||
<!-- source = "AI" 时用 icon 替代文字(W1-AI-CLOSURE 复盘:节字符 + 直观);其他显示文字 -->
|
||||
<view class="clue-source">
|
||||
<text class="clue-source-prefix">By:</text>
|
||||
<image
|
||||
wx:if="{{source === 'AI' || source === 'By:AI'}}"
|
||||
class="clue-source-ai-icon"
|
||||
src="/assets/icons/ai-robot.svg"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text wx:else class="clue-source-text">{{source}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="clue-desc" wx:if="{{content}}">
|
||||
|
||||
@@ -103,6 +103,8 @@
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 24rpx;
|
||||
line-height: 36rpx;
|
||||
color: var(--color-gray-7);
|
||||
@@ -111,6 +113,17 @@
|
||||
padding-left: 50rpx;
|
||||
z-index: 1;
|
||||
}
|
||||
.clue-source-prefix,
|
||||
.clue-source-text {
|
||||
font-size: 24rpx;
|
||||
color: var(--color-gray-7);
|
||||
}
|
||||
.clue-source-ai-icon {
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
margin-left: 2rpx;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.clue-desc {
|
||||
line-height: 30rpx;
|
||||
|
||||
@@ -133,7 +133,7 @@
|
||||
<!-- 自定义底部导航栏 -->
|
||||
<board-tab-bar active="board" />
|
||||
|
||||
<!-- AI 悬浮按钮 — 在导航栏上方 -->
|
||||
<ai-float-button />
|
||||
<!-- AI 悬浮按钮 — 在导航栏上方(W1-AI-CLOSURE 组 6:补 sourcePage) -->
|
||||
<ai-float-button sourcePage="board-coach" />
|
||||
|
||||
<dev-fab wx:if="{{false}}" />
|
||||
@@ -312,7 +312,7 @@
|
||||
<!-- 自定义底部导航栏 -->
|
||||
<board-tab-bar active="board" />
|
||||
|
||||
<!-- AI 悬浮按钮 -->
|
||||
<ai-float-button />
|
||||
<!-- AI 悬浮按钮(W1-AI-CLOSURE 组 6:补 sourcePage) -->
|
||||
<ai-float-button sourcePage="board-customer" />
|
||||
|
||||
<dev-fab wx:if="{{false}}" />
|
||||
|
||||
@@ -876,7 +876,8 @@
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<!-- AI 悬浮按钮 -->
|
||||
<ai-float-button bottom="{{200}}" />
|
||||
<!-- AI 悬浮按钮(W1-AI-CLOSURE 组 6:补 sourcePage,具体 timeDimension/areaFilter
|
||||
由 ai-float-button 组件按 page data 自动收集,这里不重复传 pageFilters) -->
|
||||
<ai-float-button bottom="{{200}}" sourcePage="board-finance" />
|
||||
|
||||
<dev-fab wx:if="{{false}}" />
|
||||
|
||||
@@ -54,8 +54,8 @@
|
||||
<text class="footer-text">— 已加载全部记录 —</text>
|
||||
</view>
|
||||
|
||||
<!-- AI 悬浮按钮 -->
|
||||
<ai-float-button />
|
||||
<!-- AI 悬浮按钮(W1-AI-CLOSURE 组 6:对话列表入口仍可启动新对话) -->
|
||||
<ai-float-button sourcePage="chat-history" />
|
||||
</view>
|
||||
|
||||
<dev-fab />
|
||||
|
||||
@@ -10,6 +10,7 @@ import { checkPageAccess } from '../../utils/auth-guard'
|
||||
import { fetchChatMessages, fetchChatMessagesByContext } from '../../services/api'
|
||||
import { formatRelativeTime, formatIMTime, shouldShowTimeDivider } from '../../utils/time'
|
||||
import { API_BASE } from '../../utils/config'
|
||||
import { mdToRichHtml } from '../../utils/markdown'
|
||||
|
||||
/** API 返回的消息项类型 */
|
||||
interface ApiMessageItem {
|
||||
@@ -31,12 +32,15 @@ function toDataList(data?: Record<string, string>): Array<{ key: string; value:
|
||||
return Object.keys(data).map((k) => ({ key: k, value: data[k] }))
|
||||
}
|
||||
|
||||
/** 为消息列表补充展示字段(时间分割线、IM时间、引用卡片)*/
|
||||
/** 为消息列表补充展示字段(时间分割线、IM时间、引用卡片、md 渲染)*/
|
||||
function enrichMessages(msgs: ApiMessageItem[]) {
|
||||
return msgs.map((m, i) => ({
|
||||
id: String(m.id),
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
// W1-AI-CLOSURE 任务 1:AI 消息预解析 markdown 为 rich-text html
|
||||
// (用户消息保持纯文本)
|
||||
contentHtml: m.role === 'assistant' ? mdToRichHtml(m.content) : '',
|
||||
timestamp: m.createdAt,
|
||||
timeLabel: formatRelativeTime(m.createdAt),
|
||||
imTimeLabel: formatIMTime(m.createdAt),
|
||||
@@ -146,6 +150,8 @@ type DisplayMessage = {
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
/** AI 消息预解析的 markdown → rich-text html(用户消息为空字符串)*/
|
||||
contentHtml?: string
|
||||
timestamp: string
|
||||
timeLabel?: string
|
||||
imTimeLabel?: string
|
||||
@@ -223,13 +229,27 @@ Page({
|
||||
this.loadMessages(options.historyId)
|
||||
} else if (options?.taskId) {
|
||||
// 从 task-detail 跳转:同一 taskId 始终复用同一对话(无时限)
|
||||
// W1-AI-CLOSURE 组 6:同步 sourcePage + pageContext.contextId 让后端 SSE
|
||||
// 阶段能注入页面上下文(build_page_text)+ build_app1_reference_card 跳转卡。
|
||||
this.setData({
|
||||
sourcePage: 'task-detail',
|
||||
pageFilters: { contextId: options.taskId },
|
||||
})
|
||||
this.loadMessagesByContext('task', options.taskId)
|
||||
} else if (options?.customerId) {
|
||||
// 从 customer-detail 跳转:≤ 3 天复用、> 3 天新建
|
||||
this.setData({ customerId: options.customerId })
|
||||
this.setData({
|
||||
customerId: options.customerId,
|
||||
sourcePage: 'customer-detail',
|
||||
pageFilters: { contextId: options.customerId },
|
||||
})
|
||||
this.loadMessagesByContext('customer', options.customerId)
|
||||
} else if (options?.coachId) {
|
||||
// 从 coach-detail 跳转:≤ 3 天复用、> 3 天新建
|
||||
this.setData({
|
||||
sourcePage: 'coach-detail',
|
||||
pageFilters: { contextId: options.coachId },
|
||||
})
|
||||
this.loadMessagesByContext('coach', options.coachId)
|
||||
} else if (options?.sourcePage) {
|
||||
// 看板类入口:保存来源页面和筛选参数
|
||||
@@ -411,6 +431,7 @@ Page({
|
||||
id: aiMsgId,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
contentHtml: '',
|
||||
timestamp: aiNow,
|
||||
timeLabel: '刚刚',
|
||||
imTimeLabel: formatIMTime(aiNow),
|
||||
@@ -429,12 +450,12 @@ Page({
|
||||
let fullContent = ''
|
||||
|
||||
const parser = new SSEParser(
|
||||
// onMessage: 逐 token 追加
|
||||
// onMessage: 逐 token 追加 + 实时 markdown 解析
|
||||
(token: string) => {
|
||||
fullContent += token
|
||||
const key = `messages[${aiIndex}].content`
|
||||
this.setData({
|
||||
[key]: fullContent,
|
||||
[`messages[${aiIndex}].content`]: fullContent,
|
||||
[`messages[${aiIndex}].contentHtml`]: mdToRichHtml(fullContent),
|
||||
streamingContent: fullContent,
|
||||
})
|
||||
this.scrollToBottom()
|
||||
|
||||
@@ -82,14 +82,15 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- AI 消息:左对齐白色 -->
|
||||
<!-- AI 消息:左对齐白色(W1-AI-CLOSURE 任务 1:流式 markdown 实时渲染)-->
|
||||
<view class="message-row message-assistant" wx:else id="msg-{{item.id}}">
|
||||
<view class="ai-avatar">
|
||||
<image src="/assets/icons/ai-robot.svg" class="ai-avatar-img" mode="aspectFit" />
|
||||
</view>
|
||||
<view class="bubble-col">
|
||||
<view class="bubble bubble-assistant">
|
||||
<text class="bubble-text">{{item.content}}</text>
|
||||
<rich-text wx:if="{{item.contentHtml}}" class="bubble-md" nodes="{{item.contentHtml}}" />
|
||||
<text wx:else class="bubble-text">{{item.content}}</text>
|
||||
</view>
|
||||
<!-- AI 侧引用卡片(后端 referenceCard 附加在 assistant 回复中)-->
|
||||
<view
|
||||
|
||||
@@ -376,3 +376,86 @@ page {
|
||||
width: 44rpx;
|
||||
height: 44rpx;
|
||||
}
|
||||
|
||||
/* W1-AI-CLOSURE 任务 1: AI 消息 markdown 渲染样式 ===================== */
|
||||
/* rich-text 不允许内联 style,样式通过 class 控制 */
|
||||
|
||||
.bubble-md {
|
||||
font-size: 30rpx;
|
||||
line-height: 1.65;
|
||||
color: var(--color-gray-13, #242424);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* 段落 */
|
||||
.md-p {
|
||||
margin: 0 0 16rpx;
|
||||
}
|
||||
.md-p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* 标题 */
|
||||
.md-h1 { font-size: 38rpx; font-weight: 700; margin: 24rpx 0 14rpx; color: #1a1a1a; }
|
||||
.md-h2 { font-size: 34rpx; font-weight: 700; margin: 22rpx 0 12rpx; color: #1a1a1a; }
|
||||
.md-h3 { font-size: 30rpx; font-weight: 600; margin: 18rpx 0 10rpx; color: #2c2c2c; }
|
||||
.md-h4 { font-size: 28rpx; font-weight: 600; margin: 14rpx 0 8rpx; color: #2c2c2c; }
|
||||
|
||||
/* 列表 */
|
||||
.md-ul, .md-ol {
|
||||
margin: 8rpx 0 16rpx;
|
||||
padding-left: 36rpx;
|
||||
}
|
||||
.md-li {
|
||||
margin-bottom: 6rpx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 表格 */
|
||||
.md-table {
|
||||
border-collapse: collapse;
|
||||
margin: 12rpx 0 18rpx;
|
||||
width: 100%;
|
||||
font-size: 26rpx;
|
||||
background: #fafafa;
|
||||
border-radius: 12rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
.md-th, .md-td {
|
||||
border: 1rpx solid #e0e0e0;
|
||||
padding: 10rpx 14rpx;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
.md-th {
|
||||
background: rgba(0, 82, 217, 0.06);
|
||||
font-weight: 600;
|
||||
color: #2c2c2c;
|
||||
}
|
||||
|
||||
/* 行内代码 */
|
||||
.md-code {
|
||||
font-family: SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 26rpx;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
padding: 2rpx 10rpx;
|
||||
border-radius: 6rpx;
|
||||
color: #c7254e;
|
||||
}
|
||||
|
||||
/* 代码块 */
|
||||
.md-pre {
|
||||
margin: 12rpx 0 16rpx;
|
||||
padding: 16rpx 20rpx;
|
||||
background: #2d2d2d;
|
||||
border-radius: 12rpx;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.md-code-block {
|
||||
font-family: SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.55;
|
||||
color: #e0e0e0;
|
||||
white-space: pre;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
@@ -319,6 +319,9 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- AI 悬浮按钮(W1-AI-CLOSURE 组 6:补 coach-detail 此前死注册的组件 + sourcePage) -->
|
||||
<ai-float-button coachId="{{detail.id}}" sourcePage="coach-detail" />
|
||||
</block>
|
||||
|
||||
<dev-fab />
|
||||
|
||||
@@ -117,7 +117,7 @@
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<!-- AI 悬浮按钮 -->
|
||||
<ai-float-button />
|
||||
<!-- AI 悬浮按钮(W1-AI-CLOSURE 组 6:补 sourcePage) -->
|
||||
<ai-float-button sourcePage="coach-service-records" />
|
||||
|
||||
<dev-fab />
|
||||
|
||||
@@ -55,17 +55,21 @@ Page({
|
||||
},
|
||||
phoneVisible: false,
|
||||
aiColor: "indigo" as "red" | "orange" | "yellow" | "blue" | "indigo" | "purple",
|
||||
// 对齐 demo 标杆 wxml(`{{item.text}}` 单字段);color 由前端按 index 轮换。
|
||||
aiInsight: {
|
||||
summary: '',
|
||||
strategies: [
|
||||
{ color: '', text: '' },
|
||||
{ color: '', text: '' },
|
||||
],
|
||||
strategies: [] as Array<{ color: string; text: string }>,
|
||||
},
|
||||
clues: [
|
||||
{ category: '', categoryColor: '', text: '', source: '' },
|
||||
{ category: '', categoryColor: '', text: '', source: '', detail: '' },
|
||||
],
|
||||
// W1-AI-CLOSURE 组 6:clues 字段对齐 RetentionClue schema
|
||||
// {tag, tag_color, emoji, text, source, desc} → camelCase {tag, tagColor, emoji, text, source, desc}
|
||||
clues: [] as Array<{
|
||||
tag: string
|
||||
tagColor: string
|
||||
emoji: string
|
||||
text: string
|
||||
source: string
|
||||
desc: string
|
||||
}>,
|
||||
coachTasks: [
|
||||
{ name: '', level: '', levelColor: '', taskType: '', taskColor: '', bgClass: '', status: '', lastService: '', metrics: [{ label: '', value: '' }] },
|
||||
{ name: '', level: '', levelColor: '', taskType: '', taskColor: '', bgClass: '', status: '', lastService: '', metrics: [{ label: '', value: '' }] },
|
||||
@@ -142,15 +146,19 @@ Page({
|
||||
},
|
||||
|
||||
async _loadAIInsight(memberId: string) {
|
||||
const cache = await fetchAICache('app7_customer_analysis', memberId)
|
||||
// cache_type 统一为 'app7_customer'(W1-AI-CLOSURE 组 1 数据库迁移已对齐)。
|
||||
// App7Result.strategies = [{title, content}],前端拼成 demo 标杆 {color, text} 单字段。
|
||||
const cache = await fetchAICache('app7_customer', memberId)
|
||||
if (!cache?.result_json) return
|
||||
const rj = cache.result_json as any
|
||||
const rj = cache.result_json as { summary?: string; strategies?: Array<{ title?: string; content?: string; text?: string }> }
|
||||
const COLORS = ['blue', 'indigo', 'purple', 'red', 'orange', 'yellow'] as const
|
||||
const strategies = Array.isArray(rj.strategies)
|
||||
? rj.strategies.map((s: any, i: number) => ({
|
||||
color: COLORS[i % COLORS.length],
|
||||
text: s.title || s.text || '',
|
||||
}))
|
||||
? rj.strategies.map((s, i) => {
|
||||
const t = (s?.title || '').trim()
|
||||
const c = (s?.content || '').trim()
|
||||
const text = s?.text || (t && c ? `${t}:${c}` : (c || t))
|
||||
return { color: COLORS[i % COLORS.length], text }
|
||||
})
|
||||
: []
|
||||
this.setData({
|
||||
'aiInsight.summary': rj.summary || '',
|
||||
|
||||
@@ -59,8 +59,8 @@
|
||||
|
||||
<!-- 主体内容 -->
|
||||
<view class="main-content" >
|
||||
<!-- AI 智能洞察 -->
|
||||
<view class="ai-insight-card">
|
||||
<!-- AI 智能洞察(W1-AI-CLOSURE 复盘:cache miss 时整段不渲染,避免空白卡) -->
|
||||
<view class="ai-insight-card" wx:if="{{aiInsight.summary || aiInsight.strategies.length > 0}}">
|
||||
<view class="ai-insight-header">
|
||||
<view class="ai-icon-box">
|
||||
<image class="ai-icon-img" src="/assets/icons/ai-robot.svg" mode="aspectFit" />
|
||||
@@ -84,7 +84,7 @@
|
||||
</view>
|
||||
|
||||
<!-- 维客线索 -->
|
||||
<view class="card">
|
||||
<view class="card" wx:if="{{clues.length > 0}}">
|
||||
<view class="card-header">
|
||||
<text class="section-title title-green">维客线索</text>
|
||||
<ai-title-badge color="{{aiColor}}" />
|
||||
@@ -93,12 +93,12 @@
|
||||
<clue-card
|
||||
wx:for="{{clues}}"
|
||||
wx:key="index"
|
||||
tag="{{item.category}}"
|
||||
category="{{item.categoryColor}}"
|
||||
emoji=""
|
||||
tag="{{item.tag}}"
|
||||
category="{{item.tagColor}}"
|
||||
emoji="{{item.emoji}}"
|
||||
title="{{item.text}}"
|
||||
source="By:{{item.source}}"
|
||||
content="{{item.detail}}"
|
||||
source="{{item.source}}"
|
||||
content="{{item.desc}}"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
@@ -325,8 +325,8 @@
|
||||
<!-- 备注弹窗 -->
|
||||
<note-modal visible="{{noteModalVisible}}" customerName="{{detail.name}}" showExpandBtn="{{false}}" showRating="{{false}}" bind:confirm="onNoteConfirm" bind:cancel="onNoteCancel" />
|
||||
|
||||
<!-- AI 悬浮按钮 -->
|
||||
<ai-float-button customerId="{{detail.id}}" />
|
||||
<!-- AI 悬浮按钮(W1-AI-CLOSURE 组 6:补 sourcePage 让 chat 上下文捕获生效) -->
|
||||
<ai-float-button customerId="{{detail.id}}" sourcePage="customer-detail" />
|
||||
</block>
|
||||
|
||||
<dev-fab />
|
||||
@@ -121,8 +121,8 @@
|
||||
|
||||
</view>
|
||||
|
||||
<!-- AI 悬浮按钮 -->
|
||||
<ai-float-button customerId="{{customerId}}" />
|
||||
<!-- AI 悬浮按钮(W1-AI-CLOSURE 组 6:补 sourcePage) -->
|
||||
<ai-float-button customerId="{{customerId}}" sourcePage="customer-service-records" />
|
||||
|
||||
</block>
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- AI 悬浮按钮 — TabBar 页面 bottom 200rpx -->
|
||||
<ai-float-button visible="{{true}}" />
|
||||
<!-- AI 悬浮按钮 — TabBar 页面 bottom 200rpx(W1-AI-CLOSURE 组 6:补 sourcePage) -->
|
||||
<ai-float-button visible="{{true}}" sourcePage="my-profile" />
|
||||
|
||||
<dev-fab wx:if="{{false}}" />
|
||||
|
||||
@@ -50,8 +50,8 @@
|
||||
<text class="footer-text">— 没有更多了 —</text>
|
||||
</view>
|
||||
|
||||
<!-- AI 悬浮按钮 -->
|
||||
<ai-float-button />
|
||||
<!-- AI 悬浮按钮(W1-AI-CLOSURE 组 6:补 sourcePage) -->
|
||||
<ai-float-button sourcePage="notes" />
|
||||
</view>
|
||||
|
||||
<dev-fab />
|
||||
|
||||
@@ -117,7 +117,7 @@
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<!-- AI 悬浮按钮 -->
|
||||
<ai-float-button />
|
||||
<!-- AI 悬浮按钮(W1-AI-CLOSURE 组 6:补 sourcePage) -->
|
||||
<ai-float-button sourcePage="performance-records" />
|
||||
|
||||
<dev-fab />
|
||||
|
||||
@@ -296,7 +296,7 @@
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<!-- AI 悬浮按钮 -->
|
||||
<ai-float-button />
|
||||
<!-- AI 悬浮按钮(W1-AI-CLOSURE 组 6:补 sourcePage) -->
|
||||
<ai-float-button sourcePage="performance" />
|
||||
|
||||
<dev-fab />
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"t-loading": "tdesign-miniprogram/loading/loading",
|
||||
"clue-card": "/components/clue-card/clue-card",
|
||||
"service-record-card": "/components/service-record-card/service-record-card",
|
||||
"dev-fab": "/components/dev-fab/dev-fab"
|
||||
"dev-fab": "/components/dev-fab/dev-fab",
|
||||
"ai-float-button": "/components/ai-float-button/ai-float-button"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,10 +10,10 @@ import { sortByTimestamp } from '../../utils/sort'
|
||||
import { formatRelativeTime } from '../../utils/time'
|
||||
import { formatStorageLevel } from '../../utils/storage-level'
|
||||
|
||||
/** 维客线索项 */
|
||||
/** 维客线索项(W1-AI-CLOSURE 组 6:tagColor 6 类对齐 VI-DESIGN-SYSTEM v1.1) */
|
||||
interface RetentionClue {
|
||||
tag: string
|
||||
tagColor: 'primary' | 'success' | 'purple' | 'error'
|
||||
tagColor: 'primary' | 'success' | 'orange' | 'gold' | 'purple' | 'error'
|
||||
emoji: string
|
||||
text: string
|
||||
source: string
|
||||
@@ -21,6 +21,12 @@ interface RetentionClue {
|
||||
expanded?: boolean
|
||||
}
|
||||
|
||||
/** 话术参考项(W1-AI-CLOSURE 组 6:对齐 App5Result.tactics) */
|
||||
interface TacticItem {
|
||||
scenario: string
|
||||
script: string
|
||||
}
|
||||
|
||||
/** 服务记录项 */
|
||||
interface ServiceRecord {
|
||||
table: string
|
||||
@@ -58,12 +64,8 @@ Page({
|
||||
{ tag: '', tagColor: 'error', emoji: '', text: '', source: '', desc: '', expanded: false },
|
||||
] as RetentionClue[],
|
||||
|
||||
// --- 话术参考 ---
|
||||
talkingPoints: [
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
] as string[],
|
||||
// --- 话术参考(对齐 App5Result.tactics 字段) ---
|
||||
talkingPoints: [] as TacticItem[],
|
||||
copiedIndex: -1,
|
||||
|
||||
// --- 近期服务记录 ---
|
||||
@@ -264,13 +266,13 @@ Page({
|
||||
this.setData({ [key]: !current })
|
||||
},
|
||||
|
||||
/** 复制话术 */
|
||||
/** 复制话术(W1-AI-CLOSURE 组 6:tactics 含 scenario+script 双字段,复制 script 部分) */
|
||||
onCopySpeech(e: WechatMiniprogram.BaseEvent) {
|
||||
const idx = e.currentTarget.dataset.index as number
|
||||
const text = this.data.talkingPoints[idx]
|
||||
if (!text) return
|
||||
const item = this.data.talkingPoints[idx]
|
||||
if (!item || !item.script) return
|
||||
wx.setClipboardData({
|
||||
data: text,
|
||||
data: item.script,
|
||||
success: () => {
|
||||
this.setData({ copiedIndex: idx })
|
||||
setTimeout(() => this.setData({ copiedIndex: -1 }), 2000)
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
<view class="speech-bubble" wx:for="{{talkingPoints}}" wx:key="index">
|
||||
<view class="speech-text-wrap">
|
||||
<ai-inline-icon color="{{aiColor}}" />
|
||||
<text class="speech-text">{{item}}</text>
|
||||
<text class="speech-text">{{item.scenario ? item.scenario + ':' + item.script : item.script}}</text>
|
||||
</view>
|
||||
<view class="speech-copy-row">
|
||||
<view class="copy-btn" bindtap="onCopySpeech" data-index="{{index}}" hover-class="copy-btn--hover">
|
||||
@@ -277,4 +277,7 @@
|
||||
<text>🔧</text>
|
||||
</view>
|
||||
|
||||
<!-- AI 悬浮按钮(W1-AI-CLOSURE 组 6:补 task-detail 整页缺失的入口 + sourcePage) -->
|
||||
<ai-float-button taskId="{{detail.id}}" sourcePage="task-detail" />
|
||||
|
||||
</block>
|
||||
|
||||
@@ -329,8 +329,8 @@
|
||||
bind:cancel="onNoteCancel"
|
||||
/>
|
||||
|
||||
<!-- AI 悬浮按钮 -->
|
||||
<ai-float-button />
|
||||
<!-- AI 悬浮按钮(W1-AI-CLOSURE 组 6:补 sourcePage) -->
|
||||
<ai-float-button sourcePage="task-list" />
|
||||
|
||||
<!-- 开发调试 FAB -->
|
||||
<dev-fab />
|
||||
|
||||
205
apps/miniprogram/miniprogram/utils/markdown.ts
Normal file
205
apps/miniprogram/miniprogram/utils/markdown.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* 轻量 Markdown 转 rich-text 兼容 HTML 字符串(W1-AI-CLOSURE 任务 1)。
|
||||
*
|
||||
* 用途:chat 页 AI 回复流式渲染。每次 token 拼接后调用,输出供小程序
|
||||
* <rich-text nodes="{{html}}"> 渲染。
|
||||
*
|
||||
* 支持(80% AI 输出场景覆盖):
|
||||
* 段落 / 换行 / 标题 H1-H4 / 粗体 / 斜体 / 行内代码 / 代码块
|
||||
* 无序列表 / 有序列表 / GFM 表格 / 行内 emoji 不变
|
||||
*
|
||||
* Streaming 容错:partial 标记(未闭合 双星 / 未结尾 三引号 / 未完整 竖线)
|
||||
* 不渲染对应节点,降级为纯文本。每次 token 来重新解析整段。
|
||||
*
|
||||
* rich-text 限制:
|
||||
* 不允许 inline style 属性
|
||||
* 仅支持有限 tag(p / div / h1-h6 / strong / em / code / ul / ol / li /
|
||||
* table / tr / th / td / pre / br / a)
|
||||
* class 由 wxss 全局样式控制
|
||||
*/
|
||||
|
||||
const HTML_ESCAPE_MAP: Record<string, string> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/[&<>"']/g, (c) => HTML_ESCAPE_MAP[c])
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析行内 markdown(粗体 / 斜体 / 行内代码),返回带 tag 的 html。
|
||||
* 优先级:行内代码 > 粗体 > 斜体。已转义 html 特殊字符。
|
||||
*/
|
||||
function parseInline(text: string): string {
|
||||
if (!text) return ""
|
||||
|
||||
// 步骤 1:抽出 `code` 占位(纯 ASCII 标记防与原文冲突)
|
||||
const codeStash: string[] = []
|
||||
let s = text.replace(/`([^`\n]+)`/g, (_m, c) => {
|
||||
const idx = codeStash.length
|
||||
codeStash.push(c)
|
||||
return `[[MDCODE${idx}MDCODE]]`
|
||||
})
|
||||
|
||||
// 步骤 2:转义 html(占位标记 [[MDCODE0MDCODE]] 中的 [ ] 不会转义)
|
||||
s = escapeHtml(s)
|
||||
|
||||
// 步骤 3:还原 code 为 <code> 标签
|
||||
s = s.replace(/\[\[MDCODE(\d+)MDCODE\]\]/g, (_m, i) => {
|
||||
const c = codeStash[Number(i)] || ""
|
||||
return `<code class="md-code">${escapeHtml(c)}</code>`
|
||||
})
|
||||
|
||||
// 步骤 4:粗体 **xx**(确保有闭合)
|
||||
s = s.replace(/\*\*([^\n*][^\n*]*?)\*\*/g, "<strong>$1</strong>")
|
||||
|
||||
// 步骤 5:斜体 *xx*(避免 **)
|
||||
s = s.replace(/(^|[^*])\*([^\n*][^\n*]*?)\*(?!\*)/g, "$1<em>$2</em>")
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
/**
|
||||
* 主入口:Markdown 转 rich-text 兼容 HTML 字符串。
|
||||
*/
|
||||
export function mdToRichHtml(md: string): string {
|
||||
if (!md) return ""
|
||||
const lines = md.split("\n")
|
||||
const out: string[] = []
|
||||
let i = 0
|
||||
let inCodeBlock = false
|
||||
let codeBuf: string[] = []
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i]
|
||||
|
||||
// 代码块 ```xx```
|
||||
if (line.trim().startsWith("```")) {
|
||||
if (!inCodeBlock) {
|
||||
inCodeBlock = true
|
||||
codeBuf = []
|
||||
} else {
|
||||
out.push(
|
||||
`<pre class="md-pre"><code class="md-code-block">${escapeHtml(
|
||||
codeBuf.join("\n"),
|
||||
)}</code></pre>`,
|
||||
)
|
||||
inCodeBlock = false
|
||||
}
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (inCodeBlock) {
|
||||
codeBuf.push(line)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// 标题 #
|
||||
const h = line.match(/^(#{1,4})\s+(.+)$/)
|
||||
if (h) {
|
||||
const level = h[1].length
|
||||
out.push(`<h${level} class="md-h${level}">${parseInline(h[2])}</h${level}>`)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// GFM 表格(连续 2 行,第二行为分隔)
|
||||
if (
|
||||
line.trim().startsWith("|") &&
|
||||
i + 1 < lines.length &&
|
||||
/^\s*\|[\s\-:|]+\|\s*$/.test(lines[i + 1])
|
||||
) {
|
||||
const headers = line.split("|").slice(1, -1).map((c) => c.trim())
|
||||
i += 2
|
||||
const rows: string[][] = []
|
||||
while (
|
||||
i < lines.length &&
|
||||
lines[i].trim().startsWith("|") &&
|
||||
lines[i].trim().endsWith("|")
|
||||
) {
|
||||
rows.push(lines[i].split("|").slice(1, -1).map((c) => c.trim()))
|
||||
i++
|
||||
}
|
||||
let html = '<table class="md-table"><thead><tr>'
|
||||
for (const h0 of headers) html += `<th class="md-th">${parseInline(h0)}</th>`
|
||||
html += "</tr></thead><tbody>"
|
||||
for (const r of rows) {
|
||||
html += "<tr>"
|
||||
for (const c of r) html += `<td class="md-td">${parseInline(c)}</td>`
|
||||
html += "</tr>"
|
||||
}
|
||||
html += "</tbody></table>"
|
||||
out.push(html)
|
||||
continue
|
||||
}
|
||||
|
||||
// 无序列表
|
||||
if (/^\s*[-*]\s+/.test(line)) {
|
||||
const items: string[] = []
|
||||
while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) {
|
||||
items.push(lines[i].replace(/^\s*[-*]\s+/, ""))
|
||||
i++
|
||||
}
|
||||
out.push(
|
||||
`<ul class="md-ul">${items
|
||||
.map((it) => `<li class="md-li">${parseInline(it)}</li>`)
|
||||
.join("")}</ul>`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// 有序列表
|
||||
if (/^\s*\d+\.\s+/.test(line)) {
|
||||
const items: string[] = []
|
||||
while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) {
|
||||
items.push(lines[i].replace(/^\s*\d+\.\s+/, ""))
|
||||
i++
|
||||
}
|
||||
out.push(
|
||||
`<ol class="md-ol">${items
|
||||
.map((it) => `<li class="md-li">${parseInline(it)}</li>`)
|
||||
.join("")}</ol>`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// 空行 → 段落分隔
|
||||
if (line.trim() === "") {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// 普通段落(连续非空行合并,内部用 <br/> 保留软换行)
|
||||
const para: string[] = [line]
|
||||
i++
|
||||
while (
|
||||
i < lines.length &&
|
||||
lines[i].trim() !== "" &&
|
||||
!lines[i].trim().startsWith("#") &&
|
||||
!lines[i].trim().startsWith("|") &&
|
||||
!/^\s*[-*]\s+/.test(lines[i]) &&
|
||||
!/^\s*\d+\.\s+/.test(lines[i]) &&
|
||||
!lines[i].trim().startsWith("```")
|
||||
) {
|
||||
para.push(lines[i])
|
||||
i++
|
||||
}
|
||||
out.push(`<p class="md-p">${parseInline(para.join("<br/>"))}</p>`)
|
||||
}
|
||||
|
||||
// 流未闭合的代码块降级为纯文本 pre
|
||||
if (inCodeBlock && codeBuf.length > 0) {
|
||||
out.push(
|
||||
`<pre class="md-pre"><code class="md-code-block">${escapeHtml(
|
||||
codeBuf.join("\n"),
|
||||
)}</code></pre>`,
|
||||
)
|
||||
}
|
||||
|
||||
return out.join("")
|
||||
}
|
||||
Reference in New Issue
Block a user