Commit 939372b1 authored by Kantz's avatar Kantz
Browse files

grundimplementirung vom Task modus. Muss noch verfeinert werden. Aktuell nur ChatGPT

parent 77449eda
from __future__ import annotations
def generate_hint(
query: str | None,
task: str,
hints: list[str],
solution: str,
history: list[dict] | None = None,
sources: str | None = None,
) -> str:
# Temporary placeholder so orchestrator_task can call this module now.
# Replace this implementation with your custom LLM call.
if hints:
return hints[0]
if solution:
return "Hinweis: Nutze die gegebene Musterloesung als Orientierung fuer den naechsten Schritt."
if task:
return "Hinweis: Lies die Aufgabenstellung genau und bestimme zuerst, was gesucht ist."
return "Kein Hinweis verfuegbar."
from __future__ import annotations from __future__ import annotations
from logging import config
from typing import List, Optional from typing import List, Optional
import logging import logging
...@@ -13,6 +12,8 @@ import app.config as config ...@@ -13,6 +12,8 @@ import app.config as config
if config.get_orchestrator() == "tutor": if config.get_orchestrator() == "tutor":
from app.deterministic_services.orchestrators import orchestrator_tutor as orchestrator from app.deterministic_services.orchestrators import orchestrator_tutor as orchestrator
elif config.get_orchestrator() == "task":
from app.deterministic_services.orchestrators import orchestrator_task as orchestrator
else: else:
from app.deterministic_services.orchestrators import orchestrator_qa as orchestrator from app.deterministic_services.orchestrators import orchestrator_qa as orchestrator
...@@ -29,6 +30,7 @@ class ChatMessage(BaseModel): ...@@ -29,6 +30,7 @@ class ChatMessage(BaseModel):
class ChatRequest(BaseModel): class ChatRequest(BaseModel):
messages: List[ChatMessage] messages: List[ChatMessage]
draft: Optional[str] = None draft: Optional[str] = None
selected_task: Optional[dict[str, str]] = None
class ChatResponse(BaseModel): class ChatResponse(BaseModel):
...@@ -60,8 +62,16 @@ def chat(request: ChatRequest) -> ChatResponse: ...@@ -60,8 +62,16 @@ def chat(request: ChatRequest) -> ChatResponse:
raise HTTPException(status_code=400, detail="messages required") raise HTTPException(status_code=400, detail="messages required")
try: try:
payload_messages = [{"role": m.role, "content": m.text} for m in request.messages]
if config.get_orchestrator() == "task":
result = orchestrator.run_chat( result = orchestrator.run_chat(
[{"role": m.role, "content": m.text} for m in request.messages], payload_messages,
draft=request.draft,
selected_task=request.selected_task,
)
else:
result = orchestrator.run_chat(
payload_messages,
draft=request.draft, draft=request.draft,
) )
reply = result["reply"] reply = result["reply"]
......
from __future__ import annotations
from typing import List
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
import app.config as config
from app.deterministic_services import context_store, task_catalog
router = APIRouter()
class TaskItem(BaseModel):
task_id: str
statement: str
full_text: str
class TaskFile(BaseModel):
file_id: str
title: str
intro: str
tasks: List[TaskItem]
class TasksResponse(BaseModel):
orchestrator: str
enabled: bool
task_files: List[TaskFile]
class SelectTaskRequest(BaseModel):
draft: str = Field(..., min_length=1)
file_id: str = Field(..., min_length=1)
task_id: str = Field(..., min_length=1)
class SelectTaskResponse(BaseModel):
status: str
file_id: str
task_id: str
@router.get("/api/tasks/config")
def get_task_config() -> dict[str, object]:
orchestrator = config.get_orchestrator()
return {"orchestrator": orchestrator, "enabled": orchestrator == "task"}
@router.get("/api/tasks", response_model=TasksResponse)
def list_tasks() -> TasksResponse:
orchestrator = config.get_orchestrator()
task_files = task_catalog.build_task_catalog()
return TasksResponse(
orchestrator=orchestrator,
enabled=orchestrator == "task",
task_files=task_files,
)
@router.post("/api/tasks/select", response_model=SelectTaskResponse)
def select_task(request: SelectTaskRequest) -> SelectTaskResponse:
chat_id = context_store.get_chat_id([], draft=request.draft)
sheet = context_store.load_sheet(chat_id)
if not sheet:
sheet = context_store.context_store_new.init_sheet(chat_id, [])
updated = task_catalog.select_task_by_ids(
sheet,
file_id=request.file_id,
task_id=request.task_id,
)
if not updated:
raise HTTPException(status_code=404, detail="task not found")
context_store.save_sheet(sheet)
file_id, task_id = task_catalog.get_selected_task_ids(sheet)
return SelectTaskResponse(
status="ok",
file_id=file_id or request.file_id,
task_id=task_id or request.task_id,
)
from __future__ import annotations
from app.LLM_services import task_hint_LLM
from app.deterministic_services import context_store, task_catalog
from app.deterministic_services.orchestrators import orchestrator_base as base
def _ensure_context_task_fields(state: base.ChatState, query_text: str) -> tuple[str, str] | None:
store_new = context_store.context_store_new
has_task = bool(store_new.get_task(state.sheet))
has_hints = bool(store_new.get_hints(state.sheet))
has_solution = bool(store_new.get_solution(state.sheet))
if has_task and has_hints and has_solution:
selected = task_catalog.get_selected_task_ids(state.sheet)
if selected[0] and selected[1]:
return selected[0], selected[1]
return None
sources_text = "\n".join([source.to_string() for source in context_store.get_retrieval(state.sheet)])
selection = task_catalog.select_task_for_context(
state.sheet,
query_text=query_text,
sources_text=sources_text,
history=context_store.get_history_turns(state.sheet),
)
if not selection:
return None
task_file, task_entry = selection
selected_file_id = str(task_file.get("_file_id", ""))
selected_task_id = str(task_entry.get("id", "")).zfill(2)
base.append_tool_log(
state.tool_log,
"task_json_selected",
{"tasks_dir": str(task_catalog.TASKS_DIR)},
{
"file": task_file.get("_path", ""),
"file_id": selected_file_id,
"task_id": selected_task_id,
"hint_count": len(store_new.get_hints(state.sheet)),
"has_solution": bool(store_new.get_solution(state.sheet)),
},
)
return selected_file_id, selected_task_id
def _on_bootstrap(state: base.ChatState, query_text: str) -> None:
base.bootstrap_retrieval(state.sheet, query_text, state.tool_log)
_ensure_context_task_fields(state, query_text)
def _on_turn_logic(state: base.ChatState) -> None:
_ensure_context_task_fields(state, state.last_user)
def _on_build_reply(state: base.ChatState) -> str | None:
store_new = context_store.context_store_new
history_turns = context_store.get_history_turns(state.sheet)
args = {
"query": state.last_user if not state.new_chat else None,
"task": store_new.get_task(state.sheet),
"hints": store_new.get_hints(state.sheet),
"solution": store_new.get_solution(state.sheet),
"history": history_turns,
"sources": "\n".join([source.to_string() for source in context_store.get_retrieval(state.sheet)]),
}
return base.log_timed_call(
state.tool_log,
"new_generate_hint",
args,
lambda: task_hint_LLM.generate_hint(**args),
)
def run_chat(
messages: list[dict],
draft: str | None = None,
selected_task: dict | None = None,
) -> dict:
def on_turn_logic(state: base.ChatState) -> None:
if selected_task:
selected_file_id = str(selected_task.get("file_id", "")).strip()
selected_task_id = str(selected_task.get("task_id", "")).strip()
if selected_file_id and selected_task_id:
task_catalog.select_task_by_ids(
state.sheet,
selected_file_id,
selected_task_id,
)
_on_turn_logic(state)
return base.run_chat_common(
messages=messages,
draft=draft,
on_bootstrap=_on_bootstrap,
on_turn_logic=on_turn_logic,
on_build_reply=_on_build_reply,
)
from __future__ import annotations from __future__ import annotations
from app.LLM_services import decision_LLM, hint_LLM, math_intent_LLM, solver_LLM from app.LLM_services import decision_LLM, open_hint_LLM, math_intent_LLM, solver_LLM
from app.deterministic_services import context_store from app.deterministic_services import context_store
from app.deterministic_services.orchestrators import orchestrator_base as base from app.deterministic_services.orchestrators import orchestrator_base as base
...@@ -57,7 +57,7 @@ def _on_build_reply(state: base.ChatState) -> str | None: ...@@ -57,7 +57,7 @@ def _on_build_reply(state: base.ChatState) -> str | None:
state.tool_log, state.tool_log,
"generate_hint", "generate_hint",
args, args,
lambda: hint_LLM.generate_hint(**args), lambda: open_hint_LLM.generate_hint(**args),
) )
......
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Any
from app.deterministic_services import context_store
TASKS_DIR = Path(__file__).resolve().parents[2] / "sources" / "Tasks"
def _normalize_text(value: str) -> str:
return re.sub(r"\s+", " ", value.strip().lower())
def _tokenize(value: str) -> set[str]:
return set(re.findall(r"[a-z0-9_]+", _normalize_text(value)))
def _match_score(query_text: str, candidate_text: str) -> int:
query_tokens = _tokenize(query_text)
if not query_tokens:
return 0
candidate_tokens = _tokenize(candidate_text)
return len(query_tokens.intersection(candidate_tokens))
def load_task_files(tasks_dir: Path = TASKS_DIR) -> list[dict[str, Any]]:
if not tasks_dir.exists():
return []
loaded: list[dict[str, Any]] = []
for path in sorted(tasks_dir.glob("*.json")):
try:
content = json.loads(path.read_text(encoding="utf-8"))
except Exception:
continue
if not isinstance(content, dict):
continue
if not isinstance(content.get("tasks"), list):
continue
content["_path"] = str(path)
content["_file_id"] = path.stem
loaded.append(content)
return loaded
def _find_task_file(task_files: list[dict[str, Any]], file_id: str) -> dict[str, Any] | None:
for task_file in task_files:
if str(task_file.get("_file_id", "")) == file_id:
return task_file
return None
def _find_task_entry(task_file: dict[str, Any], task_id: str) -> dict[str, Any] | None:
for item in task_file.get("tasks", []):
if not isinstance(item, dict):
continue
if str(item.get("id", "")).zfill(2) == str(task_id).zfill(2):
return item
return None
def _build_task_payload(task_file: dict[str, Any], task_entry: dict[str, Any]) -> tuple[str, list[str], str]:
title = str(task_file.get("title", "")).strip()
intro = str(task_file.get("intro", "")).strip()
statement = str(task_entry.get("statement", "")).strip()
hints_raw = task_entry.get("hints", [])
hints = [str(item).strip() for item in hints_raw if str(item).strip()] if isinstance(hints_raw, list) else []
solution = str(task_entry.get("solution", "")).strip()
task_parts = [part for part in [title, intro, statement] if part]
task_text = "\n".join(task_parts)
return task_text, hints, solution
def set_selected_task(
sheet: dict[str, Any],
task_file: dict[str, Any],
task_entry: dict[str, Any],
) -> None:
task_text, hints, solution = _build_task_payload(task_file, task_entry)
store_new = context_store.context_store_new
store_new.set_task(sheet, task_text)
store_new.set_hints(sheet, hints)
store_new.set_solution(sheet, solution)
sheet["task_file_id"] = str(task_file.get("_file_id", ""))
sheet["task_id"] = str(task_entry.get("id", "")).zfill(2)
def select_task_by_ids(
sheet: dict[str, Any],
file_id: str,
task_id: str,
task_files: list[dict[str, Any]] | None = None,
) -> bool:
catalog = task_files if task_files is not None else load_task_files()
task_file = _find_task_file(catalog, file_id)
if not task_file:
return False
task_entry = _find_task_entry(task_file, task_id)
if not task_entry:
return False
set_selected_task(sheet, task_file, task_entry)
return True
def get_selected_task_ids(sheet: dict[str, Any]) -> tuple[str | None, str | None]:
file_id = str(sheet.get("task_file_id", "")).strip()
task_id = str(sheet.get("task_id", "")).strip()
return (file_id or None, task_id or None)
def select_task_for_context(
sheet: dict[str, Any],
query_text: str,
sources_text: str,
history: list[dict],
task_files: list[dict[str, Any]] | None = None,
) -> tuple[dict[str, Any], dict[str, Any]] | None:
catalog = task_files if task_files is not None else load_task_files()
if not catalog:
return None
selected_file_id, selected_task_id = get_selected_task_ids(sheet)
if selected_file_id and selected_task_id:
task_file = _find_task_file(catalog, selected_file_id)
if task_file:
task_entry = _find_task_entry(task_file, selected_task_id)
if task_entry:
set_selected_task(sheet, task_file, task_entry)
return task_file, task_entry
haystack = "\n".join([query_text, sources_text])
best_file: dict[str, Any] | None = None
best_file_score = -1
for task_file in catalog:
title = str(task_file.get("title", ""))
intro = str(task_file.get("intro", ""))
statements = "\n".join(
[str(task.get("statement", "")) for task in task_file.get("tasks", []) if isinstance(task, dict)]
)
score = _match_score(haystack, "\n".join([title, intro, statements]))
if score > best_file_score:
best_file_score = score
best_file = task_file
if not best_file:
return None
tasks = [item for item in best_file.get("tasks", []) if isinstance(item, dict)]
if not tasks:
return None
history_text = "\n".join([str(item.get("content", "")) for item in history if isinstance(item, dict)])
id_match = re.search(r"\b(?:aufgabe|task)?\s*0?([1-9][0-9]?)\b", history_text.lower())
if id_match:
wanted = id_match.group(1).zfill(2)
for item in tasks:
if str(item.get("id", "")).zfill(2) == wanted:
set_selected_task(sheet, best_file, item)
return best_file, item
best_task = tasks[0]
best_task_score = -1
for item in tasks:
statement = str(item.get("statement", ""))
score = _match_score(history_text, statement)
if score > best_task_score:
best_task_score = score
best_task = item
set_selected_task(sheet, best_file, best_task)
return best_file, best_task
def build_task_catalog(task_files: list[dict[str, Any]] | None = None) -> list[dict[str, Any]]:
catalog = task_files if task_files is not None else load_task_files()
response: list[dict[str, Any]] = []
for task_file in catalog:
file_id = str(task_file.get("_file_id", ""))
title = str(task_file.get("title", "")).strip()
intro = str(task_file.get("intro", "")).strip()
tasks: list[dict[str, str]] = []
for item in task_file.get("tasks", []):
if not isinstance(item, dict):
continue
task_id = str(item.get("id", "")).zfill(2)
statement = str(item.get("statement", "")).strip()
full_text_parts = [part for part in [title, intro, statement] if part]
tasks.append(
{
"task_id": task_id,
"statement": statement,
"full_text": "\n".join(full_text_parts),
}
)
response.append(
{
"file_id": file_id,
"title": title,
"intro": intro,
"tasks": tasks,
}
)
return response
...@@ -3,7 +3,7 @@ import logging ...@@ -3,7 +3,7 @@ import logging
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.api import canvas, chat, health, context from app.api import canvas, chat, health, context, tasks
from app.config import get_frontend_url from app.config import get_frontend_url
from app.deterministic_services import embedding_provider from app.deterministic_services import embedding_provider
...@@ -36,3 +36,4 @@ app.include_router(chat.router) ...@@ -36,3 +36,4 @@ app.include_router(chat.router)
app.include_router(canvas.router) app.include_router(canvas.router)
app.include_router(context.router) app.include_router(context.router)
app.include_router(health.router) app.include_router(health.router)
app.include_router(tasks.router)
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import "../styles/theme.css"; import "../styles/theme.css";
import ChatWindow from "../components/Chat/ChatWindow"; import ChatWindow from "../components/Chat/ChatWindow";
import CanvasDrawer from "../components/Canvas/CanvasDrawer"; import CanvasDrawer from "../components/Canvas/CanvasDrawer";
...@@ -53,6 +53,35 @@ const sourceIdToUid = (source: ContextSource, index: number) => { ...@@ -53,6 +53,35 @@ const sourceIdToUid = (source: ContextSource, index: number) => {
return parts.length ? parts.join("|") : `source_${index}`; return parts.length ? parts.join("|") : `source_${index}`;
}; };
type TaskItem = {
task_id: string;
statement: string;
full_text: string;
};
type TaskFile = {
file_id: string;
title: string;
intro: string;
tasks: TaskItem[];
};
type TasksResponse = {
orchestrator: string;
enabled: boolean;
task_files: TaskFile[];
};
type SelectedTaskRef = {
fileId: string;
taskId: string;
};
type SelectedTask = SelectedTaskRef & {
title: string;
fullText: string;
};
const sourceIdToKey = (sourceId: ContextSource["source_id"]) => { const sourceIdToKey = (sourceId: ContextSource["source_id"]) => {
const parts = [ const parts = [
sourceId.chapter_title ?? "", sourceId.chapter_title ?? "",
...@@ -108,6 +137,13 @@ export default function App() { ...@@ -108,6 +137,13 @@ export default function App() {
kind: "info" | "error" | "ok"; kind: "info" | "error" | "ok";
message: string; message: string;
} | null>(null); } | null>(null);
const [isTaskModeEnabled, setIsTaskModeEnabled] = useState(false);
const [taskFiles, setTaskFiles] = useState<TaskFile[]>([]);
const [selectedTaskRef, setSelectedTaskRef] = useState<SelectedTaskRef | null>(
null
);
const [tasksError, setTasksError] = useState<string | null>(null);
const taskDisplayRef = useRef<HTMLDivElement | null>(null);
const docIndexes = useMemo(() => { const docIndexes = useMemo(() => {
const bySourceKey: Record<string, RetrievedDoc> = {}; const bySourceKey: Record<string, RetrievedDoc> = {};
...@@ -152,6 +188,119 @@ export default function App() { ...@@ -152,6 +188,119 @@ export default function App() {
return { bySourceKey, bySlug }; return { bySourceKey, bySlug };
}, [directChildren, indirectChildren, subsections, sections]); }, [directChildren, indirectChildren, subsections, sections]);
const selectedTask = useMemo<SelectedTask | null>(() => {
if (!selectedTaskRef) {
return null;
}
const file = taskFiles.find((item) => item.file_id === selectedTaskRef.fileId);
if (!file) {
return null;
}
const task = file.tasks.find((item) => item.task_id === selectedTaskRef.taskId);
if (!task) {
return null;
}
return {
fileId: file.file_id,
taskId: task.task_id,
title: file.title,
fullText: task.full_text,
};
}, [selectedTaskRef, taskFiles]);
const taskOptions = useMemo(
() =>
taskFiles.flatMap((file) =>
file.tasks.map((task) => ({
value: `${file.file_id}::${task.task_id}`,
label: `${file.title} - ${task.task_id}`,
}))
),
[taskFiles]
);
useEffect(() => {
let isActive = true;
const loadTasks = async () => {
setTasksError(null);
try {
const response = await fetch(`${backendUrl}/api/tasks`);
if (!response.ok) {
throw new Error(`Tasks failed: ${response.status}`);
}
const payload: TasksResponse = await response.json();
if (!isActive) {
return;
}
const enabled = Boolean(payload.enabled);
setIsTaskModeEnabled(enabled);
setTaskFiles(payload.task_files || []);
if (enabled && payload.task_files?.length) {
const firstFile = payload.task_files[0];
const firstTask = firstFile.tasks?.[0];
if (firstTask) {
setSelectedTaskRef((prev) =>
prev || { fileId: firstFile.file_id, taskId: firstTask.task_id }
);
}
} else {
setSelectedTaskRef(null);
}
} catch (error) {
if (!isActive) {
return;
}
setTasksError("Failed to load tasks.");
setIsTaskModeEnabled(false);
setTaskFiles([]);
setSelectedTaskRef(null);
void error;
}
};
void loadTasks();
return () => {
isActive = false;
};
}, []);
useEffect(() => {
if (!isTaskModeEnabled || !selectedTaskRef) {
return;
}
const syncTaskSelection = async () => {
try {
await fetch(`${backendUrl}/api/tasks/select`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
draft: chatSessionId,
file_id: selectedTaskRef.fileId,
task_id: selectedTaskRef.taskId,
}),
});
} catch (error) {
void error;
}
};
void syncTaskSelection();
}, [chatSessionId, isTaskModeEnabled, selectedTaskRef]);
useEffect(() => {
if (!isTaskModeEnabled || !selectedTask?.fullText || !taskDisplayRef.current) {
return;
}
const mathjax = window.MathJax;
if (!mathjax?.typesetPromise) {
return;
}
mathjax.typesetPromise([taskDisplayRef.current]).catch(() => undefined);
}, [isTaskModeEnabled, selectedTask?.fullText]);
const handleSend = async () => { const handleSend = async () => {
const trimmed = draft.trim(); const trimmed = draft.trim();
if (!trimmed) { if (!trimmed) {
...@@ -171,16 +320,28 @@ export default function App() { ...@@ -171,16 +320,28 @@ export default function App() {
setRetrievalError(null); setRetrievalError(null);
try { try {
const response = await fetch(`${backendUrl}/api/chat`, { const chatPayload: {
method: "POST", messages: Array<{ role: "user" | "assistant"; text: string }>;
headers: { "Content-Type": "application/json" }, draft: string;
body: JSON.stringify({ selected_task?: { file_id: string; task_id: string };
} = {
messages: [...messages, userMessage].map((message) => ({ messages: [...messages, userMessage].map((message) => ({
role: message.role, role: message.role,
text: message.text, text: message.text,
})), })),
draft: chatSessionId, draft: chatSessionId,
}), };
if (isTaskModeEnabled && selectedTaskRef) {
chatPayload.selected_task = {
file_id: selectedTaskRef.fileId,
task_id: selectedTaskRef.taskId,
};
}
const response = await fetch(`${backendUrl}/api/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(chatPayload),
}); });
if (!response.ok) { if (!response.ok) {
...@@ -275,6 +436,14 @@ export default function App() { ...@@ -275,6 +436,14 @@ export default function App() {
setIsCanvasVisible((prev) => !prev); setIsCanvasVisible((prev) => !prev);
}; };
const handleTaskChange = (value: string) => {
const [fileId, taskId] = value.split("::");
if (!fileId || !taskId) {
return;
}
setSelectedTaskRef({ fileId, taskId });
};
const resetChatState = () => { const resetChatState = () => {
setMessages(initialMessages); setMessages(initialMessages);
setDraft(""); setDraft("");
...@@ -504,6 +673,38 @@ export default function App() { ...@@ -504,6 +673,38 @@ export default function App() {
<main className="app-main"> <main className="app-main">
<section className="chat-column"> <section className="chat-column">
{isTaskModeEnabled ? (
<section className="task-panel">
<div className="task-panel-header">
<div className="task-panel-title">Task</div>
<select
className="task-select"
value={
selectedTaskRef
? `${selectedTaskRef.fileId}::${selectedTaskRef.taskId}`
: ""
}
onChange={(event) => handleTaskChange(event.target.value)}
disabled={!taskOptions.length}
>
{taskOptions.length ? (
taskOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))
) : (
<option value="">No tasks available</option>
)}
</select>
</div>
<div className="task-panel-content" ref={taskDisplayRef}>
{selectedTask?.fullText || "No task selected."}
</div>
{tasksError ? <div className="task-panel-error">{tasksError}</div> : null}
</section>
) : null}
<ChatWindow <ChatWindow
messages={messages} messages={messages}
draft={draft} draft={draft}
......
...@@ -99,6 +99,56 @@ body { ...@@ -99,6 +99,56 @@ body {
flex: 1; flex: 1;
} }
.task-panel {
background: #fef9f0;
border: 1px solid #d8d1c4;
border-radius: 16px;
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
.task-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
}
.task-panel-title {
font-weight: 600;
}
.task-select {
border: 1px solid #d8d1c4;
border-radius: 10px;
padding: 8px 10px;
background: #fff;
font-family: inherit;
font-size: 13px;
max-width: 100%;
}
.task-panel-content {
background: #fff;
border: 1px solid #e2ded5;
border-radius: 12px;
padding: 12px;
white-space: pre-line;
overflow-x: auto;
}
.task-panel-error {
padding: 8px 10px;
border-radius: 10px;
background: #f3d9d6;
color: #8a3b2f;
border: 1px solid #e2b4ae;
font-size: 12px;
}
.chat-archive-error { .chat-archive-error {
padding: 8px 10px; padding: 8px 10px;
border-radius: 10px; border-radius: 10px;
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment