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:
2026-06-23 07:23:03 +02:00
parent c7b054e909
commit 00dba91a5c
7 changed files with 407 additions and 7 deletions

View 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 })
}

View File

@@ -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