feat: 无界 ticket 登录、AI 写作配置确认与定时任务弹窗修复

无界 onlineTicket 对接 MaxKey 与 consumer 接口并用 sub 登录;新增 setup_writing 写作配置确认流;定时任务创建弹窗限制多行高度并支持滚动。
This commit is contained in:
fengxiangdebaozi committed 2026-06-03 16:20:45 +08:00
1 parent ab876fc7d8
commit ad4b27b7ca
25 files changed
+980 -54

No files matched your search

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo } from 'react';
import { PenLine } from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -14,6 +14,7 @@ import { useModels } from '@/core/models/hooks';
import type { AIWritingRequest } from '../../types/ai-writing';
import { useArticleTypes } from '../../hooks/useArticleTypes';
import { useWritingForm } from '../../contexts/WritingFormContext';
interface AIWritingFormProps {
onSubmit: (req: AIWritingRequest) => void;
@@ -31,7 +32,6 @@ const INPUT_CLASS =
const DEFAULT_KEYWORD_COUNT = 1;
const DEFAULT_AUDIENCE = '普通读者';
const DEFAULT_MATERIAL_SOURCE: 'knowledge_base' | 'skill' = 'knowledge_base';
const MAX_INTENT_LENGTH = 500;
// 素材来源选项:知识库=素材专家默认 web/intranet 检索;技能检索=用技能编排去检索
@@ -49,16 +49,17 @@ export function AIWritingForm({
disabled,
hideAdvancedFields = false,
}: AIWritingFormProps) {
const [intent, setIntent] = useState('');
const [showOutline, setShowOutline] = useState(false);
const [outlineText, setOutlineText] = useState('');
const [articleType, setArticleType] = useState('analysis');
const [audience, setAudience] = useState('普通读者');
const [wordCountInput, setWordCountInput] = useState('800');
const [strictMode, setStrictMode] = useState(false);
const [materialSource, setMaterialSource] =
useState<'knowledge_base' | 'skill'>(DEFAULT_MATERIAL_SOURCE);
const [modelName, setModelName] = useState('');
const {
intent, setIntent,
showOutline, setShowOutline,
outlineText, setOutlineText,
articleType, setArticleType,
audience, setAudience,
wordCountInput, setWordCountInput,
strictMode, setStrictMode,
materialSource, setMaterialSource,
modelName, setModelName,
} = useWritingForm();
const { models, isLoading: modelsLoading } = useModels();
const { articleTypes, loading: typesLoading, refresh: refreshTypes } = useArticleTypes();
@@ -161,7 +162,7 @@ export function AIWritingForm({
<div className="mt-4 space-y-2">
<button
type="button"
onClick={() => setShowOutline((v) => !v)}
onClick={() => setShowOutline(!showOutline)}
disabled={disabled}
className="flex w-full items-center justify-between text-left"
>
@@ -0,0 +1,164 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Sparkles } from 'lucide-react';
import { toast } from 'sonner';
import {
PromptInput,
PromptInputFooter,
PromptInputSubmit,
PromptInputTextarea,
} from '@/components/ai-elements/prompt-input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { ArtifactsProvider } from '@/components/workspace/artifacts';
import {
MessageList,
type SetupWritingApprovalPayload,
} from '@/components/workspace/messages';
import { ThreadContext } from '@/components/workspace/messages/context';
import { getAPIClient } from '@/core/api';
import { useModels } from '@/core/models/hooks';
import { useThreadStream } from '@/core/threads/hooks';
import { uuid } from '@/core/utils/uuid';
import { cn } from '@/lib/utils';
import { useArticleTypes } from '../../hooks/useArticleTypes';
import { useWritingForm } from '../../contexts/WritingFormContext';
/**
* 写作配置 Q&A 面板(AI 写作入口页右侧,仅在未开始写作时显示)。
*
* 复用智能体创建页的「对话 → setup_writing 工具审批卡 → 回填表单」模式:
* - 用 ``useThreadStream`` 起一条独立的 lead_agent 线程,context 带 ``is_writing_setup``,
* 后端走写作配置专用 bootstrap(只挂 setup_writing 工具)。
* - 模型调用 setup_writing 被 SetupWritingApprovalMiddleware 拦截成审批卡。
* - 用户点「应用到表单」→ ``applyProposal`` 把建议字段写进左侧写作表单(WritingFormContext)。
*
* 这条线程只负责「帮用户把表单聊清」,与真正写作的 ai_writing graph 完全解耦。
*/
export function WritingSetupChat() {
const threadId = useMemo(() => uuid(), []);
const [threadPrepared, setThreadPrepared] = useState(false);
const [chatModel, setChatModel] = useState('');
const { models, isLoading: modelsLoading } = useModels();
const { articleTypes } = useArticleTypes();
const { applyProposal } = useWritingForm();
// 首个模型可用时默认选中
useEffect(() => {
if (models.length === 0) return;
setChatModel((prev) => (prev && models.some((m) => m.name === prev) ? prev : models[0]!.name));
}, [models]);
// 注入后端 prompt 的合法文章类型(只取 key/label)
const validArticleTypes = useMemo(
() => articleTypes.map((t) => ({ key: t.key, label: t.label })),
[articleTypes],
);
const [thread, sendMessage] = useThreadStream({
threadId,
context: {
mode: 'flash',
is_writing_setup: true,
valid_article_types: validArticleTypes,
model_name: chatModel || undefined,
},
});
const ensureThreadExists = useCallback(async () => {
if (threadPrepared) return;
await getAPIClient().threads.create({ threadId, metadata: { thread_type: 'writing_setup' } });
setThreadPrepared(true);
}, [threadId, threadPrepared]);
const handleChatSubmit = useCallback(
async (text: string) => {
const trimmed = text.trim();
if (!trimmed || thread.isLoading) return;
await ensureThreadExists();
await sendMessage(threadId, { text: trimmed, files: [] });
},
[ensureThreadExists, sendMessage, thread.isLoading, threadId],
);
const handleApprove = useCallback(
async (payload: SetupWritingApprovalPayload) => {
applyProposal(payload);
toast.success('已应用到左侧表单,可继续编辑或开始写作');
},
[applyProposal],
);
const handleReject = useCallback(async () => {
await handleChatSubmit('这份配置先不应用,请继续帮我调整。');
}, [handleChatSubmit]);
return (
<ThreadContext.Provider value={{ thread }}>
<ArtifactsProvider>
<div className="flex size-full flex-col bg-background">
<header className="flex shrink-0 items-center gap-2 border-b px-4 py-3">
<Sparkles className="size-4 text-primary" />
<h2 className="text-sm font-semibold">写作助手</h2>
<span className="text-muted-foreground text-xs">描述你想写什么,我来帮你填好左侧配置</span>
</header>
<div className="min-h-0 flex-1">
<MessageList
className="size-full"
threadId={threadId}
thread={thread}
onClarificationSubmit={handleChatSubmit}
onSetupWritingApprove={handleApprove}
onSetupWritingReject={handleReject}
/>
</div>
<div className="bg-background shrink-0 space-y-2 border-t px-4 py-4">
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs">当前对话模型</span>
{modelsLoading || models.length === 0 ? (
<div className="border-input bg-background text-muted-foreground flex h-7 min-w-[10rem] items-center rounded-md border px-2 text-xs shadow-xs dark:border-[#1B477E] dark:bg-[#022248]">
{modelsLoading ? '加载中…' : '暂无模型'}
</div>
) : (
<Select value={chatModel} onValueChange={setChatModel} disabled={thread.isLoading}>
<SelectTrigger
className={cn(
'border-input bg-background h-7 min-w-[10rem] px-2 text-xs shadow-xs outline-none',
'dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF]',
)}
>
<SelectValue placeholder="选择模型" />
</SelectTrigger>
<SelectContent position="popper" className="w-[var(--radix-select-trigger-width)] dark:border-[#1B477E] dark:bg-[#022248]">
{models.map((model) => (
<SelectItem key={model.name} value={model.name} className="dark:text-[#AACEFF]">
{model.display_name || model.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<PromptInput onSubmit={({ text }) => void handleChatSubmit(text)}>
<PromptInputTextarea
placeholder="例如:帮我写一篇 2000 字、面向行业专家的新能源汽车出口分析"
disabled={thread.isLoading}
/>
<PromptInputFooter className="justify-end">
<PromptInputSubmit disabled={thread.isLoading} />
</PromptInputFooter>
</PromptInput>
</div>
</div>
</ArtifactsProvider>
</ThreadContext.Provider>
);
}
@@ -14,6 +14,7 @@ import { CanvasChatPanel } from "../chat/CanvasChatPanel";
import { CanvasArtifactPanel } from "../artifact/CanvasArtifactPanel";
import { AIWritingPanel } from "../ai-writing/AIWritingPanel";
import { AIWritingProvider } from "../../contexts/AIWritingContext";
import { WritingFormProvider } from "../../contexts/WritingFormContext";
const LAYOUT_COLLAPSED = { chat: 0, artifact: 100 };
const LAYOUT_DEFAULT = { chat: 35, artifact: 65 };
@@ -68,7 +69,9 @@ export function CanvasResizableLayout() {
<div className="flex-1 min-h-0 overflow-hidden">
{leftMode === "chat" ? <CanvasChatPanel /> : (
<AIWritingProvider>
<AIWritingPanel />
<WritingFormProvider>
<AIWritingPanel />
</WritingFormProvider>
</AIWritingProvider>
)}
</div>
@@ -0,0 +1,102 @@
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
type ReactNode,
} from 'react';
import type { SetupWritingApprovalPayload } from '@/components/workspace/messages';
/**
* 写作配置表单的共享状态。
*
* 上提到 Context 是为了让「右侧写作助手 Q&A」在用户批准模型建议后,能把建议字段
* 一次性回填到「左侧写作表单」的输入框(``applyProposal``)。表单与 Q&A 面板共用同一份
* state,因此 Provider 必须同时包住两侧面板(见 AIWritingPage)。
*/
export interface WritingFormValue {
intent: string;
setIntent: (v: string) => void;
showOutline: boolean;
setShowOutline: (v: boolean) => void;
outlineText: string;
setOutlineText: (v: string) => void;
articleType: string;
setArticleType: (v: string) => void;
wordCountInput: string;
setWordCountInput: (v: string) => void;
audience: string;
setAudience: (v: string) => void;
strictMode: boolean;
setStrictMode: (v: boolean) => void;
materialSource: 'knowledge_base' | 'skill';
setMaterialSource: (v: 'knowledge_base' | 'skill') => void;
modelName: string;
setModelName: (v: string) => void;
/** 把模型建议(setup_writing 审批 payload)回填到表单各字段。 */
applyProposal: (payload: SetupWritingApprovalPayload) => void;
}
const WritingFormContext = createContext<WritingFormValue | null>(null);
export function useWritingForm(): WritingFormValue {
const ctx = useContext(WritingFormContext);
if (!ctx) {
throw new Error('useWritingForm must be used within WritingFormProvider');
}
return ctx;
}
export function useWritingFormOptional(): WritingFormValue | null {
return useContext(WritingFormContext);
}
export function WritingFormProvider({ children }: { children: ReactNode }) {
const [intent, setIntent] = useState('');
const [showOutline, setShowOutline] = useState(false);
const [outlineText, setOutlineText] = useState('');
const [articleType, setArticleType] = useState('analysis');
const [wordCountInput, setWordCountInput] = useState('800');
const [audience, setAudience] = useState('普通读者');
const [strictMode, setStrictMode] = useState(false);
const [materialSource, setMaterialSource] = useState<'knowledge_base' | 'skill'>('knowledge_base');
const [modelName, setModelName] = useState('');
const applyProposal = useCallback((payload: SetupWritingApprovalPayload) => {
if (payload.user_intent?.trim()) setIntent(payload.user_intent.trim());
if (payload.article_type?.trim()) setArticleType(payload.article_type.trim());
if (typeof payload.word_count_target === 'number' && payload.word_count_target > 0) {
setWordCountInput(String(payload.word_count_target));
}
if (payload.target_audience?.trim()) setAudience(payload.target_audience.trim());
if (payload.writing_mode === 'strict') setStrictMode(true);
else if (payload.writing_mode === 'loose') setStrictMode(false);
if (payload.material_source === 'skill' || payload.material_source === 'knowledge_base') {
setMaterialSource(payload.material_source);
}
if (payload.outline?.trim()) {
setOutlineText(payload.outline);
setShowOutline(true);
}
}, []);
const value = useMemo<WritingFormValue>(
() => ({
intent, setIntent,
showOutline, setShowOutline,
outlineText, setOutlineText,
articleType, setArticleType,
wordCountInput, setWordCountInput,
audience, setAudience,
strictMode, setStrictMode,
materialSource, setMaterialSource,
modelName, setModelName,
applyProposal,
}),
[intent, showOutline, outlineText, articleType, wordCountInput, audience, strictMode, materialSource, modelName, applyProposal],
);
return <WritingFormContext.Provider value={value}>{children}</WritingFormContext.Provider>;
}
@@ -90,7 +90,9 @@ import { CanvasGraphProvider } from '../contexts/CanvasGraphContext';
import { CanvasUIProvider } from '../contexts/CanvasUIContext';
import { AIWritingPanel } from '../components/ai-writing/AIWritingPanel';
import { AIWritingDraftPanel } from '../components/ai-writing/AIWritingDraftPanel';
import { AIWritingProvider } from '../contexts/AIWritingContext';
import { WritingSetupChat } from '../components/ai-writing/WritingSetupChat';
import { AIWritingProvider, useAIWriting } from '../contexts/AIWritingContext';
import { WritingFormProvider } from '../contexts/WritingFormContext';
import {
ResizableHandle,
ResizablePanel,
@@ -104,6 +106,15 @@ const DRAFT_ID = 'aw-draft';
// Match the original canvas page proportions (chat=35, artifact=65)
const DEFAULT_LAYOUT = { [WRITING_ID]: 35, [DRAFT_ID]: 65 };
/**
* 右侧面板:未开始写作(idle)时显示「写作助手 Q&A」,帮用户聊清并回填左侧表单;
* 一旦开始写作(status !== idle)就切回草稿预览。
*/
function AIWritingRightPanel() {
const { state } = useAIWriting();
return state.status === 'idle' ? <WritingSetupChat /> : <AIWritingDraftPanel />;
}
export default function AIWritingPage({
hideAdvancedFields = false,
}: {
@@ -116,6 +127,7 @@ export default function AIWritingPage({
<CanvasGraphProvider>
<CanvasUIProvider>
<AIWritingProvider>
<WritingFormProvider>
<TooltipProvider delayDuration={300}>
<div className="h-full flex flex-col overflow-hidden bg-background">
<div className="flex-1 min-h-0 overflow-hidden">
@@ -147,12 +159,13 @@ export default function AIWritingPage({
minSize={40}
className="overflow-hidden"
>
<AIWritingDraftPanel />
<AIWritingRightPanel />
</ResizablePanel>
</ResizablePanelGroup>
</div>
</div>
</TooltipProvider>
</WritingFormProvider>
</AIWritingProvider>
</CanvasUIProvider>
</CanvasGraphProvider>