feat(tickets): Kurz-Verweise (Shortlinks) im Ticket-Text auflösen
Die KI kann im Text `tier:<8hex>` / `kontakt:` / `wurf:` / `gehege:` schreiben; Frontend löst per POST /refs/resolve (Präfix-Match, DB-agnostisch) zu Name + internem Link auf. Reine KI-Schreibhilfe — kein Nutzer-Button. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
51
gerbil-manager-web/src/api/refs.ts
Normal file
51
gerbil-manager-web/src/api/refs.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* REFS: API-Client für getippte Kurz-Verweise (Shortlinks, git-Stil) in Freitexten.
|
||||
*
|
||||
* Tokens im Text: `tier:<code>`, `kontakt:<code>`, `wurf:<code>`, `gehege:<code>` —
|
||||
* `<code>` ist ein GUID-Präfix (mind. 6 Hex) oder die volle GUID. Das Backend löst
|
||||
* sie zu { id, name } auf (mehrdeutig/unbekannt => id=null); das Frontend rendert
|
||||
* daraus Name + internen Link zur jeweiligen Detailseite.
|
||||
*/
|
||||
import { api } from './client'
|
||||
|
||||
/** Die vier auflösbaren Entitätstypen (Token-Präfix vor dem ":"). */
|
||||
export type RefType = 'tier' | 'kontakt' | 'wurf' | 'gehege'
|
||||
|
||||
export interface RefRequest {
|
||||
type: RefType
|
||||
code: string
|
||||
}
|
||||
|
||||
export interface RefResolution {
|
||||
type: string
|
||||
code: string
|
||||
/** Volle GUID oder null (unbekannt ODER mehrdeutig — nicht geraten). */
|
||||
id: string | null
|
||||
name: string | null
|
||||
}
|
||||
|
||||
/** Routen-Präfix je Typ für den internen Link (siehe App.tsx). */
|
||||
export const REF_ROUTE: Record<RefType, string> = {
|
||||
tier: '/rennmaeuse',
|
||||
kontakt: '/kontakte',
|
||||
wurf: '/wuerfe',
|
||||
gehege: '/gehege',
|
||||
}
|
||||
// Hinweis: Frontend-Routen weichen vom Token-Namen ab (wurf→/wuerfe), daher die Map.
|
||||
|
||||
/** Token-Regex: `tier|kontakt|wurf|gehege` ":" gefolgt von >=6 Hex (oder voller GUID mit "-"). */
|
||||
export const REF_TOKEN_RE = /\b(tier|kontakt|wurf|gehege):([0-9a-fA-F]{6,}(?:-[0-9a-fA-F-]+)?)/g
|
||||
|
||||
/** Erste 8 Hex-Stellen (erster GUID-Block) als Kurz-ID — wie eine kurze Git-Commit-ID. */
|
||||
export function shortCode(id: string): string {
|
||||
return id.replace(/-/g, '').slice(0, 8)
|
||||
}
|
||||
|
||||
/** Vollständiges Token für die Zwischenablage, z. B. "tier:3c5b747b". */
|
||||
export function shortToken(type: RefType, id: string): string {
|
||||
return `${type}:${shortCode(id)}`
|
||||
}
|
||||
|
||||
export function resolveRefs(refs: RefRequest[]): Promise<RefResolution[]> {
|
||||
return api.post<RefResolution[]>('/refs/resolve', { refs })
|
||||
}
|
||||
@@ -22,21 +22,32 @@ import {
|
||||
type FeedbackTicket,
|
||||
} from '../api/feedback'
|
||||
import { getGerbil } from '../api/gerbils'
|
||||
import { REF_ROUTE, REF_TOKEN_RE, resolveRefs, type RefType } from '../api/refs'
|
||||
import { useApi } from '../hooks/useApi'
|
||||
import { useToast } from '../components/toast'
|
||||
import './tickets.css'
|
||||
|
||||
/** Aufgelöster Kurz-Verweis (Shortlink): null = unbekannt/mehrdeutig. */
|
||||
type ResolvedRef = { id: string; name: string } | null
|
||||
|
||||
/**
|
||||
* Nachschlage-Kontext für Ticket-Text-Links, damit RichText nicht durch jede Ebene
|
||||
* durchgereicht werden muss:
|
||||
* - ticketIds: bekannte Ticket-IDs (nackte GUID → Link auf das Ticket).
|
||||
* - gerbilNames: aufgelöste Tier-Namen je GerbilId (nackte GUID → Name + Link zur Akte).
|
||||
* Wert `undefined` = noch nicht geladen, `null` = nicht gefunden.
|
||||
* - refNames: aufgelöste Kurz-Verweise je `typ:code` (die KI kann `tier:3c5b747b` schreiben
|
||||
* → Name + Link). Wert `undefined` = noch nicht geladen, `null` = unbekannt/mehrdeutig.
|
||||
*/
|
||||
const TicketRefsContext = createContext<{
|
||||
ticketIds: Set<string>
|
||||
gerbilNames: Record<string, string | null>
|
||||
}>({ ticketIds: new Set(), gerbilNames: {} })
|
||||
refNames: Record<string, ResolvedRef>
|
||||
}>({ ticketIds: new Set(), gerbilNames: {}, refNames: {} })
|
||||
|
||||
/** Cache-Schlüssel für einen Kurz-Verweis. */
|
||||
function refKey(type: string, code: string): string {
|
||||
return `${type.toLowerCase()}:${code.replace(/-/g, '').toLowerCase()}`
|
||||
}
|
||||
|
||||
const t = de.feedback.tickets
|
||||
const ctx = de.feedback.contexts
|
||||
@@ -112,11 +123,13 @@ function TextWithBreaks({ value }: { value: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
/** Inline-Markdown: **fett**, `code`, [Label](url)-Links, Tier-/Ticket-IDs, Zeilenumbrüche. */
|
||||
/** Inline-Markdown: **fett**, `code`, [Label](url)-Links, Kurz-Verweise (tier:…), IDs, Umbrüche. */
|
||||
function InlineMarkdown({ text }: { text: string }) {
|
||||
const { ticketIds, gerbilNames } = useContext(TicketRefsContext)
|
||||
const { ticketIds, gerbilNames, refNames } = useContext(TicketRefsContext)
|
||||
// Gruppen: 1/2 = Markdown-Link, 3 = fett, 4 = code, 5/6 = Kurz-Verweis (typ/code), 7 = nackte GUID.
|
||||
const re = new RegExp(
|
||||
`\\[([^\\]]+)\\]\\(([^)]+)\\)|\\*\\*([^*]+)\\*\\*|\`([^\`]+)\`|(${GUID_RE})`,
|
||||
`\\[([^\\]]+)\\]\\(([^)]+)\\)|\\*\\*([^*]+)\\*\\*|\`([^\`]+)\`|` +
|
||||
`\\b(tier|kontakt|wurf|gehege):([0-9a-fA-F]{6,}(?:-[0-9a-fA-F-]+)?)|(${GUID_RE})`,
|
||||
'g',
|
||||
)
|
||||
const nodes: ReactNode[] = []
|
||||
@@ -146,8 +159,21 @@ function InlineMarkdown({ text }: { text: string }) {
|
||||
{m[4]}
|
||||
</code>,
|
||||
)
|
||||
} else if (m[5] !== undefined && m[6] !== undefined) {
|
||||
// Kurz-Verweis tier:/kontakt:/wurf:/gehege: → aufgelöster Name + Link, sonst Roh-Text.
|
||||
const type = m[5] as RefType
|
||||
const resolved = refNames[refKey(type, m[6])]
|
||||
if (resolved) {
|
||||
nodes.push(
|
||||
<Link key={k++} to={`${REF_ROUTE[type]}/${resolved.id}`} className="ticket-link">
|
||||
{resolved.name}
|
||||
</Link>,
|
||||
)
|
||||
} else {
|
||||
nodes.push(<TextWithBreaks key={k++} value={m[0]} />)
|
||||
}
|
||||
} else {
|
||||
const guid = m[5]
|
||||
const guid = m[7]
|
||||
const gn = gerbilNames[guid]
|
||||
if (ticketIds.has(guid)) {
|
||||
nodes.push(
|
||||
@@ -269,6 +295,8 @@ export default function TicketsPage() {
|
||||
const ticketIds = useMemo(() => new Set((rows ?? []).map((r) => r.id)), [rows])
|
||||
// Aufgelöste Tier-Namen je GerbilId (für nackte Tier-IDs im Text → Name + Link).
|
||||
const [gerbilNames, setGerbilNames] = useState<Record<string, string | null>>({})
|
||||
// Aufgelöste Kurz-Verweise (tier:/kontakt:/wurf:/gehege:) je `typ:code`.
|
||||
const [refNames, setRefNames] = useState<Record<string, ResolvedRef>>({})
|
||||
|
||||
// Gruppierung + Zählung je gefilterter Ansicht (für die Badges an den Filter-Chips).
|
||||
const counts = useMemo(() => {
|
||||
@@ -343,6 +371,49 @@ export default function TicketsPage() {
|
||||
}
|
||||
}, [candidateGerbilIds, gerbilNames])
|
||||
|
||||
// Kurz-Verweise (tier:/kontakt:/wurf:/gehege:) in den sichtbaren Tickets sammeln und
|
||||
// einmalig über POST /refs/resolve auflösen — so kann die KI im Text die Kurz-ID nutzen.
|
||||
const candidateRefs = useMemo(() => {
|
||||
const map = new Map<string, { type: RefType; code: string }>()
|
||||
const add = (txt?: string | null) => {
|
||||
if (!txt) return
|
||||
const re = new RegExp(REF_TOKEN_RE.source, REF_TOKEN_RE.flags)
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = re.exec(txt)) !== null) {
|
||||
const type = m[1] as RefType
|
||||
map.set(refKey(type, m[2]), { type, code: m[2] })
|
||||
}
|
||||
}
|
||||
for (const tk of visible) {
|
||||
add(tk.message); add(tk.question); add(tk.answer); add(tk.fixNote)
|
||||
for (const e of tk.thread) add(e.text)
|
||||
}
|
||||
return map
|
||||
}, [visible])
|
||||
|
||||
useEffect(() => {
|
||||
const missing = [...candidateRefs].filter(([key]) => !(key in refNames))
|
||||
if (missing.length === 0) return
|
||||
let cancelled = false
|
||||
resolveRefs(missing.map(([, r]) => r))
|
||||
.then((results) => {
|
||||
if (cancelled) return
|
||||
setRefNames((prev) => {
|
||||
const next = { ...prev }
|
||||
for (const r of results) {
|
||||
next[refKey(r.type, r.code)] = r.id && r.name ? { id: r.id, name: r.name } : null
|
||||
}
|
||||
return next
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
/* Auflösung fehlgeschlagen — Tokens bleiben Roh-Text. */
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [candidateRefs, refNames])
|
||||
|
||||
const emptyText: Record<TicketView, string> = {
|
||||
dialog: t.emptyDialog,
|
||||
open: t.emptyOpen,
|
||||
@@ -386,7 +457,7 @@ export default function TicketsPage() {
|
||||
{visible.length === 0 ? (
|
||||
<p className="tickets-empty">{emptyText[activeView]}</p>
|
||||
) : (
|
||||
<TicketRefsContext.Provider value={{ ticketIds, gerbilNames }}>
|
||||
<TicketRefsContext.Provider value={{ ticketIds, gerbilNames, refNames }}>
|
||||
<ul className="tickets-list">
|
||||
{visible.map((ticket) => (
|
||||
<TicketCard
|
||||
|
||||
Reference in New Issue
Block a user