feat(ai): 写作模式拆成自由创作与模板两条路(issue 18)

自由创作不再解析自然语言:原话整段作为 userContent 下发,docType 留空,由后端转
「AI 创意助手」。选了文体才提交 docType 等结构化条件,走「内容创作与宣传助手」。

- writing-params.ts 删掉 parseWritingRequest、字段别名表与字数兜底表,只留选项常量。
- chatgpt/index.vue:字数选择器只在选中文体后出现;撤掉图片比例选择器(上游开始
  节点无比例变量,只能拼进 user_content,等于替上游写 prompt);结果不再用入参补标题。
- gxmu/manuscriptGen:新增「自由创作」入口与 freeContent 文本域,模板模式才展开活动
  信息等可选项。
- 顺带删除 chatgpt/index.bak.vue 旧备份。
This commit is contained in:
2026-10-21 12:39:54 +08:00
parent 4e725c7840
commit d8ef29afe7
5 changed files with 193 additions and 701 deletions
+55 -64
View File
@@ -226,32 +226,20 @@
>
{{ item.label }}
</button>
<span class="ratio-label">字数</span>
<button
v-for="item in WRITING_WORD_COUNT_OPTIONS"
:key="item.value"
class="ratio-option"
type="button"
:class="{ 'is-active': writingWordCount === item.value }"
:disabled="loading"
@click="writingWordCount = item.value"
>
{{ item.label }}
</button>
</div>
<div v-if="activeChatMode === 'image'" class="ratio-panel">
<span class="ratio-label">图片比例</span>
<button
v-for="item in imageRatioOptions"
:key="item.value"
class="ratio-option"
type="button"
:class="{ 'is-active': imageRatio === item.value }"
:disabled="loading"
@click="imageRatio = item.value"
>
{{ item.label }}
</button>
<template v-if="writingTemplateMode">
<span class="ratio-label">字数</span>
<button
v-for="item in WRITING_WORD_COUNT_OPTIONS"
:key="item.value"
class="ratio-option"
type="button"
:class="{ 'is-active': writingWordCount === item.value }"
:disabled="loading"
@click="writingWordCount = item.value"
>
{{ item.label }}
</button>
</template>
</div>
<div v-if="activeChatMode === 'image'" class="ratio-panel attach-panel">
<span class="ratio-label">参考素材</span>
@@ -348,11 +336,10 @@ import type { AiChatList } from "@/api/ai/aiChatList/model";
import { sendMessage, stopMessage } from "@/api/oa/chatgpt";
import { manuscriptGen } from "@/api/ai/manuscriptGen";
import {
parseWritingRequest,
WRITING_DOC_TYPE_FREE,
WRITING_DOC_TYPE_OPTIONS,
WRITING_WORD_COUNT_OPTIONS
} from "./writing-params";
import type { WritingOverrides } from "./writing-params";
import { creativeAssistant, uploadAiFile } from "@/api/ai/creativeAssistant";
import type { CreativeAssistantUploadFile } from "@/api/ai/creativeAssistant/model";
import { API_BASE_URL } from "@/config/setting";
@@ -404,7 +391,6 @@ const loading = ref(false);
const stopping = ref(false);
const content = ref("");
const activeChatMode = ref<ChatMode>("chat");
const imageRatio = ref("1:1");
/**
* 图片模式的参考素材,对应 Dify「AI 创意助手」开始节点的 file-list 变量 `user_files`。
*
@@ -416,8 +402,8 @@ const imageRatio = ref("1:1");
const imageFiles = ref<CreativeAttachment[]>([]);
const uploadingFiles = ref(false);
const fileInputRef = ref<HTMLInputElement>();
// 写作模式的显式选择:默认 auto,即由 writing-params 从用户输入里自动识别
const writingDocType = ref("auto");
// 写作模式的显式选择:默认 auto = 不下发这两个字段的值,交给上游 Dify 识别
const writingDocType = ref(WRITING_DOC_TYPE_FREE);
const writingWordCount = ref("auto");
const wsConnected = ref(false);
const deletingChatId = ref<number>();
@@ -462,13 +448,9 @@ const chatModeOptions: { label: string; value: ChatMode }[] = [
{ label: "图片", value: "image" }
];
const imageRatioOptions = [
{ label: "1:1", value: "1:1" },
{ label: "4:3", value: "4:3" },
{ label: "3:4", value: "3:4" },
{ label: "16:9", value: "16:9" },
{ label: "9:16", value: "9:16" }
];
// 图片比例选择器已撤掉:上游创意助手开始节点没有比例变量(实测只有 user_content /
// user_files),前端只能把它拼进 user_content —— 那是替上游写 prompt。
// 要恢复这个功能,先在上游开始节点加变量,再按变量契约接上来。
/** 参考素材上限,与 Dify 开始节点 user_files 的 max_length 一致 */
const IMAGE_FILE_LIMIT = 5;
@@ -476,12 +458,17 @@ const IMAGE_FILE_EXTS = ["PNG", "JPG", "JPEG", "GIF", "WEBP", "BMP", "SVG"];
const VIDEO_FILE_EXTS = ["MP4", "MOV", "MPEG", "MPGA", "AVI", "M4V"];
const AUDIO_FILE_EXTS = ["MP3", "M4A", "WAV", "AMR", "AAC", "OGG", "WEBM"];
/** 选中文体 = 套模板走「内容创作与宣传助手」;否则是自由创作走「AI 创意助手」 */
const writingTemplateMode = computed(
() => writingDocType.value !== WRITING_DOC_TYPE_FREE
);
const inputPlaceholder = computed(() => {
if (activeChatMode.value === "writing") {
return "直接描述需求即可,例如:帮我写一篇300字关于迎新晚会的推文,风格活泼,面向大一新生(也可在下方直接指定文体和字数)";
return "描述你想创作的内容,例如:帮我写一段迎新晚会的主持人开场词,口语化一点(选了文体后才需要再定字数)";
}
if (activeChatMode.value === "image") {
return "请输入图片生成需求,例如校园活动海报、公众号头图、志愿服务插画,并选择图片比例";
return "请输入图片生成需求,例如校园活动海报、公众号头图、志愿服务插画";
}
return "说说你想问的问题";
});
@@ -1026,23 +1013,27 @@ const pickString = (...values: unknown[]) => {
};
const buildWritingPayload = (query: string) => {
// 界面上显式选了文体/字数就用显式值,否则由 writing-params 从自然语言里识别。
// 过去这里没有表单,一律回落 doc_type=新闻稿 / Number=800,而上游提示词把这两项
// 当作主控指令,会直接压掉用户原话里的诉求(实测见 .scratch/ai-assistant/probe/)。
const overrides: WritingOverrides = {};
if (writingDocType.value !== "auto") {
overrides.docType = writingDocType.value;
// 两条路,都不解析自然语言(2026-10-21 改造):
// - 自由创作:原话整段作为 userContent,后端见 docType 为空就转「AI 创意助手」;
// - 选了文体:原话整段作为 highlights,只带界面显式选过的 docType / number,
// 其余可选字段留空交给上游。doc_type / Number 是上游提示词的**主控指令**,
// 本地猜会猜错(见 issue 13)。
const content = String(query || "").trim();
if (writingDocType.value === WRITING_DOC_TYPE_FREE) {
return { docType: "", userContent: content };
}
if (writingWordCount.value !== "auto") {
overrides.number = Number(writingWordCount.value);
}
return {
...parseWritingRequest(query, overrides),
const payload: Record<string, unknown> = {
docType: writingDocType.value,
highlights: content,
files: []
};
if (writingWordCount.value !== "auto") {
payload.number = Number(writingWordCount.value);
}
return payload;
};
const formatWritingResult = (payload: unknown, query: string) => {
const formatWritingResult = (payload: unknown) => {
if (typeof payload === "string") {
return payload;
}
@@ -1076,7 +1067,9 @@ const formatWritingResult = (payload: unknown, query: string) => {
data.choices?.[0]?.message?.content
);
if (title || body) {
return `${title || buildWritingPayload(query).theme}\n\n${body}`;
// 不再从入参里补标题:改造后没有「从原话抽主题」这一步,
// 拼一个空标题只会在正文前多两行空行。
return title ? `${title}\n\n${body}` : body;
}
// 不再 stringifyUnknown(payload):那会把 conversationId / taskId 这类
// 内部标识符整包渲染给用户。
@@ -1222,14 +1215,6 @@ const formatCreativeResult = (payload: unknown) => {
});
};
const buildImagePrompt = (query: string) => {
return [
query,
`图片比例:${imageRatio.value}`,
"请直接生成图片,优先输出图片文件或图片链接。"
].join("\n");
};
/** 按 MIME 或扩展名判定 Dify file-list 要求的 type(image/video/audio/document) */
const resolveUploadFileType = (
contentType: string,
@@ -1309,7 +1294,13 @@ const onImageFilesPicked = async (event: Event) => {
}
};
/** 图片模式入参:user_content 一段文本 + user_files(Dify file-list 变量) */
/**
* 图片模式入参:`user_content` 就是**用户原话** + `user_files`(Dify file-list 变量)。
*
* 不往 `user_content` 里追加任何我们自己写的话(如「图片比例:1:1」
* 「请直接生成图片…」):上游创意助手只有 `user_content` / `user_files` 两个变量,
* 没有比例变量,拼进去等于替上游写 prompt。比例选择器已撤掉。
*/
const buildImagePayload = (query: string) => {
const userFiles: CreativeAssistantUploadFile[] = imageFiles.value.map((item) => ({
transfer_method: "local_file",
@@ -1318,7 +1309,7 @@ const buildImagePayload = (query: string) => {
url: ""
}));
return {
user_content: buildImagePrompt(query),
user_content: query,
user_files: userFiles
};
};
@@ -1340,7 +1331,7 @@ const sendModeMessage = async (
if (typeof (result as any)?.taskId === "string") {
currentTaskId.value = (result as any).taskId;
}
setResult(formatWritingResult(result, query));
setResult(formatWritingResult(result));
return;
}
if (activeChatMode.value === "image") {