feat: LLM多阶段交互式Schema生成 + UI布局优化

- AI Service: 多阶段对话模型(理解→澄清→生成→校验→确认),支持对话历史和当前Schema上下文
- Backend: 适配多阶段AI模型,Gateway/Service/Controller传递history和currentSchema
- Frontend: AiDesignerChat组件重写,支持阶段徽章、快速回复、Schema校验展示
- Frontend: 表单/流程设计器布局优化,AI助手和Schema预览作为独立列
- Frontend: 流程设计器元数据和模式选择器合并为一行
- Keycloak: 自定义登录主题(中文化)
- 修复CSS媒体查询括号平衡问题
This commit is contained in:
selfrelease
2026-07-19 09:36:42 +08:00
parent a2238bc853
commit 57c8a26147
19 changed files with 853 additions and 14 deletions
+150
View File
@@ -0,0 +1,150 @@
import { useState, useRef, useEffect } from 'react';
import { api } from './api';
import type { Field, ControlType } from './schema';
export type DesignerStage = 'UNDERSTANDING' | 'CLARIFYING' | 'GENERATING' | 'VALIDATING' | 'CONFIRMING';
export type DesignerSuggestion = {
stage: DesignerStage;
formTitle?: string;
formKey?: string;
fields: {
key: string; label: string; control: string; required: boolean;
placeholder?: string; options?: string[]; helperText?: string;
}[];
process?: {
mode: string;
steps: { name: string; assigneeVariable: string }[];
conditionThresholdDays?: number | null;
};
summary: string;
understanding: string;
assumptions: string[];
needsClarification: string[];
validationIssues?: { field: string; issue: string; severity: string }[];
schemaReady: boolean;
};
type ChatMessage = {
role: 'user' | 'assistant';
content: string;
suggestion?: DesignerSuggestion;
};
export type ApplySuggestion = (s: DesignerSuggestion) => void;
const stageLabels: Record<DesignerStage, string> = {
UNDERSTANDING: '🧠 理解需求',
CLARIFYING: '❓ 澄清确认',
GENERATING: '📋 生成 Schema',
VALIDATING: '✅ 校验 Schema',
CONFIRMING: '🎯 等待确认',
};
export function AiDesignerChat({ onApply, contextLabel, getCurrentSchema }: { onApply: ApplySuggestion; contextLabel: string; getCurrentSchema?: () => string }) {
const [messages, setMessages] = useState<ChatMessage[]>([
{ role: 'assistant', content: '你好!我是 AI 设计助手。用自然语言描述你想要的表单或审批流程,我来帮你生成。\n\n例如:\n• "报销申请表单,包含报销人、金额、日期、事由"\n• "报销审批:先部门主管审批,超过5000元加财务复核,最后OA管理员审批"' },
]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const [currentStage, setCurrentStage] = useState<DesignerStage>('UNDERSTANDING');
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });
}, [messages, loading]);
function buildHistory(msgs: ChatMessage[]) {
return msgs.slice(1).map(m => ({ role: m.role, content: m.content }));
}
async function send(presetText?: string) {
const text = (presetText ?? input).trim();
if (!text || loading) return;
setInput('');
const next = [...messages, { role: 'user' as const, content: text }];
setMessages(next);
setLoading(true);
try {
const result = await api<{ suggestion: DesignerSuggestion; model: string }>('/ai/designer-suggestions', {
method: 'POST',
body: JSON.stringify({
message: text,
history: buildHistory(next.slice(0, -1)),
currentSchema: getCurrentSchema?.() ?? null,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'Asia/Shanghai',
}),
});
const s = result.suggestion;
setCurrentStage(s.stage);
const parts = formatSuggestionMessage(s);
setMessages([...next, { role: 'assistant', content: parts, suggestion: s }]);
} catch (e) {
setMessages([...next, { role: 'assistant', content: `⚠️ ${e instanceof Error ? e.message : 'AI 服务暂时不可用'}` }]);
} finally {
setLoading(false);
}
}
return <div className="aiChatPanel">
<div className="aiChatHeader">
<span> AI </span>
<small>{contextLabel}</small>
<span className="aiStageBadge">{stageLabels[currentStage]}</span>
</div>
<div className="aiChatMessages" ref={scrollRef}>
{messages.map((m, i) => <div key={i} className={`aiMsg ${m.role}`}>
<div className="aiMsgContent">{m.content}</div>
{m.suggestion && m.suggestion.fields.length > 0 && (m.suggestion.stage === 'GENERATING' || m.suggestion.stage === 'VALIDATING' || m.suggestion.stage === 'CONFIRMING') && (
<button className="aiApplyBtn" onClick={() => onApply(m.suggestion!)}> </button>
)}
{m.suggestion && m.suggestion.needsClarification.length > 0 && m.suggestion.stage === 'CLARIFYING' && (
<div className="aiQuickReplies">
{m.suggestion.needsClarification.map((q, qi) => <button key={qi} className="aiQuickReply" onClick={() => void send(q)}>{q}</button>)}
</div>
)}
{m.suggestion && m.suggestion.schemaReady && (
<div className="aiSchemaReady"> Schema </div>
)}
</div>)}
{loading && <div className="aiMsg assistant"><div className="aiMsgContent aiTyping"></div></div>}
</div>
<div className="aiChatInput">
<textarea
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); void send(); } }}
placeholder={currentStage === 'CONFIRMING' ? '输入"确认"或描述调整…' : '描述你想要的表单或流程…'}
rows={2}
disabled={loading}
/>
<button onClick={() => void send()} disabled={loading || !input.trim()}></button>
</div>
</div>;
}
function formatSuggestionMessage(s: DesignerSuggestion): string {
const parts: string[] = [];
if (s.understanding) parts.push(`🧠 我的理解:${s.understanding}`);
if (s.needsClarification.length > 0) parts.push(`❓ 需要澄清:\n${s.needsClarification.map((q, i) => `${i + 1}. ${q}`).join('\n')}`);
if (s.fields.length > 0) parts.push(`📋 表单字段(${s.fields.length} 个):\n${s.fields.map(f => `${f.label}${f.control}${f.required ? ',必填' : ''}`).join('\n')}`);
if (s.process) parts.push(`🔄 审批流程(${s.process.mode}):${s.process.steps.map(st => st.name).join(' → ')}`);
if (s.validationIssues && s.validationIssues.length > 0) parts.push(`⚠️ 校验问题:\n${s.validationIssues.map(v => ` • [${v.severity}] ${v.field}: ${v.issue}`).join('\n')}`);
if (s.assumptions.length > 0) parts.push(`💡 假设:${s.assumptions.join('')}`);
if (s.summary) parts.push(`📝 ${s.summary}`);
if (s.schemaReady) parts.push(`✅ Schema 已就绪,请确认或调整`);
return parts.join('\n\n') || '已处理';
}
export function suggestionToFields(s: DesignerSuggestion): Field[] {
return s.fields.map(f => ({
id: crypto.randomUUID(),
key: f.key,
label: f.label,
control: (['text', 'textArea', 'select', 'dateTime', 'number'].includes(f.control) ? f.control : 'text') as ControlType,
required: f.required,
placeholder: f.placeholder,
options: f.options,
helperText: f.helperText,
}));
}
+12 -3
View File
@@ -4,6 +4,7 @@ import { userManager } from './auth';
import { buildSchemas, type ControlType, type Field } from './schema';
import { ProcessBindings } from './ProcessBindings';
import { ProcessDesigner } from './ProcessDesigner';
import { AiDesignerChat, suggestionToFields, type DesignerSuggestion } from './AiDesignerChat';
type FormVersion = { form_key: string; version: number; status: string; published_at?: string };
const palette: { control: ControlType; label: string }[] = [
@@ -46,11 +47,17 @@ function Designer() {
const [selected, setSelected] = useState(fields[0].id);
const [versions, setVersions] = useState<FormVersion[]>([]);
const [message, setMessage] = useState('');
const [showAI, setShowAI] = useState(false);
const schemas = useMemo(() => buildSchemas(title, fields), [title, fields]);
const selectedField = fields.find(f => f.id === selected);
const loadVersions = () => api<FormVersion[]>('/admin/process-configuration/forms').then(setVersions).catch(e => setMessage(e.message));
useEffect(() => { void loadVersions(); }, []);
function applySuggestion(s: DesignerSuggestion) {
if (s.formTitle) setTitle(s.formTitle);
if (s.formKey) setFormKey(s.formKey);
if (s.fields.length) { const newFields = suggestionToFields(s); setFields(newFields); setSelected(newFields[0]?.id ?? ''); }
}
function add(control: ControlType) {
const index = fields.length + 1;
const field: Field = { id: crypto.randomUUID(), key: `field${index}`, label: `字段 ${index}`, control, required: false, ...(control === 'select' ? { options: ['OPTION_1', 'OPTION_2'] } : {}) };
@@ -69,13 +76,15 @@ function Designer() {
}
return <div className="appShell">
<div className="subHeader"><div><strong></strong><span>Schema </span></div><button onClick={save}>稿</button></div>
<div className="subHeader"><div><strong></strong><span>Schema </span></div><div className="subHeaderActions"><button className="aiToggleBtn" onClick={() => setShowAI(!showAI)}> AI </button><button onClick={save}>稿</button></div></div>
{message && <div className="toast" onClick={() => setMessage('')}>{message}</div>}
<section className="meta"><label> Key<input value={formKey} onChange={e => setFormKey(e.target.value)} /></label><label><input value={title} onChange={e => setTitle(e.target.value)} /></label></section>
<main className="designer">
<main className={`designer ${showAI ? 'designerWithAI' : ''}`}>
<aside><h2></h2>{palette.map(p => <button className="palette" key={p.control} onClick={() => add(p.control)}> {p.label}</button>)}<h2></h2>{versions.filter(v => v.form_key === formKey).map(v => <div className="version" key={v.version}><span>v{v.version} · {v.status}</span>{v.status === 'DRAFT' && <button onClick={() => publish(v.version)}></button>}</div>)}</aside>
<section className="canvas"><div className="phone"><div className="phoneHeader">{title}</div>{fields.map((f, i) => <div className={`field ${selected === f.id ? 'selected' : ''}`} key={f.id} draggable onDragStart={e => e.dataTransfer.setData('field', f.id)} onDragOver={e => e.preventDefault()} onDrop={e => dropField(e, f.id)} onClick={() => setSelected(f.id)}><label>{f.label}{f.required && ' *'}</label>{f.control === 'textArea' ? <textarea disabled placeholder={f.placeholder} /> : f.control === 'select' ? <select disabled><option>{f.options?.[0] ?? '请选择'}</option></select> : <input disabled placeholder={f.control === 'dateTime' ? '请选择日期和时间' : f.placeholder} />}<div className="fieldActions"><button onClick={e => { e.stopPropagation(); move(f.id, -1); }}></button><button onClick={e => { e.stopPropagation(); move(f.id, 1); }}></button><button onClick={e => { e.stopPropagation(); setFields(fields.filter(x => x.id !== f.id)); }}>×</button></div><small>{i + 1}</small></div>)}</div></section>
<aside className="properties"><h2></h2>{selectedField ? <><label> Key<input value={selectedField.key} onChange={e => update({ key: e.target.value })} /></label><label><input value={selectedField.label} onChange={e => update({ label: e.target.value })} /></label><label><input value={selectedField.placeholder ?? ''} onChange={e => update({ placeholder: e.target.value })} /></label><label className="check"><input type="checkbox" checked={selectedField.required} onChange={e => update({ required: e.target.checked })} /></label>{selectedField.control === 'select' && <label><textarea value={(selectedField.options ?? []).join('\n')} onChange={e => update({ options: e.target.value.split('\n') })} /></label>}</> : <p></p>}<details><summary> Schema</summary><pre>{JSON.stringify(schemas, null, 2)}</pre></details></aside>
{showAI && <div className="aiChatContainer"><AiDesignerChat onApply={applySuggestion} contextLabel="表单设计" getCurrentSchema={() => JSON.stringify(schemas)} /></div>}
{showAI && <aside className="schemaPreview"><h2> Schema</h2><pre>{JSON.stringify(schemas, null, 2)}</pre></aside>}
<aside className="properties"><h2></h2>{selectedField ? <><label> Key<input value={selectedField.key} onChange={e => update({ key: e.target.value })} /></label><label><input value={selectedField.label} onChange={e => update({ label: e.target.value })} /></label><label><input value={selectedField.placeholder ?? ''} onChange={e => update({ placeholder: e.target.value })} /></label><label className="check"><input type="checkbox" checked={selectedField.required} onChange={e => update({ required: e.target.checked })} /></label>{selectedField.control === 'select' && <label><textarea value={(selectedField.options ?? []).join('\n')} onChange={e => update({ options: e.target.value.split('\n') })} /></label>}</> : <p></p>}</aside>
</main>
</div>;
}
+19 -4
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { api } from './api';
import { AiDesignerChat, type DesignerSuggestion } from './AiDesignerChat';
type Mode = 'SERIAL' | 'PARALLEL' | 'CONDITIONAL';
type Assignee = 'approverId' | 'oaAdministratorId' | 'hrReviewerId';
@@ -28,6 +29,7 @@ export function ProcessDesigner() {
]);
const [selected, setSelected] = useState(steps[0].id);
const [message, setMessage] = useState('');
const [showAI, setShowAI] = useState(false);
const [rules, setRules] = useState<ApprovalRule[]>(fallbackRules);
const [history, setHistory] = useState<ProcessHistory[]>([]);
async function loadConfiguration() {
@@ -46,6 +48,18 @@ export function ProcessDesigner() {
const duplicateRestrictedRule = mode !== 'SERIAL' && new Set(effectiveSteps.map(step => step.assigneeVariable)).size !== effectiveSteps.length;
const selectedRule = rules.find(rule => rule.variable === selectedStep?.assigneeVariable);
function applySuggestion(s: DesignerSuggestion) {
if (s.process) {
const p = s.process;
const m = (['SERIAL', 'PARALLEL', 'CONDITIONAL'].includes(p.mode) ? p.mode : 'SERIAL') as Mode;
setMode(m);
if (p.steps.length) {
const newSteps = p.steps.map(st => ({ id: crypto.randomUUID(), name: st.name, assigneeVariable: (['approverId', 'oaAdministratorId', 'hrReviewerId'].includes(st.assigneeVariable) ? st.assigneeVariable : 'approverId') as Assignee }));
setSteps(newSteps); setSelected(newSteps[0]?.id ?? '');
}
if (p.conditionThresholdDays != null) setThresholdDays(p.conditionThresholdDays);
}
}
function changeMode(next: Mode) {
setMode(next);
if (next === 'CONDITIONAL' && steps.length < 2) setSteps([...steps, { id: crypto.randomUUID(), name: 'OA 管理员复核', assigneeVariable: 'oaAdministratorId' }]);
@@ -79,16 +93,17 @@ export function ProcessDesigner() {
return <section className="processDesignerPage">
{message && <div className="toast" onClick={() => setMessage('')}>{message}</div>}
<div className="processTop"><div><h1></h1><p>使 Flowable</p></div><button onClick={deploy} disabled={duplicateRestrictedRule}></button></div>
<section className="processMeta"><label> Key<input value={key} onChange={e => setKey(e.target.value)} /></label><label><input value={name} onChange={e => setName(e.target.value)} /></label></section>
<div className="modeSelector">{modes.map(item => <button key={item.value} className={mode === item.value ? 'modeActive' : 'modeButton'} onClick={() => changeMode(item.value)}><strong>{item.label}</strong><span>{item.hint}</span></button>)}</div>
<div className="processWorkspace">
<div className="processTop"><div><h1></h1><p>使 Flowable</p></div><div className="subHeaderActions"><button className="aiToggleBtn" onClick={() => setShowAI(!showAI)}> AI </button><button onClick={deploy} disabled={duplicateRestrictedRule}></button></div></div>
<section className="processMetaRow"><div className="processMeta"><label> Key<input value={key} onChange={e => setKey(e.target.value)} /></label><label><input value={name} onChange={e => setName(e.target.value)} /></label></div><div className="modeSelector">{modes.map(item => <button key={item.value} className={mode === item.value ? 'modeActive' : 'modeButton'} onClick={() => changeMode(item.value)}><strong>{item.label}</strong><span>{item.hint}</span></button>)}</div></section>
<div className={`processWorkspace ${showAI ? 'processWorkspaceWithAI' : ''}`}>
<div className="processCanvas"><div className="templateHint"><strong>{modes.find(item => item.value === mode)?.label}</strong><span>{summary}</span></div>
<div className={`processGraph ${mode.toLowerCase()}`}><ProcessNode kind="start" title="开始" subtitle="业务已提交" /><Arrow />
{mode === 'PARALLEL' && <><ProcessNode kind="gateway" title="并行拆分" subtitle="同时创建任务" /><Arrow /></>}
<div className={mode === 'PARALLEL' ? 'parallelBranches' : 'linearNodes'}>{effectiveSteps.map((step, index) => <div className="graphStep" key={step.id}><button className={`approvalNode ${selected === step.id ? 'nodeSelected' : ''}`} onClick={() => setSelected(step.id)}><span>{index + 1}</span><strong>{step.name}</strong><small>{rules.find(item => item.variable === step.assigneeVariable)?.label}</small></button>{mode !== 'PARALLEL' && index < effectiveSteps.length - 1 && <Arrow label={mode === 'CONDITIONAL' ? `>${thresholdDays}` : undefined} />}</div>)}</div>
{mode === 'PARALLEL' && <><Arrow /><ProcessNode kind="gateway" title="并行汇聚" subtitle="全部通过" /></>}<Arrow /><ProcessNode kind="end" title="结束" subtitle="批准 / 驳回" /></div>
</div>
{showAI && <div className="aiChatContainer"><AiDesignerChat onApply={applySuggestion} contextLabel="流程设计" getCurrentSchema={() => JSON.stringify({ key, name, mode, steps: effectiveSteps.map(({ name: n, assigneeVariable }) => ({ name: n, assigneeVariable })), thresholdDays })} /></div>}
{showAI && <aside className="schemaPreview"><h2> Schema</h2><pre>{JSON.stringify({ key, name, mode, steps: effectiveSteps.map(({ name: n, assigneeVariable }) => ({ name: n, assigneeVariable })), thresholdDays }, null, 2)}</pre></aside>}
<aside className="processProperties"><h2></h2>{mode === 'CONDITIONAL' && <label><input type="number" min="0" step="0.5" value={thresholdDays} onChange={e => setThresholdDays(Number(e.target.value))} /></label>}<h2></h2>{selectedStep ? <><label><input value={selectedStep.name} onChange={e => update({ name: e.target.value })} /></label><label><select value={selectedStep.assigneeVariable} onChange={e => update({ assigneeVariable: e.target.value as Assignee })}>{rules.map(item => <option key={item.variable} value={item.variable}>{item.label}</option>)}</select></label>{selectedRule && <div className="ruleDetail"><div><span></span><strong>{selectedRule.sourceType === 'POSITION' ? '岗位' : '角色'} · {selectedRule.selector}</strong></div><div><span></span><strong>{selectedRule.scope === 'TENANT' ? '当前租户' : '发起人所在部门'}</strong></div><p>{selectedRule.description}</p><small></small></div>}<div className="nodeActions"><button onClick={() => move(selectedStep.id, -1)}></button><button onClick={() => move(selectedStep.id, 1)}></button><button className="dangerGhost" disabled={effectiveSteps.length <= (mode === 'CONDITIONAL' ? 2 : 1)} onClick={() => setSteps(steps.filter(step => step.id !== selectedStep.id))}></button></div></> : <p></p>}{mode !== 'CONDITIONAL' && steps.length < 6 && <button className="primaryWide" onClick={addStep}> </button>}{duplicateRestrictedRule && <div className="validationError"></div>}<div className="securityNote"><strong></strong><p></p></div><div className="processHistory"><h2></h2>{history.length === 0 ? <p></p> : history.map(item => <button key={`${item.key}:${item.version}`} onClick={() => loadTemplate(item)}><span><strong>{item.name}</strong><small>{item.key} · v{item.version} · {item.mode}</small></span><b></b></button>)}</div></aside>
</div>
</section>;
+1
View File
@@ -8,6 +8,7 @@ export const userManager = new UserManager({
post_logout_redirect_uri: window.location.origin,
response_type: 'code',
scope: 'openid profile email',
extraQueryParams: { ui_locales: 'zh-CN' },
userStore: new WebStorageStateStore({ store: window.sessionStorage }),
});
+5 -3
View File
@@ -1,5 +1,5 @@
export type ControlType = 'text' | 'textArea' | 'select' | 'dateTime';
export type Field = { id: string; key: string; label: string; control: ControlType; required: boolean; placeholder?: string; options?: string[] };
export type ControlType = 'text' | 'textArea' | 'select' | 'dateTime' | 'number';
export type Field = { id: string; key: string; label: string; control: ControlType; required: boolean; placeholder?: string; options?: string[]; helperText?: string };
export function buildSchemas(title: string, fields: Field[]) {
const properties: Record<string, unknown> = {};
@@ -8,7 +8,9 @@ export function buildSchemas(title: string, fields: Field[]) {
? { type: 'string', enum: field.options?.filter(Boolean) ?? [] }
: field.control === 'dateTime'
? { type: 'string', format: 'date-time' }
: { type: 'string', minLength: field.required ? 1 : 0, maxLength: field.control === 'textArea' ? 2000 : 255 };
: field.control === 'number'
? { type: 'number' }
: { type: 'string', minLength: field.required ? 1 : 0, maxLength: field.control === 'textArea' ? 2000 : 255 };
}
return {
dataSchema: { $id: `${title.toLowerCase().replace(/\s+/g, '-')}-draft`, title, type: 'object', required: fields.filter(f => f.required).map(f => f.key), properties },
File diff suppressed because one or more lines are too long
+23 -1
View File
@@ -1,13 +1,14 @@
from fastapi import Depends, FastAPI, HTTPException
from app.config import get_settings
from app.models import LeaveDraftSuggestionRequest, LeaveDraftSuggestionResponse, LeaveProgressAnswerRequest, LeaveProgressAnswerResponse
from app.models import LeaveDraftSuggestionRequest, LeaveDraftSuggestionResponse, LeaveProgressAnswerRequest, LeaveProgressAnswerResponse, DesignerSuggestionRequest, DesignerSuggestionResponse
from app.qwen import (
QwenConfigurationError,
QwenSuggestionGateway,
QwenUpstreamError,
SuggestionGateway,
ProgressGateway,
DesignerGateway,
)
app = FastAPI(title="AIOA AI Service", version="0.1.0")
@@ -21,6 +22,10 @@ def get_progress_gateway() -> ProgressGateway:
return QwenSuggestionGateway(get_settings())
def get_designer_gateway() -> DesignerGateway:
return QwenSuggestionGateway(get_settings())
@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "UP"}
@@ -55,3 +60,20 @@ async def answer_leave_progress(
except QwenUpstreamError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return LeaveProgressAnswerResponse(answer=answer, model=get_settings().qwen_model)
@app.post("/v1/designer/suggest", response_model=DesignerSuggestionResponse)
async def suggest_design(
request: DesignerSuggestionRequest,
gateway: DesignerGateway = Depends(get_designer_gateway),
) -> DesignerSuggestionResponse:
try:
suggestion = await gateway.suggest_design(request)
except QwenConfigurationError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
except QwenUpstreamError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return DesignerSuggestionResponse(
suggestion=suggestion,
model=get_settings().qwen_model,
)
+78
View File
@@ -65,3 +65,81 @@ class LeaveProgressAnswerRequest(BaseModel):
class LeaveProgressAnswerResponse(BaseModel):
answer: str = Field(min_length=1, max_length=2000)
model: str
# ── AI 对话式设计助手(多阶段交互) ──────────────────────────────────
class DesignerStage(StrEnum):
UNDERSTANDING = "UNDERSTANDING"
CLARIFYING = "CLARIFYING"
GENERATING = "GENERATING"
VALIDATING = "VALIDATING"
CONFIRMING = "CONFIRMING"
class DesignerFieldType(StrEnum):
TEXT = "text"
TEXT_AREA = "textArea"
SELECT = "select"
DATE_TIME = "dateTime"
NUMBER = "number"
class DesignerFormField(BaseModel):
key: str = Field(min_length=1, max_length=64)
label: str = Field(min_length=1, max_length=100)
control: DesignerFieldType
required: bool = False
placeholder: str | None = Field(default=None, max_length=200)
options: list[str] | None = Field(default=None, max_length=20)
helperText: str | None = Field(default=None, max_length=200)
class DesignerProcessStep(BaseModel):
name: str = Field(min_length=1, max_length=100)
assigneeVariable: str = Field(min_length=1, max_length=64)
class DesignerProcessSuggestion(BaseModel):
mode: str = Field(default="SERIAL", max_length=20)
steps: list[DesignerProcessStep] = Field(default_factory=list, max_length=10)
conditionThresholdDays: float | None = None
class SchemaValidationIssue(BaseModel):
field: str = Field(min_length=1, max_length=100)
issue: str = Field(min_length=1, max_length=300)
severity: str = Field(default="WARNING", max_length=20)
class DesignerSuggestion(BaseModel):
stage: DesignerStage = DesignerStage.UNDERSTANDING
formTitle: str | None = Field(default=None, max_length=100)
formKey: str | None = Field(default=None, max_length=64)
fields: list[DesignerFormField] = Field(default_factory=list, max_length=30)
process: DesignerProcessSuggestion | None = None
summary: str = Field(default="", max_length=500)
understanding: str = Field(default="", max_length=800)
assumptions: list[str] = Field(default_factory=list, max_length=10)
needsClarification: list[str] = Field(default_factory=list, max_length=10)
validationIssues: list[SchemaValidationIssue] = Field(default_factory=list, max_length=20)
schemaReady: bool = False
class ChatTurn(BaseModel):
role: str = Field(min_length=1, max_length=20)
content: str = Field(min_length=1, max_length=4000)
class DesignerSuggestionRequest(BaseModel):
message: str = Field(min_length=1, max_length=2000)
history: list[ChatTurn] = Field(default_factory=list, max_length=20)
currentSchema: str | None = Field(default=None, max_length=8000)
timezone: str = Field(default="Asia/Shanghai", min_length=1, max_length=64)
class DesignerSuggestionResponse(BaseModel):
suggestion: DesignerSuggestion
model: str
requiresUserConfirmation: bool = True
+98 -1
View File
@@ -5,7 +5,14 @@ from typing import Protocol
import httpx
from app.config import Settings
from app.models import LeaveDraftSuggestion, LeaveDraftSuggestionRequest, LeaveProgressAnswerRequest
from app.models import (
LeaveDraftSuggestion,
LeaveDraftSuggestionRequest,
LeaveProgressAnswerRequest,
DesignerSuggestionRequest,
DesignerSuggestion,
ChatTurn,
)
class SuggestionGateway(Protocol):
@@ -16,6 +23,10 @@ class ProgressGateway(Protocol):
async def answer_progress(self, request: LeaveProgressAnswerRequest) -> str: ...
class DesignerGateway(Protocol):
async def suggest_design(self, request: DesignerSuggestionRequest) -> DesignerSuggestion: ...
class QwenSuggestionGateway:
def __init__(self, settings: Settings) -> None:
self.settings = settings
@@ -82,6 +93,42 @@ class QwenSuggestionGateway:
except (KeyError, IndexError, TypeError, ValueError, json.JSONDecodeError) as exc:
raise QwenUpstreamError("Qwen returned an invalid progress answer") from exc
async def suggest_design(self, request: DesignerSuggestionRequest) -> DesignerSuggestion:
if not self.settings.qwen_api_key:
raise QwenConfigurationError("QWEN_API_KEY is not configured")
user_content = json.dumps(
{
"message": request.message,
"history": [t.model_dump() for t in request.history],
"currentSchema": request.currentSchema,
"timezone": request.timezone,
},
ensure_ascii=False,
)
messages: list[dict[str, str]] = [{"role": "system", "content": DESIGNER_SYSTEM_PROMPT}]
for turn in request.history:
messages.append({"role": turn.role, "content": turn.content})
messages.append({"role": "user", "content": user_content})
payload = {
"model": self.settings.qwen_model,
"temperature": 0.2,
"response_format": {"type": "json_object"},
"messages": messages,
}
async with httpx.AsyncClient(timeout=self.settings.request_timeout_seconds) as client:
response = await client.post(
f"{self.settings.qwen_base_url}/chat/completions",
headers={"Authorization": f"Bearer {self.settings.qwen_api_key}"},
json=payload,
)
if response.status_code >= 400:
raise QwenUpstreamError(f"Qwen returned HTTP {response.status_code}")
try:
content = response.json()["choices"][0]["message"]["content"]
return DesignerSuggestion.model_validate_json(content)
except (KeyError, IndexError, TypeError, ValueError) as exc:
raise QwenUpstreamError("Qwen returned an invalid designer response") from exc
SYSTEM_PROMPT = """
你是企业 OA 请假表单解析器。只把用户自然语言转换为 JSON 建议值,不执行任何业务动作。
@@ -97,6 +144,56 @@ PROGRESS_SYSTEM_PROMPT = """
不得猜测审批人、原因、流程变量或预计完成时间,不得给出批准、驳回、撤回、提交等写操作指令,不得使用 Markdown。
""".strip()
DESIGNER_SYSTEM_PROMPT = """
你是企业 OA 表单与流程设计助手,通过多阶段对话帮助用户生成正确的表单和审批流程 Schema。
## 工作流程(5 个阶段)
1. UNDERSTANDING(理解):分析用户需求,复述你的理解,判断信息是否充分。
- 如果信息不足需要澄清 → 进入 CLARIFYING
- 如果信息充分 → 直接进入 GENERATING
2. CLARIFYING(澄清):向用户提出具体问题,每次最多 3 个问题。
- 用户回答后重新评估,信息充分则进入 GENERATING
3. GENERATING(生成):根据理解生成完整的表单字段和审批流程 Schema。
- 生成后自动进入 VALIDATING
4. VALIDATING(校验):自检生成的 Schema 是否正确完整。
- 检查项:字段 key 唯一、必填字段合理、控件类型匹配、审批步骤 ≥ 2、assigneeVariable 不重复(PARALLEL/CONDITIONAL)、select 有 options
- 有问题则修复后重新校验,无问题则进入 CONFIRMING
5. CONFIRMING(确认):展示最终 Schema 摘要,请用户确认或调整。
- 用户确认 → schemaReady = true
- 用户要求调整 → 回到 GENERATING
## 输出格式
输出必须是 JSON 对象,包含以下字段:
- stage:当前阶段(UNDERSTANDING/CLARIFYING/GENERATING/VALIDATING/CONFIRMING
- understanding:你对用户需求的理解复述(中文)
- formTitle:表单中文标题
- formKey:英文 kebab-case 标识符
- fields:数组,每项含 key(英文 camelCase)、label(中文)、controltext/textArea/select/dateTime/number)、required、placeholder、options(仅 select)、helperText
- process:对象,含 modeSERIAL/PARALLEL/CONDITIONAL)、steps(数组,每项含 name 和 assigneeVariable)、conditionThresholdDays(仅 CONDITIONAL
- summary:一句话总结
- assumptions:你做出的假设
- needsClarification:需要用户回答的问题(CLARIFYING 阶段使用)
- validationIssues:校验发现的问题数组,每项含 field、issue、severityERROR/WARNING
- schemaReadySchema 是否已确认可用(仅 CONFIRMING 阶段用户确认后为 true
## 约束
- assigneeVariable 只能是:approverId(部门主管)、oaAdministratorIdOA 管理员)、hrReviewerIdHR 复核人)
- 字段 key 用英文 camelCase 且唯一,label 用中文
- 审批流程至少 2 个步骤
- PARALLEL 和 CONDITIONAL 模式下 assigneeVariable 不可重复
- CONDITIONAL 模式需设置 conditionThresholdDays
- 不得输出权限、租户、隐藏字段等敏感信息
- 不得使用 Markdown
- 如果用户提供了 currentSchema,说明用户已在图形界面修改过,你需要基于当前 Schema 进行调整而非重新生成
""".strip()
class QwenConfigurationError(RuntimeError):
pass
@@ -0,0 +1,43 @@
package com.all8ai.aioa.ai.api
import com.all8ai.aioa.ai.application.AiDesignerService
import com.all8ai.aioa.ai.domain.ChatTurn
import com.all8ai.aioa.ai.domain.DesignerSuggestionResult
import com.all8ai.aioa.identity.application.CurrentUserService
import jakarta.validation.Valid
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Size
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/v1/ai/designer-suggestions")
class AiDesignerController(
private val currentUserService: CurrentUserService,
private val service: AiDesignerService,
) {
@PostMapping
fun suggest(
@AuthenticationPrincipal jwt: Jwt,
@Valid @RequestBody request: AiDesignerSuggestionRequest,
): DesignerSuggestionResult = service.suggest(
currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")),
request.message,
request.history?.map { ChatTurn(it.role, it.content) } ?: emptyList(),
request.currentSchema,
request.timezone,
)
}
data class AiDesignerSuggestionRequest(
@field:NotBlank @field:Size(max = 2000) val message: String,
val history: List<ChatTurnDto>? = null,
@field:Size(max = 8000) val currentSchema: String? = null,
@field:NotBlank @field:Size(max = 64) val timezone: String = "Asia/Shanghai",
)
data class ChatTurnDto(val role: String, val content: String)
@@ -0,0 +1,47 @@
package com.all8ai.aioa.ai.application
import com.all8ai.aioa.ai.domain.AiDesignerGateway
import com.all8ai.aioa.ai.domain.ChatTurn
import com.all8ai.aioa.ai.domain.DesignerSuggestionResult
import com.all8ai.aioa.audit.application.AuditService
import com.all8ai.aioa.identity.domain.CurrentUser
import com.all8ai.aioa.shared.web.ApiException
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import com.all8ai.aioa.shared.security.ToolPermission
import com.all8ai.aioa.shared.security.requirePermission
@Service
class AiDesignerService(
private val gateway: AiDesignerGateway,
private val auditService: AuditService,
) {
fun suggest(actor: CurrentUser, message: String, history: List<ChatTurn>, currentSchema: String?, timezone: String): DesignerSuggestionResult {
actor.requirePermission(ToolPermission.PROCESS_CONFIGURATION_MANAGE_TENANT)
val normalized = message.trim()
if (normalized.isEmpty() || normalized.length > 2000) {
throw ApiException(HttpStatus.BAD_REQUEST, "AI_PROMPT_INVALID", "描述长度必须为 1 到 2000 个字符")
}
if (timezone.isBlank() || timezone.length > 64) {
throw ApiException(HttpStatus.BAD_REQUEST, "TIMEZONE_INVALID", "时区无效")
}
val result = gateway.suggest(normalized, history, currentSchema?.takeIf { it.isNotBlank() }, timezone)
if (!result.requiresUserConfirmation) {
throw ApiException(HttpStatus.BAD_GATEWAY, "AI_CONFIRMATION_REQUIRED", "AI 建议必须要求用户确认")
}
auditService.recordSuccess(
actor,
"AI_DESIGNER_SUGGESTED",
"AI_SUGGESTION",
"designer",
null,
mapOf(
"model" to result.model,
"promptLength" to normalized.length,
"fieldCount" to result.suggestion.fields.size,
"hasProcess" to (result.suggestion.process != null),
),
)
return result
}
}
@@ -0,0 +1,57 @@
package com.all8ai.aioa.ai.domain
data class DesignerFormField(
val key: String,
val label: String,
val control: String,
val required: Boolean = false,
val placeholder: String? = null,
val options: List<String>? = null,
val helperText: String? = null,
)
data class DesignerProcessStep(
val name: String,
val assigneeVariable: String,
)
data class DesignerProcessSuggestion(
val mode: String = "SERIAL",
val steps: List<DesignerProcessStep> = emptyList(),
val conditionThresholdDays: Double? = null,
)
data class SchemaValidationIssue(
val field: String,
val issue: String,
val severity: String = "WARNING",
)
data class DesignerSuggestion(
val stage: String = "UNDERSTANDING",
val formTitle: String? = null,
val formKey: String? = null,
val fields: List<DesignerFormField> = emptyList(),
val process: DesignerProcessSuggestion? = null,
val summary: String = "",
val understanding: String = "",
val assumptions: List<String> = emptyList(),
val needsClarification: List<String> = emptyList(),
val validationIssues: List<SchemaValidationIssue> = emptyList(),
val schemaReady: Boolean = false,
)
data class DesignerSuggestionResult(
val suggestion: DesignerSuggestion,
val model: String,
val requiresUserConfirmation: Boolean = true,
)
data class ChatTurn(
val role: String,
val content: String,
)
fun interface AiDesignerGateway {
fun suggest(message: String, history: List<ChatTurn>, currentSchema: String?, timezone: String): DesignerSuggestionResult
}
@@ -0,0 +1,31 @@
package com.all8ai.aioa.ai.infrastructure
import com.all8ai.aioa.ai.domain.AiDesignerGateway
import com.all8ai.aioa.ai.domain.ChatTurn
import com.all8ai.aioa.ai.domain.DesignerSuggestionResult
import com.all8ai.aioa.shared.web.ApiException
import org.springframework.beans.factory.annotation.Value
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Component
import org.springframework.web.client.RestClient
import org.springframework.web.client.RestClientException
@Component
class HttpAiDesignerGateway(@Value("\${aioa.ai-service.url}") aiServiceUrl: String) : AiDesignerGateway {
private val client = RestClient.builder().baseUrl(aiServiceUrl).build()
override fun suggest(message: String, history: List<ChatTurn>, currentSchema: String?, timezone: String): DesignerSuggestionResult = try {
client.post()
.uri("/v1/designer/suggest")
.body(DesignerRequest(message, history, currentSchema, timezone))
.retrieve()
.body(DesignerSuggestionResult::class.java)
?: throw ApiException(HttpStatus.BAD_GATEWAY, "AI_RESPONSE_EMPTY", "AI 服务未返回设计建议")
} catch (e: ApiException) {
throw e
} catch (_: RestClientException) {
throw ApiException(HttpStatus.BAD_GATEWAY, "AI_SERVICE_UNAVAILABLE", "AI 服务暂时不可用")
}
}
private data class DesignerRequest(val message: String, val history: List<ChatTurn>, val currentSchema: String?, val timezone: String)
+1
View File
@@ -38,6 +38,7 @@ services:
- "8081:8080"
volumes:
- ./keycloak/realm-aioa.json:/opt/keycloak/data/import/realm-aioa.json:ro
- ./keycloak/themes/aioa:/opt/keycloak/themes/aioa:ro
minio:
image: minio/minio:RELEASE.2025-06-13T11-33-47Z
+5 -1
View File
@@ -1,10 +1,14 @@
{
"realm": "aioa",
"enabled": true,
"displayName": "AIOA Development",
"displayName": "AIOA 智能办公自动化平台",
"registrationAllowed": false,
"resetPasswordAllowed": true,
"loginWithEmailAllowed": true,
"loginTheme": "aioa",
"internationalizationEnabled": true,
"defaultLocale": "zh_CN",
"supportedLocales": ["zh-CN", "zh_CN", "en", "en-US"],
"roles": {
"realm": [
{ "name": "employee", "description": "普通员工" },
@@ -0,0 +1,105 @@
# AIOA 自定义中文消息
# 登录页
loginTitle=AIOA 管理平台登录
loginTitleHtml=AIOA 管理平台
loginSubtitle=请使用您的账号登录
doLogIn=登 录
doRegister=注册
doCancel=取消
doSubmit=提交
doBack=返回
doYes=
doNo=
doContinue=继续
doIgnore=忽略
doAccept=接受
doDecline=拒绝
doForgotPassword=忘记密码?
doClickHere=点击这里
doTryAgain=重试
doTryAnotherWay=尝试其他方式
doConfirmDelete=确认删除
# 账号字段
username=用户名
usernameOrEmail=用户名或邮箱
password=密码
passwordConfirm=确认密码
passwordNew=新密码
passwordConfirmNew=再次输入新密码
email=邮箱
firstName=
lastName=
rememberMe=记住我
# 提示信息
loginTotpTitle=移动认证器应用
loginTotpMessage=请输入验证码
loginTotpStep1=第 1 步:在认证器应用中添加此账号
loginTotpStep2=第 2 步:输入验证码
loginTotpStep3DeviceName=请为您的设备命名(可选)
loginTotpManualStep2=请输入以下密钥:
loginTotpScanBarcode=扫描二维码
loginTotpOneTimeCode=验证码
loginTotp.totp=基于时间(TOTP
loginTotp.hotp=基于计数器(HOTP
# 忘记密码
emailForgotTitle=忘记密码
emailForgotInstructions=请输入您的用户名或邮箱,我们将发送重置密码的链接。
# 错误信息
invalidUserMessage=用户名或密码错误
invalidPasswordMessage=密码错误
invalidEmailMessage=请输入有效的邮箱地址
invalidUsernameMessage=用户名不存在
invalidUsernameOrEmailMessage=用户名或邮箱不存在
accountDisabledMessage=账号已被禁用
accountTemporarilyDisabledMessage=账号已被临时锁定
expiredCodeMessage=登录超时,请重试
expiredActionMessage=操作超时,请重试
sessionLimitExceeded=会话数超限
identityProviderDisabledMessage=身份提供者已被禁用
identityProviderAlreadyLinkedMessage=该第三方账号已绑定其他用户
identityProviderNotFoundMessage=无法连接到身份提供者
emailNotFoundMessage=未找到与该邮箱关联的账号
federatedIdentityLinkMessage=请验证您的邮箱以关联账号
federatedIdentityUnlinkSuccessMsg=已成功解除第三方账号关联
confirmAccountLinkingMsg=确认关联您的账号 {0} 与 {1}
confirmEmailAddress=请确认您的邮箱地址
confirmExecutionDescription=确认执行操作
# 信息提示
emailSendInstructions=重置密码邮件已发送
emailSendAlreadyInstructions=重置密码邮件已发送
updatePasswordTitle=更新密码
updatePasswordMessage=请设置您的新密码
updatePasswordMessageTwo=请确认您的新密码
# 页脚
termsText=登录即表示您同意我们的服务条款
privacyPolicy=隐私政策
termsOfService=服务条款
# 其他
requiredMessage=此字段为必填
browserLogin=浏览器登录
otherMethods=其他登录方式
grantInformation=请确认授权以下权限
scopes=权限范围
# OTP 验证
loginTotpIntro=为了您的账号安全,请配置两步验证
# 信息页面
infoTitle=提示
infoMessage=请按照说明操作
# 登出
logoutTitle=您已登出
logoutSignOutIn=您已成功登出,可以重新登录
backToApplication=返回应用
# 会话超时
sessionTimeoutMsg=您的会话已超时,请重新登录
@@ -0,0 +1,124 @@
/*
* AIOA 自定义登录主题样式
* 适配 Keycloak v2 主题 (PatternFly v5)
*/
:root {
--pf-v5-global--primary-color--100: #2563eb;
--pf-v5-global--primary-color--200: #1d4ed8;
--pf-v5-global--BackgroundColor--100: #f0f4f8;
--aioa-gradient-start: #1e3a8a;
--aioa-gradient-end: #2563eb;
}
/* 整体页面背景 */
.login-pf body,
body.pf-v5-c-login {
background: linear-gradient(135deg, var(--aioa-gradient-start) 0%, var(--aioa-gradient-end) 100%) !important;
min-height: 100vh;
}
/* 登录卡片 */
.pf-v5-c-login__main {
background: #ffffff;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
}
.pf-v5-c-login__main-header {
padding: 40px 36px 0 36px;
}
.pf-v5-c-login__main-header h1 {
font-size: 22px;
font-weight: 700;
color: #1e293b;
}
.pf-v5-c-login__main-header p {
font-size: 14px;
color: #64748b;
}
.pf-v5-c-login__main-body {
padding: 28px 36px;
}
/* 表单输入框 */
.pf-v5-c-form__group {
margin-bottom: 20px;
}
.pf-v5-c-form-control {
border: 1.5px solid #cbd5e1 !important;
border-radius: 8px !important;
padding: 10px 14px !important;
font-size: 15px !important;
transition: border-color 0.2s, box-shadow 0.2s;
}
.pf-v5-c-form-control:focus {
border-color: #2563eb !important;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15) !important;
outline: none;
}
/* 登录按钮 */
.pf-v5-c-button.pf-m-primary {
background: linear-gradient(135deg, #1e3a8a, #2563eb) !important;
border: none !important;
border-radius: 8px !important;
padding: 12px 24px !important;
font-size: 15px !important;
font-weight: 600 !important;
width: 100%;
cursor: pointer;
transition: opacity 0.2s, transform 0.1s;
}
.pf-v5-c-button.pf-m-primary:hover {
opacity: 0.92;
}
.pf-v5-c-button.pf-m-primary:active {
transform: scale(0.98);
}
/* 次要链接 */
.pf-v5-c-login__main-footer a,
.pf-v5-c-form__helper-text a {
color: #2563eb;
font-size: 14px;
text-decoration: none;
}
.pf-v5-c-login__main-footer a:hover,
.pf-v5-c-form__helper-text a:hover {
text-decoration: underline;
}
/* 提示信息 */
.pf-v5-c-alert {
border-radius: 8px;
font-size: 14px;
}
/* 页脚 */
.pf-v5-c-login__footer {
text-align: center;
font-size: 12px;
color: rgba(255, 255, 255, 0.7);
padding: 16px;
}
/* 响应式 */
@media (max-width: 480px) {
.pf-v5-c-login__main {
border-radius: 12px;
}
.pf-v5-c-login__main-header,
.pf-v5-c-login__main-body {
padding-left: 20px;
padding-right: 20px;
}
}
@@ -0,0 +1,18 @@
// AIOA 开发环境:预填充测试管理员凭据
(function () {
function prefill() {
var usernameInput = document.getElementById('username');
var passwordInput = document.getElementById('password');
if (usernameInput && !usernameInput.value) {
usernameInput.value = 'admin';
}
if (passwordInput && !passwordInput.value) {
passwordInput.value = 'Admin123!';
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', prefill);
} else {
prefill();
}
})();
@@ -0,0 +1,12 @@
parent=keycloak.v2
import=common/keycloak
# Styles generated by the server
styles=css/login.css
scripts=js/prefill.js
# Meta tags
meta=viewport==width=device-width,initial-scale=1.0
# Branding
displayName=AIOA 登录