创作配置
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+ 不套模板时,这段内容会作为 `user_content` 交给「AI 创意助手」;选上面的文稿模板后可填写活动信息等可选参数。
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
上传附件
@@ -119,6 +131,7 @@
+
@@ -136,8 +149,9 @@
提交说明
- 页面会先提交 `POST /ai/manuscriptGen`,再按 `/ai` 页面的方式通过 WebSocket
- 流式接收生成结果;如果后端仍然直接返回正文,页面也会自动兼容解析。
+ 未选模板(自由创作)时只提交一段内容,后端转「AI 创意助手」;选了模板才提交
+ `docType` 等结构化条件。两种都会先 `POST /ai/manuscriptGen`,再按 `/ai` 页面的方式
+ 通过 WebSocket 流式接收生成结果;如果后端仍然直接返回正文,页面也会自动兼容解析。
@@ -227,7 +241,9 @@
import { resolveDisplayText, isFailedPayload, pickDisplayable, FAILED_TEXT } from '@/utils/ai-result';
interface FormState {
+ /** 空串 = 自由创作(不套模板);非空 = 文稿模板,走「内容创作与宣传助手」 */
docType: string;
+ freeContent: string;
eventType: string;
theme: string;
time: string;
@@ -243,7 +259,7 @@
summary: string;
}
- type DemoType = 'news' | 'wechat' | 'speech';
+ type DemoType = 'news' | 'wechat' | 'speech' | 'free';
type StreamResolver = {
resolve: () => void;
reject: (error: Error) => void;
@@ -275,8 +291,13 @@
const THINK_OPEN_TAG = '
';
const THINK_CLOSE_TAG = '';
+ /** 「自由创作」的取值:选中它时不下发 docType,后端转「AI 创意助手」 */
+ const DOC_TYPE_FREE = '自由创作';
+
const form = reactive
({
- docType: '新闻稿',
+ // 默认自由创作:未选模板就说清想写什么,交给「AI 创意助手」
+ docType: DOC_TYPE_FREE,
+ freeContent: '',
eventType: '校园活动',
theme: '',
time: '',
@@ -287,6 +308,8 @@
});
const docTypeOptions = [
+ // 自由创作:表单只留一个内容输入框,提交时不带 docType
+ { label: '自由创作(不套模板)', value: DOC_TYPE_FREE },
{ label: '新闻稿', value: '新闻稿' },
{ label: '公众号推文', value: '公众号推文' },
{ label: '宣传稿', value: '宣传稿' },
@@ -304,7 +327,13 @@
{ label: '品牌项目', value: '品牌项目' }
];
+ /** 选了文稿模板才展开其余可选参数 */
+ const templateMode = computed(() => !!form.docType && form.docType !== DOC_TYPE_FREE);
+
const canSubmit = computed(() => {
+ if (!templateMode.value) {
+ return !!form.freeContent.trim();
+ }
return !!(form.theme.trim() || form.highlights.trim() || uploadedFiles.value.length);
});
@@ -375,6 +404,13 @@
});
const buildPayload = (): ManuscriptGenParams => {
+ if (!templateMode.value) {
+ // 自由创作:只发一段内容,后端见 docType 为空就转「AI 创意助手」
+ return {
+ docType: '',
+ userContent: form.freeContent.trim()
+ };
+ }
return {
docType: form.docType,
eventType: form.eventType,
@@ -718,7 +754,9 @@
};
const resetForm = () => {
- form.docType = '新闻稿';
+ // 重置回自由创作,而不是上一次的模板
+ form.docType = DOC_TYPE_FREE;
+ form.freeContent = '';
form.eventType = '校园活动';
form.theme = '';
form.time = '';
@@ -730,6 +768,12 @@
};
const fillDemo = (type: DemoType) => {
+ if (type === 'free') {
+ form.docType = DOC_TYPE_FREE;
+ form.freeContent =
+ '帮我写一段迎新晚会的主持人开场词,口语化一点,时长两分钟左右,面向大一新生。';
+ return;
+ }
if (type === 'news') {
form.theme = '广西医科大学青年志愿者开展“清洁校园”志愿服务活动';
form.docType = '新闻稿';
@@ -805,7 +849,11 @@
const handleGenerate = async () => {
if (!canSubmit.value) {
- message.warning('请至少填写稿件标题、亮点内容或上传附件中的一项');
+ message.warning(
+ templateMode.value
+ ? '请至少填写稿件标题、亮点内容或上传附件中的一项'
+ : '请先描述你想创作的内容'
+ );
return;
}
loading.value = true;
@@ -814,7 +862,9 @@
responseSummary.value = '正在建立 WebSocket 连接...';
try {
await connectWebSocket();
- responseSummary.value = '连接成功,已提交生成任务,等待流式返回...';
+ responseSummary.value = templateMode.value
+ ? '连接成功,已提交生成任务,等待流式返回...'
+ : '自由创作已提交给 AI 创意助手,生成完成后一次性返回...';
const waitStream = createStreamWaiter();
const result = await manuscriptGen(buildPayload());
if (!streamStarted.value && shouldTreatAsDirectResult(result)) {
diff --git a/src/views/oa/chatgpt/index.bak.vue b/src/views/oa/chatgpt/index.bak.vue
deleted file mode 100644
index 8a39d6a..0000000
--- a/src/views/oa/chatgpt/index.bak.vue
+++ /dev/null
@@ -1,231 +0,0 @@
-
-
-
-
-
-
-
-
-
- |
-
- |
-
-
- |
-
- 复制
- |
-
-
-
-
-
-
- 清空当前会话
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/views/oa/chatgpt/index.vue b/src/views/oa/chatgpt/index.vue
index 2bfa10f..8c73648 100644
--- a/src/views/oa/chatgpt/index.vue
+++ b/src/views/oa/chatgpt/index.vue
@@ -226,32 +226,20 @@
>
{{ item.label }}
- 字数
-
-
-
- 图片比例
-
+
+ 字数
+
+
参考素材
@@ -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("chat");
-const imageRatio = ref("1:1");
/**
* 图片模式的参考素材,对应 Dify「AI 创意助手」开始节点的 file-list 变量 `user_files`。
*
@@ -416,8 +402,8 @@ const imageRatio = ref("1:1");
const imageFiles = ref([]);
const uploadingFiles = ref(false);
const fileInputRef = ref();
-// 写作模式的显式选择:默认 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();
@@ -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 = {
+ 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") {
diff --git a/src/views/oa/chatgpt/writing-params.ts b/src/views/oa/chatgpt/writing-params.ts
index 2631c32..806d5b7 100644
--- a/src/views/oa/chatgpt/writing-params.ts
+++ b/src/views/oa/chatgpt/writing-params.ts
@@ -1,365 +1,44 @@
/**
- * 「AI 写作助手」入参解析:把用户自然语言诉求抽取成上游 Dify 真正读取的字段。
+ * 「AI 写作助手」的可选项定义(2026-10-21 改造后)。
*
- * 背景(实测记录见 .scratch/ai-assistant/probe/):
- * 上游应用「AI内容创作与宣传助手」是按**结构化表单**设计的,它的 user prompt 模板是
+ * 本文件与 `tuanwei-uniapp/src/pages/assistant/writing-params.js` 保持同一套常量,
+ * 改动其一请同步另一侧。
*
- * 本次需要生成的文稿类型为:{doc_type}
- * 篇幅要求(字数限制):{Number}
- * 以下是本次活动的核心要素,请严格据此生成稿件:
- * - 活动主题:{theme}
- * - 活动类型:{event_type}
- * - 举办时间:{time} / 举办地点:{location} / 参与人员:{participants}
- * - 核心亮点与具体要求:{highlights}
+ * 改造前这里还负责把用户原话解析成「键:值」入参,再据此填 doc_type / Number。
+ * 现在写作模式只有两条路,都不解析自然语言:
*
- * 其中 doc_type 与 Number 是**主控指令**,模型会显式服从、甚至忽略用户原话里的冲突要求。
- * 实测思维链原文:
- * I will ignore the conflicting "300字推文" instruction inside the bullet points
- * because the main task says "文稿类型为:新闻稿" and "篇幅要求(字数限制):800"
+ * 1. 自由创作(value = `auto`):原话整段作为 `userContent`,后端见 `docType` 为空
+ * 就转「AI 创意助手」(只吃 `user_content`);
+ * 2. 选了文体:原话整段作为 `highlights`(核心亮点与具体要求),连同 doc_type
+ * 交给「内容创作与宣传助手」,字数只认界面上的显式选择。
*
- * 而聊天页的「写作」入口过去没有表单,只按「键:值」解析,解析不到就一律回落
- * doc_type=新闻稿 / Number=800,用户自然语言里的真实诉求(推文、300 字)因此被覆盖,
- * 表现为「写作模块听不懂要求」。
+ * 为什么不猜:上游提示词里 doc_type / Number 是**主控指令**,模型会显式服从、
+ * 甚至压掉用户原话里的冲突要求(实测思维链:「I will ignore the conflicting
+ * "300字推文" instruction … because the main task says "文稿类型为:新闻稿"」)。
+ * 从原话里推断还会在短祈使句上产出「帮我写个」这类残片当活动主题
+ * (见 issue 13 的「2026-10-19 契约修正」)。
*
- * 本模块做三件事:
- * 1. 保留既有的「键:值」入参(向后兼容,老用法不受影响);
- * 2. 从自然语言里抽取文体、字数、主题、时间、地点、人员、活动类型;
- * 3. 允许用户在界面上显式指定文体 / 字数,显式选择优先于自动识别。
- *
- * 纯函数、零依赖,可直接用 node 运行测试。
+ * 本模块只剩选项常量:纯数据、零依赖。
*/
-export interface WritingOverrides {
- /** 用户显式选择的文体;不传或传 "auto" 表示自动识别 */
- docType?: string;
- /** 用户显式选择的字数;不传表示自动识别 */
- number?: number;
-}
-
-export interface WritingRequest {
- docType: string;
- eventType: string;
- theme: string;
- time: string;
- location: string;
- participants: string;
- highlights: string;
- number?: number;
-}
+/** 「自由创作」的取值,同时也是 `writingDocType` 的默认值 */
+export const WRITING_DOC_TYPE_FREE = 'auto';
/** 上游 Dify 提示词里真正定义了 Markdown 结构与文风的 4 种文体 */
export const WRITING_DOC_TYPE_OPTIONS = [
- { label: '自动识别', value: 'auto' },
+ { label: '自由创作', value: WRITING_DOC_TYPE_FREE },
{ label: '新闻稿', value: '新闻稿' },
{ label: '活动总结', value: '活动总结' },
{ label: '微信推文', value: '微信推文' },
{ label: '通知公告', value: '通知公告' }
];
+/** 字数只在选了文体之后才显示;`auto` 表示不下发 */
export const WRITING_WORD_COUNT_OPTIONS = [
- { label: '自动识别', value: 'auto' },
+ { label: '默认', value: 'auto' },
{ label: '300字', value: '300' },
{ label: '500字', value: '500' },
{ label: '800字', value: '800' },
{ label: '1000字', value: '1000' },
{ label: '1500字', value: '1500' }
];
-
-const DEFAULT_DOC_TYPE = '新闻稿';
-const DEFAULT_EVENT_TYPE = '校园内容';
-
-/**
- * 用户没提字数时的兜底:按文体给一个常规篇幅。
- *
- * 之所以不干脆不下发 Number —— 上游提示词对空字数的处理是「按文体常规篇幅撰写,
- * 并在文末用一行注明(未设定有效字数限制)」,那行尾注会直接出现在用户看到的正文里。
- */
-const DEFAULT_WORD_COUNT_BY_DOC_TYPE: Record = {
- 新闻稿: 800,
- 活动总结: 1000,
- 微信推文: 600,
- 通知公告: 500
-};
-
-const FIELD_ALIASES: Record = {
- docType: ['稿件类型', '文稿类型', '文体', '类型'],
- eventType: ['活动类型', '场景'],
- theme: ['稿件标题', '标题', '主题', '活动主题'],
- time: ['活动时间', '举办时间', '时间'],
- location: ['活动地点', '举办地点', '地点'],
- participants: ['参与人员', '参加人员', '受众', '人员'],
- highlights: ['亮点内容', '要点', '亮点', '具体要求', '要求', '内容'],
- number: ['建议篇幅', '字数', '篇幅']
-};
-
-/** 活动类型关键词(与 gxmu/manuscriptGen 表单页的选项保持一致的措辞) */
-const EVENT_TYPE_KEYWORDS = [
- '志愿服务',
- '社会实践',
- '文体活动',
- '会议论坛',
- '品牌项目',
- '校园活动',
- '团日活动',
- '迎新',
- '讲座',
- '培训',
- '比赛',
- '表彰',
- '招聘',
- '宣讲'
-];
-
-/** 既有的「键:值」解析:按行取 `键:值`,普通自然语言(无冒号)解析为空对象 */
-export const parseKeyValueInput = (value: string) => {
- const result: Record = {};
- value
- .split(/\n+/)
- .map((item) => item.trim())
- .filter(Boolean)
- .forEach((line) => {
- const match = line.match(/^([^::]+)[::]\s*(.*)$/);
- if (match) {
- result[match[1].trim()] = match[2].trim();
- }
- });
- return result;
-};
-
-const pickField = (fields: Record, aliases: string[]) => {
- for (const alias of aliases) {
- const value = fields[alias];
- if (value) {
- return value;
- }
- }
- return '';
-};
-
-/** 上游没有内置模板、但用户会说的其它文体;命中就原样交给模型发挥 */
-const EXTRA_DOC_TYPE_WORDS = [
- '发言稿',
- '演讲稿',
- '致辞',
- '主持词',
- '短视频脚本',
- '视频脚本',
- '脚本',
- '策划书',
- '活动方案',
- '简报',
- '心得体会'
-];
-
-/**
- * 从一段文本里识别文体关键词,认不出来返回空串。
- *
- * 只认「明确的文体词」,不做任何兜底 —— 它会被喂进用户的**整句话**,
- * 若像 {@link normalizeDocType} 那样原样返回,整句话就会变成一个非法的文稿类型。
- */
-export const inferDocType = (text: string) => {
- if (/推文|公众号|微信|朋友圈|新媒体/.test(text)) {
- return '微信推文';
- }
- if (/总结|复盘|综述/.test(text)) {
- return '活动总结';
- }
- if (/通知|公告|告示|通告|启事/.test(text)) {
- return '通知公告';
- }
- if (/新闻|通讯|通稿|报道|简讯|宣传稿|消息稿/.test(text)) {
- return '新闻稿';
- }
- for (const word of EXTRA_DOC_TYPE_WORDS) {
- if (text.includes(word)) {
- return word;
- }
- }
- return '';
-};
-
-/**
- * 把用户给出的文体归一化到上游定义过的 4 种。
- *
- * 认不出来的类型(如「发言稿」「短视频脚本」)**原样返回**,交给模型自行发挥 ——
- * 强行映射成「新闻稿」会生成一篇完全不是发言稿的文章,比不映射更糟。
- * 注意:入参应当是「文体名」(键值或界面选择),不要传用户整句话。
- */
-export const normalizeDocType = (raw: string) => {
- const value = (raw || '').trim();
- if (!value) {
- return '';
- }
- return inferDocType(value) || value;
-};
-
-/**
- * 从文本里抽取目标字数。
- *
- * 覆盖「300字」「300字左右」「不少于500字」「800-1000字」等写法;
- * 区间取上界(用户给区间时通常不希望写少)。50~10000 之外视为无效。
- */
-export const parseWordCount = (text: string): number | undefined => {
- const valid = (raw: string | undefined) => {
- if (!raw) {
- return undefined;
- }
- const value = Number(raw);
- return value >= 50 && value <= 10000 ? value : undefined;
- };
- // 「键:值」入参里的字数常常就是一个裸数字(字数:500)
- const bare = text.trim().match(/^(\d{2,5})\s*(?:字|字符)?$/);
- if (bare) {
- return valid(bare[1]);
- }
- const range = text.match(
- /(\d{2,5})\s*[-~~—-至到]\s*(\d{2,5})\s*(?:字|字符)/
- );
- if (range) {
- return valid(range[2]);
- }
- const single = text.match(/(\d{2,5})\s*(?:字|字符)/);
- if (single) {
- return valid(single[1]);
- }
- const target = text.match(
- /(?:不少于|不低于|至少|不超过|大约|约|控制在|限制在|需要|要)\s*(\d{2,5})\s*(?:字|字符)?/
- );
- return valid(target?.[1]);
-};
-
-/** 从「关于X的」「以X为主题」「写一篇X总结」等说法里抽取活动主题 */
-export const parseTheme = (query: string) => {
- const patterns = [
- /关于[「“"'《]?([^,。;!?\n]{2,40}?)[」”"'》]?的/,
- /以[「“"'《]?([^,。;!?\n]{2,40}?)[」”"'》]?(?:为主题|为题|为标题)/,
- /(?:写|撰写|生成|创作|拟写|草拟|做)(?:一篇|一份|一段|一个|一则|条)?[「“"'《]?([^,。;!?\n]{2,40}?)[」”"'》]?(?:上的|中的|里的|的|之)?(?:新闻稿|通讯稿|通稿|报道|推文|总结|通知|公告|文案|稿件|宣传稿|发言稿|脚本|简讯)/,
- /([^,。;!?\n]{2,30}?)(?:活动)?(?:总结|新闻稿|推文|通知|公告|报道|简讯)/
- ];
- for (const pattern of patterns) {
- const match = query.match(pattern);
- const value = match?.[1]?.trim();
- if (value) {
- return tidyTheme(value);
- }
- }
- return '';
-};
-
-/**
- * 主题清洗:把抽到的短语里混进来的时间、地点、指令动词和助词去掉。
- * 例如「2026年3月5日在学校礼堂举办的学习雷锋志愿服务活动」→「学习雷锋志愿服务活动」。
- */
-const tidyTheme = (value: string) => {
- const cleaned = value
- .replace(/^\s*(?:\d{4}\s*年)?\s*\d{1,2}\s*月\s*\d{1,2}\s*[日号]\s*/, '')
- .replace(
- /^\s*(?:在|于)[^,。;!?\n]{2,20}?(?:举办|举行|开展|召开|进行)的?/,
- ''
- )
- .replace(/^\s*(?:开展|举办|举行|组织|进行|召开)/, '')
- .replace(/(?:上的|中的|里的|的)$/, '')
- .replace(/[,。;!?、\s]+$/, '')
- .trim();
- return cleaned || value.trim();
-};
-
-/** 兜底主题:清洗掉指令性词汇与字数要求后截断 */
-const fallbackTheme = (query: string) => {
- const cleaned = query
- .replace(/[\r\n]+/g, ' ')
- .replace(
- /(?:请|帮我|麻烦|辛苦|需要)?\s*(?:写|撰写|生成|创作|拟写|草拟|出一?)(?:一篇|一份|一段|一个|条)?/g,
- ''
- )
- .replace(/(?:关于|围绕|以)/g, '')
- .replace(
- /\d{2,5}\s*(?:[-~~—-至到]\s*\d{2,5})?\s*(?:字|字符)(?:左右|以内|以下|以上)?/g,
- ''
- )
- .replace(/[,。;!?、,.;!?]/g, ' ')
- .replace(/\s+/g, ' ')
- .trim();
- return (cleaned || query.trim()).slice(0, 30);
-};
-
-const parseTime = (query: string) => {
- const match = query.match(/(\d{4}\s*年)?\s*\d{1,2}\s*月\s*\d{1,2}\s*[日号]/);
- return match ? match[0].replace(/\s+/g, '') : '';
-};
-
-const parseLocation = (query: string) => {
- const match = query.match(
- /(?:在|于)\s*([^,。;!?\n]{2,20}?)\s*(?:举办|举行|开展|召开|进行)/
- );
- return match?.[1]?.trim() || '';
-};
-
-const parseParticipants = (query: string) => {
- const match = query.match(/面向\s*([^,。;!?\n]{2,20})/);
- return match?.[1]?.trim() || '';
-};
-
-const parseEventType = (query: string) => {
- for (const keyword of EVENT_TYPE_KEYWORDS) {
- if (query.includes(keyword)) {
- return keyword;
- }
- }
- return '';
-};
-
-/**
- * 把用户输入解析成上游工作流的入参。
- *
- * @param query 用户在写作框里输入的内容(自然语言或「键:值」)
- * @param overrides 界面上的显式选择,优先于自动识别
- */
-export const parseWritingRequest = (
- query: string,
- overrides: WritingOverrides = {}
-): WritingRequest => {
- const fields = parseKeyValueInput(query);
-
- const explicitDocType =
- overrides.docType && overrides.docType !== 'auto' ? overrides.docType : '';
- const keyedDocType = normalizeDocType(
- pickField(fields, FIELD_ALIASES.docType)
- );
- const inferredDocType = inferDocType(query) || DEFAULT_DOC_TYPE;
- const docType = explicitDocType || keyedDocType || inferredDocType;
-
- const explicitNumber =
- typeof overrides.number === 'number' && overrides.number > 0
- ? overrides.number
- : undefined;
- const keyedNumber = parseWordCount(pickField(fields, FIELD_ALIASES.number));
- const inferredNumber = parseWordCount(query);
- const number =
- explicitNumber ??
- keyedNumber ??
- inferredNumber ??
- DEFAULT_WORD_COUNT_BY_DOC_TYPE[docType] ??
- 800;
-
- const theme =
- pickField(fields, FIELD_ALIASES.theme) ||
- parseTheme(query) ||
- fallbackTheme(query);
-
- return {
- docType,
- eventType:
- pickField(fields, FIELD_ALIASES.eventType) ||
- parseEventType(query) ||
- DEFAULT_EVENT_TYPE,
- theme,
- time: pickField(fields, FIELD_ALIASES.time) || parseTime(query),
- location: pickField(fields, FIELD_ALIASES.location) || parseLocation(query),
- participants:
- pickField(fields, FIELD_ALIASES.participants) || parseParticipants(query),
- // 「核心亮点与具体要求」保留用户原话:这里放原话比放抽取结果更贴合上游用途
- highlights: pickField(fields, FIELD_ALIASES.highlights) || query,
- number
- };
-};