feat(ai): 助手页支持写作/生图等记录的历史列表与按类型筛选

- 三模式各自独立工作区:切模式不丢消息、草稿与当前记录
- 历史面板加类型徽标与筛选(全部/聊天/写作/图片/创意/活动策划),文案改「历史记录」
- 生成成功认领末帧 chatId 并刷新列表;聊天 __END__ 帧的 chatId 同样接受
- 生图回复支持多张 ![](url) 渲染
- 写作/图片记录支持「按原参数再来一次」(只回填、不自动发送)
- 三个独立页(文稿/创意/活动策划)加历史入口,/ai?type= 预置筛选
- 后台 aiChatList 加类型列与筛选;历史列表提示仅展示最近 30 条
This commit is contained in:
2026-10-24 17:37:46 +08:00
parent 73155d0bd2
commit 77378a2791
9 changed files with 726 additions and 45 deletions
+5
View File
@@ -28,6 +28,10 @@ export interface AiChatList {
updateTime?: string;
//
title?: string;
// 记录类型:chat / writing / image / creative / activity_outline
chatType?: string;
// 生成记录入参快照(JSON 字符串,可能为 null)
params?: string;
}
/**
@@ -43,5 +47,6 @@ export interface AiChatListParam extends PageParam {
status?: number;
deleted?: number;
title?: string;
chatType?: string;
keywords?: string;
}
+42
View File
@@ -0,0 +1,42 @@
/**
* AI 历史记录的类型(对应后端 `ai_chat_list.chat_type`)。
*
* 契约见 `docs/ai-history-contract.md`:类型由后端在生成成功时判定,前端只负责展示与筛选。
* 旧数据没有 `chat_type`,按聊天处理。
*/
export type AiChatType =
| 'chat'
| 'writing'
| 'image'
| 'creative'
| 'activity_outline';
/** 类型的中文名(徽标与后台表格共用) */
export const AI_CHAT_TYPE_LABELS: Record<AiChatType, string> = {
chat: '聊天',
writing: '写作',
image: '图片',
creative: '创意',
activity_outline: '活动策划'
};
/** 筛选下拉的可选项(不含「全部」,由使用方按需补充) */
export const AI_CHAT_TYPE_OPTIONS: { label: string; value: AiChatType }[] = [
{ label: '聊天', value: 'chat' },
{ label: '写作', value: 'writing' },
{ label: '图片', value: 'image' },
{ label: '创意', value: 'creative' },
{ label: '活动策划', value: 'activity_outline' }
];
/** 把后端返回值收敛到已知类型,缺失或未知一律按聊天处理 */
export function resolveAiChatType(value?: string | null): AiChatType {
const type = String(value || '').trim() as AiChatType;
return type in AI_CHAT_TYPE_LABELS ? type : 'chat';
}
/** 直接取中文名,用于模板与表格列 */
export function getAiChatTypeLabel(value?: string | null): string {
return AI_CHAT_TYPE_LABELS[resolveAiChatType(value)];
}
+24 -2
View File
@@ -148,6 +148,28 @@ export function streamCreativeAssistant(
});
}
/**
* 末帧 `result` 是普通接口的完整响应包(`{code, message, data}`)。
* 生成成功后后端会在包里带上 `chatId`(见 `docs/ai-history-contract.md`),
* 既可能在包上也可能在 `data` 里;这里统一把它合并进返回的 data,
* 页面只读 `result.chatId` 即可。
*/
function unwrapFinalData(finalResult: any) {
const data = finalResult?.data ?? null;
const chatId = Number(finalResult?.chatId);
if (
data &&
typeof data === 'object' &&
!Array.isArray(data) &&
Number.isFinite(chatId) &&
chatId > 0 &&
data.chatId == null
) {
data.chatId = chatId;
}
return data;
}
/**
* 创意助手(出图/出视频):跑完才有结果,中途一个字节都不推。
*
@@ -181,7 +203,7 @@ export async function runCreativeAssistantStream(
if (Number(finalResult.code) !== 0) {
throw new Error(finalResult.message || '创意助手处理失败');
}
return finalResult.data ?? null;
return unwrapFinalData(finalResult);
}
/**
@@ -224,5 +246,5 @@ export async function runManuscriptGenStream(
if (Number(finalResult.code) !== 0) {
throw new Error(finalResult.message || '稿件生成失败');
}
return finalResult.data ?? null;
return unwrapFinalData(finalResult);
}
+26 -3
View File
@@ -1,6 +1,14 @@
<!-- 搜索表单 -->
<template>
<a-space :size="10" style="flex-wrap: wrap">
<a-select
v-model:value="where.chatType"
allow-clear
placeholder="请选择类型"
style="width: 180px"
:options="chatTypeOptions"
@change="search"
/>
<a-button type="primary" class="ele-btn-icon" @click="add">
<template #icon>
<PlusOutlined />
@@ -12,8 +20,9 @@
<script lang="ts" setup>
import { PlusOutlined } from '@ant-design/icons-vue';
import type { GradeParam } from '@/api/user/grade/model';
import { watch } from 'vue';
import type { AiChatListParam } from '@/api/ai/aiChatList/model';
import { AI_CHAT_TYPE_OPTIONS } from '@/utils/ai-chat-type';
import { reactive, watch } from 'vue';
const props = withDefaults(
defineProps<{
@@ -24,12 +33,26 @@
);
const emit = defineEmits<{
(e: 'search', where?: GradeParam): void;
(e: 'search', where?: AiChatListParam): void;
(e: 'add'): void;
(e: 'remove'): void;
(e: 'batchMove'): void;
}>();
const chatTypeOptions = AI_CHAT_TYPE_OPTIONS;
const where = reactive<AiChatListParam>({
chatType: undefined
});
// 类型筛选:清空即回到全部
const search = () => {
emit('search', {
...where,
chatType: where.chatType || undefined
});
};
// 新增
const add = () => {
emit('add');
+29
View File
@@ -24,6 +24,11 @@
<template v-if="column.key === 'image'">
<a-image :src="record.image" :width="50" />
</template>
<template v-if="column.key === 'chatType'">
<a-tag :color="chatTypeColor(record.chatType)">
{{ getAiChatTypeLabel(record.chatType) }}
</a-tag>
</template>
<template v-if="column.key === 'status'">
<a-tag v-if="record.status === 1" color="green">显示</a-tag>
<a-tag v-if="record.status === 0" color="red">隐藏</a-tag>
@@ -64,6 +69,7 @@
import AiChatListEdit from './components/aiChatListEdit.vue';
import { pageAiChatList, removeAiChatList, removeBatchAiChatList } from '@/api/ai/aiChatList';
import type { AiChatList, AiChatListParam } from '@/api/ai/aiChatList/model';
import { getAiChatTypeLabel, resolveAiChatType } from '@/utils/ai-chat-type';
// 表格实例
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
@@ -79,6 +85,22 @@
// 加载状态
const loading = ref(true);
// 记录类型徽标的颜色,与助手页历史面板一致
const chatTypeColor = (value?: string) => {
switch (resolveAiChatType(value)) {
case 'writing':
return 'orange';
case 'image':
return 'purple';
case 'creative':
return 'magenta';
case 'activity_outline':
return 'green';
default:
return 'blue';
}
};
// 表格数据源
const datasource: DatasourceFunction = ({
page,
@@ -107,6 +129,13 @@
align: 'center',
width: 90,
},
{
title: '类型',
dataIndex: 'chatType',
key: 'chatType',
align: 'center',
width: 110
},
{
title: '',
dataIndex: 'conversationId',
@@ -6,6 +6,9 @@
<h1>活动策划大纲生成</h1>
<p>填写活动主题、受众、时间和预算,AI 给出一份结构完整的策划大纲,可直接改、复制或导出。</p>
</div>
<div class="hero-actions">
<a-button ghost @click="goHistory">历史记录</a-button>
</div>
</div>
<a-row :gutter="[24, 24]">
@@ -169,6 +172,7 @@
import zh_Hans from 'bytemd/locales/zh_Hans.json';
import { getProcessor } from 'bytemd';
import { computed, onBeforeUnmount, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import { messageLoading } from 'ele-admin-pro';
import { saveAs } from 'file-saver';
@@ -183,6 +187,14 @@
import { FAILED_TEXT } from '@/utils/ai-result';
type ExampleType = 'volunteer' | 'salon' | 'sports';
const { push } = useRouter();
/** 活动策划记录统一在助手页查看,这里只带类型跳过去 */
const goHistory = () => {
push({ path: '/ai', query: { type: 'activity_outline' } });
};
/** 等「流结束」的挂起点:只由 __END__ 帧或请求收尾来 resolve */
type StreamResolver = {
resolve: () => void;
@@ -665,6 +677,18 @@
color: rgba(255, 238, 238, 0.9);
}
.hero-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
justify-content: flex-end;
flex: none;
}
.hero-actions :deep(.ant-btn) {
border-radius: 999px;
}
.quick-actions-panel {
margin-bottom: 14px;
padding: 16px;
@@ -805,5 +829,9 @@
.hero-panel h1 {
font-size: 28px;
}
.hero-actions {
justify-content: flex-start;
}
}
</style>
@@ -8,6 +8,9 @@
支持文本指令与多类型附件联合驱动工作流,自动返回视频、脚本 Markdown 或图像/排版结果。
</p>
</div>
<div class="hero-actions">
<a-button ghost @click="goHistory">历史记录</a-button>
</div>
</div>
<a-row :gutter="[24, 24]">
@@ -187,6 +190,7 @@
import highlight from '@bytemd/plugin-highlight-ssr';
import 'highlight.js/styles/default.css';
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import ByteMdViewer from '@/components/ByteMdViewer/index.vue';
import { runCreativeAssistantStream } from '@/utils/ai-stream';
@@ -200,6 +204,13 @@
type ExampleType = 'video' | 'script' | 'image';
const { push } = useRouter();
/** 创意助手记录统一在助手页查看,这里只带类型跳过去 */
const goHistory = () => {
push({ path: '/ai', query: { type: 'creative' } });
};
const plugins = ref([
gfm({
locale: zh_HansGfm
@@ -497,6 +508,18 @@
color: rgba(255, 239, 241, 0.88);
}
.hero-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
justify-content: flex-end;
flex: none;
}
.hero-actions :deep(.ant-btn) {
border-radius: 999px;
}
.panel-card {
border-radius: 20px;
box-shadow: 0 14px 34px rgba(87, 54, 30, 0.08);
@@ -824,6 +847,10 @@
font-size: 28px;
}
.hero-actions {
justify-content: flex-start;
}
.video-grid,
.photo-grid {
grid-template-columns: 1fr;
+6
View File
@@ -8,6 +8,7 @@
</div>
<div class="hero-actions">
<a-button type="primary" ghost @click="goChatPage">进入问答助手</a-button>
<a-button type="primary" ghost @click="goHistory">历史记录</a-button>
</div>
</div>
@@ -844,6 +845,11 @@
push('/ai');
};
/** 写作记录统一在助手页查看,这里只带类型跳过去 */
const goHistory = () => {
push({ path: '/ai', query: { type: 'writing' } });
};
onBeforeUnmount(() => {
clearStreamResolver();
});
+539 -40
View File
@@ -22,6 +22,20 @@
</button>
<div class="history-block">
<div class="history-heading">历史记录</div>
<div class="history-hint">仅展示最近 30 条</div>
<div class="history-filter">
<button
v-for="item in historyTypeFilters"
:key="item.value"
class="history-filter-btn"
type="button"
:class="{ 'is-active': historyTypeFilter === item.value }"
@click="historyTypeFilter = item.value"
>
{{ item.label }}
</button>
</div>
<template v-if="historySections.length">
<template v-for="section in historySections" :key="section.label">
<div class="history-label">{{ section.label }}</div>
@@ -37,7 +51,11 @@
:disabled="loading"
@click="selectChat(item)"
>
{{ getHistoryTitle(item) }}
<span
class="history-type-badge"
:class="`is-${getHistoryType(item)}`"
>{{ getHistoryTypeLabel(item) }}</span>
<span class="history-title-text">{{ getHistoryTitle(item) }}</span>
</button>
<button
class="history-delete-btn"
@@ -73,7 +91,7 @@
</div>
</template>
</template>
<div v-else class="history-empty">暂无历史对话</div>
<div v-else class="history-empty">暂无历史记录</div>
</div>
<div class="sidebar-footer">
@@ -97,6 +115,24 @@
<main class="chat-main">
<div class="chat-main-header">{{ chatHeaderText }}</div>
<!--
选中写作 / 图片 / 创意 / 活动策划的历史记录后,消息区顶部给一条说明:
写作与图片可回填参数「再来一次」(不自动发送),另外两类只读。
-->
<div
v-if="activeHistoryItem && activeHistoryItemType !== 'chat'"
class="history-banner"
>
<span class="history-banner-text">{{ historyBannerText }}</span>
<button
v-if="canReplayActiveHistory"
class="history-replay-btn"
type="button"
:disabled="loading || !activeHistoryItem?.params"
@click="replayActiveHistory"
>按原参数再来一次</button>
</div>
<div class="chat-body" ref="chatBodyRef">
<template v-if="messages.length">
<div
@@ -127,11 +163,13 @@
{{ getAssistantDisplayContent(item.content) }}
</div>
<div
v-else-if="getAssistantImageUrl(item.content)"
v-else-if="getAssistantImageUrls(item.content).length"
class="assistant-image-wrap"
>
<a-image
:src="getAssistantImageUrl(item.content)"
v-for="(imageUrl, imageIndex) in getAssistantImageUrls(item.content)"
:key="`${item.id}-image-${imageIndex}`"
:src="imageUrl"
class="assistant-image"
/>
</div>
@@ -348,7 +386,7 @@
type="button"
:class="{ 'is-active': activeChatMode === item.value }"
:disabled="loading"
@click="activeChatMode = item.value"
@click="switchMode(item.value)"
>
{{ item.label }}
</button>
@@ -375,7 +413,7 @@
import "bytemd/dist/index.min.css";
import dayjs from "dayjs";
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { useRoute, useRouter } from "vue-router";
import "github-markdown-css/github-markdown-light.css";
import gfm from "@bytemd/plugin-gfm";
import zh_HansGfm from "@bytemd/plugin-gfm/locales/zh_Hans.json";
@@ -402,9 +440,12 @@ import { logout } from "@/utils/page-tab-util";
import { copyText } from "@/utils/common";
import { resolveDisplayText, isFailedPayload, pickDisplayable, FAILED_TEXT } from "@/utils/ai-result";
import { getToken } from "@/utils/token-util";
import { AI_CHAT_TYPE_LABELS, AI_CHAT_TYPE_OPTIONS, resolveAiChatType } from "@/utils/ai-chat-type";
import type { AiChatType } from "@/utils/ai-chat-type";
type ChatRole = "user" | "assistant";
type ChatMode = "chat" | "writing" | "image";
type HistoryType = AiChatType;
interface ChatMessageItem {
id: string;
@@ -413,6 +454,15 @@ interface ChatMessageItem {
loading?: boolean;
}
/** 每种模式一份独立工作区:消息、当前记录、输入草稿互不干扰 */
interface ChatWorkspace {
messages: ChatMessageItem[];
activeChatId?: number;
activeConversationId?: string;
/** 输入框草稿:切到别的模式再切回来要还在 */
draft: string;
}
interface HistorySection {
label: string;
items: AiChatList[];
@@ -434,8 +484,50 @@ const THINK_OPEN_TAG = "<think>";
const THINK_CLOSE_TAG = "</think>";
const loading = ref(false);
const stopping = ref(false);
const content = ref("");
const activeChatMode = ref<ChatMode>("chat");
// 三种模式各自一份工作区:切换模式只换视图,不丢消息、进行中的会话与输入草稿
const createWorkspace = (): ChatWorkspace => ({
messages: [],
draft: ""
});
const workspaces = reactive<Record<ChatMode, ChatWorkspace>>({
chat: createWorkspace(),
writing: createWorkspace(),
image: createWorkspace()
});
const activeWorkspace = computed(() => workspaces[activeChatMode.value]);
// 创意 / 活动策划没有对应模式,选中时进只读预览,不往工作区里塞
const previewHistory = ref<AiChatList | null>(null);
const previewMessages = ref<ChatMessageItem[]>([]);
const messages = computed<ChatMessageItem[]>({
get: () =>
previewHistory.value ? previewMessages.value : activeWorkspace.value.messages,
set: (list) => {
if (previewHistory.value) {
previewMessages.value = list;
return;
}
activeWorkspace.value.messages = list;
}
});
const content = computed<string>({
get: () => activeWorkspace.value.draft,
set: (value) => {
activeWorkspace.value.draft = value;
}
});
const activeChatId = computed<number | undefined>({
get: () => activeWorkspace.value.activeChatId,
set: (value) => {
activeWorkspace.value.activeChatId = value;
}
});
const activeConversationId = computed<string | undefined>({
get: () => activeWorkspace.value.activeConversationId,
set: (value) => {
activeWorkspace.value.activeConversationId = value;
}
});
// 写作模式的显式选择:默认 auto = 不下发这两个字段的值,交给上游 Dify 识别
const writingDocType = ref(WRITING_DOC_TYPE_FREE);
const writingWordCount = ref("auto");
@@ -452,11 +544,14 @@ const writingParams = reactive({
});
const wsConnected = ref(false);
const deletingChatId = ref<number>();
const messages = ref<ChatMessageItem[]>([]);
const historyList = ref<AiChatList[]>([]);
// 历史面板的类型筛选:纯前端过滤(列表一次拉全量)
const historyTypeFilter = ref<HistoryType | "all">("all");
const historyTypeFilters: { label: string; value: HistoryType | "all" }[] = [
{ label: "全部", value: "all" },
...AI_CHAT_TYPE_OPTIONS
];
const chatBodyRef = ref<HTMLElement | null>(null);
const activeChatId = ref<number>();
const activeConversationId = ref<string>();
const streamingMessageId = ref<string>();
const currentTaskId = ref<string>();
const currentAbortController = ref<AbortController | null>(null);
@@ -464,6 +559,7 @@ const webSocketRef = ref<WebSocket | null>(null);
const wsConnectPromiseRef = ref<Promise<void> | null>(null);
const userStore = useUserStore();
const { push } = useRouter();
const route = useRoute();
const loginUser = computed(() => userStore.info ?? {});
console.log('loginUser', loginUser)
const userName = computed(
@@ -499,6 +595,20 @@ const chatModeOptions: { label: string; value: ChatMode }[] = [
{ label: "图片", value: "image" }
];
/** 切换模式:各模式的工作区各自保留,切回来时消息、记录与草稿原样恢复 */
const switchMode = (mode: ChatMode) => {
if (mode === activeChatMode.value) {
return;
}
if (loading.value) {
message.warning("当前正在生成回复,请稍后再切换模式");
return;
}
previewHistory.value = null;
activeChatMode.value = mode;
scrollToBottom();
};
// 图片比例选择器已撤掉:上游创意助手开始节点没有比例变量(实测只有 user_content /
// user_files),前端只能把它拼进 user_content —— 那是替上游写 prompt。
// 要恢复这个功能,先在上游开始节点加变量,再按变量契约接上来。
@@ -550,7 +660,27 @@ const chatHeaderText = computed(() => {
return "AI助手待命中";
});
/** 记录类型:缺失或未知一律按聊天处理(旧数据没有 chatType) */
const getHistoryType = (item: AiChatList): HistoryType =>
resolveAiChatType(item.chatType);
const getHistoryTypeLabel = (item: AiChatList) =>
AI_CHAT_TYPE_LABELS[getHistoryType(item)];
// 纯前端筛选:列表一次拉全量,切类型不再打接口
const filteredHistoryList = computed(() => {
if (historyTypeFilter.value === "all") {
return historyList.value;
}
return historyList.value.filter(
(item) => getHistoryType(item) === historyTypeFilter.value
);
});
const activeHistoryKey = computed(() => {
if (previewHistory.value) {
return getHistoryKey(previewHistory.value);
}
if (activeChatId.value) {
return `chat-${activeChatId.value}`;
}
@@ -560,9 +690,53 @@ const activeHistoryKey = computed(() => {
return "";
});
const activeHistoryItem = computed<AiChatList | null>(() => {
if (previewHistory.value) {
return previewHistory.value;
}
if (!activeChatId.value) {
return null;
}
return historyList.value.find((item) => item.id === activeChatId.value) ?? null;
});
const activeHistoryItemType = computed<HistoryType | "">(() =>
activeHistoryItem.value ? getHistoryType(activeHistoryItem.value) : ""
);
/** 「再来一次」只对写作 / 图片记录开放;其余类型只读 */
const canReplayActiveHistory = computed(
() =>
activeHistoryItemType.value === "writing" ||
activeHistoryItemType.value === "image"
);
const historyBannerText = computed(() => {
const item = activeHistoryItem.value;
if (!item) {
return "";
}
switch (getHistoryType(item)) {
case "writing":
return item.params
? "这是写作记录,可回填文体与写作参数后重新生成(不会自动发送)。"
: "这是写作记录,未保存原参数,无法回填。";
case "image":
return item.params
? "这是图片记录,可回填提示词后重新生成(不会自动发送)。"
: "这是图片记录,未保存原提示词,无法回填。";
case "creative":
return "创意助手记录为只读,请到「AI创意助手」页面查看。";
case "activity_outline":
return "活动策划记录为只读,请到「活动策划大纲生成」页面查看。";
default:
return "";
}
});
const historySections = computed<HistorySection[]>(() => {
const groupMap = new Map<string, AiChatList[]>();
[...historyList.value]
[...filteredHistoryList.value]
.sort((a, b) => {
const left = dayjs(b.updateTime || b.createTime || 0).valueOf();
const right = dayjs(a.updateTime || a.createTime || 0).valueOf();
@@ -651,19 +825,36 @@ const getAssistantDisplayContent = (value: string) =>
const hasAssistantDisplayContent = (value: string) =>
getAssistantDisplayContent(value).trim().length > 0;
const getAssistantImageUrl = (value: string) => {
/**
* 从回复里取出图片地址。
*
* 生图结果可能是:
* ① 一条纯 URL / data:image(旧数据);
* ② 后端把多张图拼成的多个 `![](url)`(见契约 5:多张图用空行分隔)。
* 只有整段内容**全是** markdown 图片时才按图片渲染,正文里夹带插图仍走 markdown 分支。
*/
const getAssistantImageUrls = (value: string) => {
const content = getAssistantDisplayContent(value).trim();
if (!content) {
return [];
}
const firstLine = content.split("\n")[0].trim();
if (
/^data:image\//i.test(firstLine) ||
(/^https?:\/\//i.test(firstLine) &&
/\.(png|jpe?g|gif|webp|bmp|svg)(\?.*)?$/i.test(firstLine))
) {
return [firstLine];
}
const urls: string[] = [];
const rest = content.replace(/!\[[^\]]*]\(([^)\s]+)\)/g, (_match, url: string) => {
const text = String(url || "").trim();
if (text) {
urls.push(text);
}
return "";
}
if (/^(https?:\/\/|data:image\/)/i.test(content) && /\.(png|jpe?g|gif|webp|bmp|svg)(\?.*)?$/i.test(content.split("\n")[0]) || /^data:image\//i.test(content)) {
return content;
}
const markdownImageMatch = content.match(/^!\[[^\]]*]\((.+?)\)$/);
if (markdownImageMatch?.[1]) {
return markdownImageMatch[1];
}
return "";
});
return urls.length && !rest.trim() ? urls : [];
};
const shouldShowAssistantThinking = (item: ChatMessageItem) => {
@@ -695,10 +886,14 @@ const getHistoryTitle = (item: AiChatList) => {
if (title) {
return title;
}
if (item.conversationId) {
return `会话 ${item.conversationId.slice(0, 8)}`;
// 「会话 xxx」只保留给聊天:生成记录没有会话,回退成「未命名记录」
if (getHistoryType(item) === "chat") {
if (item.conversationId) {
return `会话 ${item.conversationId.slice(0, 8)}`;
}
return "未命名对话";
}
return "未命名对话";
return "未命名记录";
};
const getHistoryDateLabel = (value?: string) => {
@@ -845,10 +1040,18 @@ const appendAssistantChunk = (answer: string, conversationId?: string) => {
scrollToBottom();
};
const finishAssistantMessage = async (conversationId?: string) => {
const finishAssistantMessage = async (
conversationId?: string,
chatId?: number | string
) => {
if (conversationId) {
activeConversationId.value = conversationId;
}
// 聊天记录也改为成功后才建行:__END__ 带 chatId 时直接认领
const generatedChatId = Number(chatId);
if (Number.isFinite(generatedChatId) && generatedChatId > 0) {
activeChatId.value = generatedChatId;
}
const target = messages.value.find(
(item) => item.id === streamingMessageId.value
);
@@ -911,7 +1114,8 @@ const handleSocketMessage = async (raw: string) => {
currentTaskId.value = data.taskId;
}
if (data?.answer === "__END__") {
await finishAssistantMessage(data.conversationId);
// 契约:__END__ 帧可能带 chatId(记录成功写入时才有)
await finishAssistantMessage(data.conversationId, data.chatId);
disconnectWebSocket();
return;
}
@@ -1004,14 +1208,25 @@ const loadHistoryList = async (autoSelect = true) => {
}
const response = await listAiChatList({ userId });
historyList.value = response || [];
syncActiveChat();
// 预览的记录被删掉后,别让消息区停在旧内容上
if (
autoSelect &&
!activeChatId.value &&
!activeConversationId.value &&
historyList.value.length
previewHistory.value &&
!historyList.value.some((item) => item.id === previewHistory.value?.id)
) {
await selectChat(historyList.value[0], true);
previewHistory.value = null;
previewMessages.value = [];
}
syncActiveChat();
if (autoSelect && !activeChatId.value && !activeConversationId.value) {
// 默认仍打开最近的聊天;带类型筛选时打开该类型的第一条
const autoType =
historyTypeFilter.value === "all" ? "chat" : historyTypeFilter.value;
const target = historyList.value.find(
(item) => getHistoryType(item) === autoType
);
if (target) {
await selectChat(target, true);
}
}
};
@@ -1028,9 +1243,20 @@ const loadChatHistory = async (item: AiChatList) => {
const selectChat = async (item: AiChatList, silent = false) => {
if (loading.value && !silent) {
message.warning("当前正在生成回复,请稍后再切换会话");
message.warning("当前正在生成回复,请稍后再切换历史记录");
return;
}
const type = getHistoryType(item);
// 创意 / 活动策划没有对应模式:进只读预览,不覆盖任何模式的工作区
if (type === "creative" || type === "activity_outline") {
previewHistory.value = item;
await loadChatHistory(item);
return;
}
previewHistory.value = null;
// 打开记录时切到它所属的模式,历史内容落在各自的工作区里
activeChatMode.value =
type === "writing" ? "writing" : type === "image" ? "image" : "chat";
activeChatId.value = item.id;
activeConversationId.value = item.conversationId;
await loadChatHistory(item);
@@ -1042,6 +1268,8 @@ const startNewChat = () => {
return;
}
disconnectWebSocket();
previewHistory.value = null;
previewMessages.value = [];
activeChatId.value = undefined;
activeConversationId.value = undefined;
streamingMessageId.value = undefined;
@@ -1049,6 +1277,110 @@ const startNewChat = () => {
content.value = "";
};
/** 从入参快照里按候选键取字符串(后端可能存 camelCase 或 snake_case) */
const readHistoryParam = (params: Record<string, any>, keys: string[]) => {
for (const key of keys) {
const value = params[key];
if (typeof value === "string" && value.trim()) {
return value.trim();
}
if (typeof value === "number" && Number.isFinite(value)) {
return String(value);
}
}
return "";
};
/** 解析 `params` 快照;不是合法 JSON 对象时返回 null(调用方忽略) */
const parseHistoryParams = (value?: string) => {
const text = String(value || "").trim();
if (!text) {
return null;
}
try {
const parsed = JSON.parse(text);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed as Record<string, any>;
}
} catch {
// 解析失败按「没有参数」处理,不影响其它记录
}
return null;
};
/** 「再来一次」:把写作参数与正文回填到写作面板,不自动发送 */
const applyWritingHistoryParams = (params: Record<string, any>) => {
const docType = readHistoryParam(params, ["docType", "doc_type"]);
writingDocType.value = WRITING_DOC_TYPE_OPTIONS.some(
(item) => item.value === docType
)
? docType
: WRITING_DOC_TYPE_FREE;
const eventType = readHistoryParam(params, ["eventType", "event_type"]);
writingParams.eventType = WRITING_EVENT_TYPE_OPTIONS.includes(eventType)
? eventType
: "";
writingParams.theme = readHistoryParam(params, ["theme"]);
writingParams.time = readHistoryParam(params, ["time"]);
writingParams.location = readHistoryParam(params, ["location"]);
writingParams.participants = readHistoryParam(params, ["participants"]);
const wordCount = readHistoryParam(params, [
"number",
"wordCount",
"word_count"
]);
writingWordCount.value = WRITING_WORD_COUNT_OPTIONS.some(
(item) => item.value === wordCount
)
? wordCount
: "auto";
// 模板模式的正文是 highlights,自由创作是 userContent
content.value = readHistoryParam(params, [
"highlights",
"userContent",
"user_content"
]);
if (writingTemplateMode.value && writingParamCount.value > 0) {
writingParamsOpen.value = true;
}
};
/** 「再来一次」:图片记录只回填提示词 */
const applyImageHistoryParams = (params: Record<string, any>) => {
content.value = readHistoryParam(params, [
"user_content",
"userContent",
"content",
"query"
]);
};
/** 回填选中记录的原参数,交给用户确认后再发送 */
const replayActiveHistory = () => {
const item = activeHistoryItem.value;
if (!item || !canReplayActiveHistory.value) {
return;
}
if (loading.value) {
message.warning("当前正在生成,请稍后再回填参数");
return;
}
const params = parseHistoryParams(item.params);
if (!params) {
message.warning("这条记录没有可回填的参数");
return;
}
previewHistory.value = null;
if (getHistoryType(item) === "writing") {
activeChatMode.value = "writing";
applyWritingHistoryParams(params);
} else {
activeChatMode.value = "image";
applyImageHistoryParams(params);
}
message.success("已回填原参数,确认后点击发送");
};
// 原本这里有一个 stringifyUnknown(JSON.stringify 整包兜底),已移除:
// 它会把 conversationId / taskId / workflowRunId 等内部标识符渲染给用户。
// 现在统一走 @/utils/ai-result 的 resolveDisplayText(见 issue 10)。
@@ -1304,6 +1636,19 @@ const buildImagePayload = (query: string) => {
};
};
/**
* 生成成功后认领后端写入的记录:末帧带回 chatId 就设为当前高亮项并刷新列表;
* 没带回(失败态 / 后端未回)就只刷新列表,不猜 id。
*/
const claimGeneratedChatId = async (result: unknown) => {
const chatId = Number((result as any)?.chatId);
if (Number.isFinite(chatId) && chatId > 0) {
activeChatId.value = chatId;
activeConversationId.value = undefined;
}
await loadHistoryList(false);
};
const sendModeMessage = async (
query: string,
assistantId: string,
@@ -1323,6 +1668,7 @@ const sendModeMessage = async (
currentTaskId.value = (result as any).taskId;
}
setResult(formatWritingResult(result));
await claimGeneratedChatId(result);
return;
}
if (activeChatMode.value === "image") {
@@ -1332,16 +1678,17 @@ const sendModeMessage = async (
currentTaskId.value = (result as any).taskId;
}
setResult(formatCreativeResult(result));
await claimGeneratedChatId(result);
}
};
const removeChat = async (item: AiChatList) => {
if (loading.value) {
message.warning("当前正在生成回复,请稍后再删除会话");
message.warning("当前正在生成回复,请稍后再删除历史记录");
return;
}
if (!item.id) {
message.error("当前会话缺少标识,无法删除");
message.error("当前记录缺少标识,无法删除");
return;
}
if (!window.confirm(`确定删除“${getHistoryTitle(item)}”吗?`)) {
@@ -1349,17 +1696,23 @@ const removeChat = async (item: AiChatList) => {
}
deletingChatId.value = item.id;
try {
const isPreview = previewHistory.value?.id === item.id;
const isActive =
activeChatId.value === item.id ||
activeConversationId.value === item.conversationId;
(!!item.conversationId &&
activeConversationId.value === item.conversationId);
await removeAiChatList(item.id);
if (isPreview) {
previewHistory.value = null;
previewMessages.value = [];
}
await loadHistoryList(false);
if (isActive) {
const nextChat = historyList.value[0];
const nextChat = filteredHistoryList.value.find(
(row) => row.id !== item.id
);
if (nextChat) {
activeChatId.value = nextChat.id;
activeConversationId.value = nextChat.conversationId;
await loadChatHistory(nextChat);
await selectChat(nextChat, true);
} else {
startNewChat();
}
@@ -1426,6 +1779,14 @@ const onSend = async () => {
message.error("未获取到当前登录用户");
return;
}
// 从只读预览切回当前模式的工作区再追加消息,别把新消息写进预览里
previewHistory.value = null;
previewMessages.value = [];
// 写作 / 图片每次生成都是一条独立记录,先清掉上一条的高亮
if (activeChatMode.value !== "chat") {
activeChatId.value = undefined;
activeConversationId.value = undefined;
}
const query = content.value.trim();
// 只填了写作参数、没写正文时,气泡上仍要有一句话,否则是一条空消息
const displayQuery =
@@ -1505,6 +1866,20 @@ const onSend = async () => {
onMounted(async () => {
try {
// 独立页跳过来时带 ?type=<chat_type>:预置历史筛选,写作 / 图片顺便切到对应模式
const queryType = String(route.query.type || "").trim();
if (
queryType === "chat" ||
queryType === "writing" ||
queryType === "image" ||
queryType === "creative" ||
queryType === "activity_outline"
) {
historyTypeFilter.value = queryType;
if (queryType === "writing" || queryType === "image") {
activeChatMode.value = queryType;
}
}
if (!loginUser.value.userId) {
await userStore.fetchUserInfo();
}
@@ -1689,6 +2064,41 @@ export default {
}
}
.history-heading {
font-size: 14px;
font-weight: 700;
color: #1f2d3d;
}
.history-hint {
margin-top: 4px;
font-size: 12px;
color: #8a94a6;
}
.history-filter {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.history-filter-btn {
border: 1px solid rgba(21, 144, 255, 0.24);
border-radius: 999px;
background: #f5fbff;
color: #1590ff;
font-size: 12px;
line-height: 20px;
padding: 1px 10px;
cursor: pointer;
}
.history-filter-btn.is-active {
border-color: #1590ff;
background: #1590ff;
color: #fff;
}
.history-label {
font-size: 12px;
color: #5f7f9f;
@@ -1706,6 +2116,9 @@ export default {
.history-item-main {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 6px;
text-align: left;
background: transparent;
border: none;
@@ -1713,11 +2126,50 @@ export default {
font-size: 14px;
padding: 8px 0;
cursor: pointer;
}
.history-title-text {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 类型徽标:颜色与历史筛选、后台表格保持一致 */
.history-type-badge {
flex: none;
padding: 0 6px;
border-radius: 999px;
font-size: 11px;
line-height: 18px;
font-weight: 600;
}
.history-type-badge.is-chat {
background: #e8f4ff;
color: #1590ff;
}
.history-type-badge.is-writing {
background: #fff7e6;
color: #d46b08;
}
.history-type-badge.is-image {
background: #f9f0ff;
color: #722ed1;
}
.history-type-badge.is-creative {
background: #fff0f6;
color: #c41d7f;
}
.history-type-badge.is-activity_outline {
background: #f6ffed;
color: #389e0d;
}
.history-item-main:disabled,
.history-delete-btn:disabled {
opacity: 0.6;
@@ -1839,6 +2291,46 @@ export default {
z-index: 1;
}
.history-banner {
position: relative;
z-index: 1;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
width: 800px;
margin: 0 auto 14px;
padding: 8px 12px;
border: 1px dashed rgba(21, 144, 255, 0.28);
border-radius: 12px;
background: rgba(255, 255, 255, 0.78);
}
.history-banner-text {
flex: 1;
min-width: 200px;
color: #5f7f9f;
font-size: 13px;
line-height: 1.6;
}
.history-replay-btn {
flex: none;
border: 1px solid #1590ff;
border-radius: 999px;
background: #1590ff;
color: #fff;
font-size: 12px;
font-weight: 600;
padding: 3px 14px;
cursor: pointer;
}
.history-replay-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.chat-body {
flex: 1 1 0;
height: 0;
@@ -1928,6 +2420,9 @@ export default {
}
.assistant-image-wrap {
display: flex;
flex-direction: column;
gap: 10px;
max-width: 640px;
}
@@ -2374,5 +2869,9 @@ export default {
.chat-main {
padding: 24px 24px 0;
}
.history-banner {
width: 100%;
}
}
</style>